From 549d13f4695b66469a74bf335454eb9db914d365 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 24 May 2026 12:03:16 +0000 Subject: [PATCH] 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 --- apps/web/src/components/session-card.tsx | 238 +++++++++++ apps/web/src/components/session-list.tsx | 125 ++++++ apps/web/src/pages/dashboard.tsx | 127 ++---- apps/web/src/pages/sessions.tsx | 373 +++--------------- .../changes/session-list-overhaul/tasks.md | 46 +-- 5 files changed, 471 insertions(+), 438 deletions(-) create mode 100644 apps/web/src/components/session-card.tsx create mode 100644 apps/web/src/components/session-list.tsx diff --git a/apps/web/src/components/session-card.tsx b/apps/web/src/components/session-card.tsx new file mode 100644 index 0000000..a1e6c88 --- /dev/null +++ b/apps/web/src/components/session-card.tsx @@ -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 = { + 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 ( +
+
+
+
+

{session.display_name}

+
+ {status.label} + {hasTunnelError && ( + Tunnel Error + )} + {hasAppError && ( + App Error {tunnelHealth?.tunnel_status_code} + )} +
+
+

+ {session.tool_type_name} + {session.project_name && ` · ${session.project_name}`} + {session.repository_name && ` · ${session.repository_name}`} +

+ {session.clone_mode && ( +

+ + {session.clone_mode === "clone" + ? `Clone${session.branch ? ` (${session.branch})` : ""}` + : "Mount"} +

+ )} + {session.url && ( +

+ + {session.url} + +

+ )} + {session.port && session.status === "running" && ( +

Port: {session.port}

+ )} + {session.created_at && ( +

+ Created: {new Date(session.created_at).toLocaleDateString()} +

+ )} +
+
+ +
+ {isActive && ( + <> + {session.url ? ( + + + Open + + ) : ( + + )} + + {hasTunnelError && onRecreateTunnel && ( + + )} + + {showStopConfirm ? ( +
+ Stop? + + +
+ ) : ( + + )} + + )} + + {!isActive && onStart && ( + + )} + + {showDeleteConfirm ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/session-list.tsx b/apps/web/src/components/session-list.tsx new file mode 100644 index 0000000..16af314 --- /dev/null +++ b/apps/web/src/components/session-list.tsx @@ -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; + 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 ( +
+ {sessions.length === 0 ? ( +

{emptyMessage}

+ ) : ( + sessions.map((session) => ( + + )) + )} +
+ ); + } + + return ( +
+ {/* Active Sessions */} +
+
+

{activeTitle}

+ {activeSessions.length > 0 && ( + {activeSessions.length} + )} +
+ {activeSessions.length === 0 ? ( +

No active sessions

+ ) : ( +
+ {activeSessions.map((session) => ( + + ))} +
+ )} +
+ + {/* Recent Sessions */} + {recentSessions.length > 0 && ( +
+
+

{recentTitle}

+ {recentSessions.length} +
+
+ {recentSessions.map((session) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index 77b46a1..199eb36 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -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([]); const [selectedProject, setSelectedProject] = useState(""); const [actionBusy, setActionBusy] = useState(null); - const [stopConfirmId, setStopConfirmId] = useState(null); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [tunnelHealth, setTunnelHealth] = useState>({}); 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 (
@@ -237,74 +237,20 @@ export const HomePage = () => {

Open sessions

-

{activeSessions.length}

+

{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}

- {activeSessions.length === 0 ? ( -

No active sessions right now.

- ) : ( -
- {activeSessions.map((session) => { - const health = tunnelHealth[session.id]; - const isUnhealthy = health && !health.healthy; - return ( -
-
-
-

{session.display_name}

-
- {isUnhealthy && ( - ! - )} - {session.status} -
-
-

{session.project_name} · {session.repository_name}

-

{session.tool_type_name}

-
-
- - - {stopConfirmId === session.id ? ( -
- Stop? - - -
- ) : ( - - )} - {deleteConfirmId === session.id ? ( -
- Delete? - - -
- ) : ( - - )} -
-
- ); - })} -
- )} +
@@ -350,25 +296,24 @@ export const HomePage = () => { />
- {recentSessions.length > 0 && ( + {safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (

Recent sessions

-

{recentSessions.length}

+

{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}

-
- {recentSessions.map((session) => ( -
-
- {session.display_name} - {session.project_name} · {session.tool_type_name} -
- -
- ))} -
+
)} diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index 66e8c78..3f4a88a 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -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(null); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); - const [stopConfirmId, setStopConfirmId] = useState(null); - const [tunnelHealth, setTunnelHealth] = useState>({}); - const [recreatingId, setRecreatingId] = useState(null); - const [expandedProbeId, setExpandedProbeId] = useState(null); + const [tunnelHealth, setTunnelHealth] = useState>({}); const [loadingSessionId, setLoadingSessionId] = useState(null); const [loadingAction, setLoadingAction] = useState(""); @@ -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 && (

Last Session

-
-
-

{lastSession.display_name}

-

- {lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name} -

- {lastSession.url && ( -

- - {lastSession.url} - -

- )} - {lastSession.status} -
-
- {lastSession.url ? ( - - - Open - - ) : ( - - )} -
-
+
)} - {/* Active Sessions */} -
+ {/* Session List */} +
{loadingSessionId && (
@@ -326,250 +291,17 @@ export const SessionsPage = () => {
)} -

- Active Sessions - {activeSessions.length > 0 && ( - {activeSessions.length} - )} -

- {activeSessions.length === 0 ? ( -

No active sessions

- ) : ( -
- {activeSessions.map((session) => { - const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web"); - return ( -
-
-

{session.display_name}

-

- {session.tool_type_name} · {session.project_name} -

- {session.created_at && ( -

- Started: {new Date(session.created_at).toLocaleString()} -

- )} - {session.clone_mode && ( -

- Repo: {session.clone_mode === "clone" ? `clone (${session.branch || "main"})` : "mount"} -

- )} - {session.url && ( -

- - {session.url} - -

- )} - {session.status} - {session.status === "starting" && ( - starting... - )} - {session.status === "probing" && ( - checking... - )} - {!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && ( - tunnel error - )} - {!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && ( - app error ({tunnelHealth[session.id].tunnel_status_code}) - )} - {!isTerminalOnly && tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && ( -
- - {expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && ( -
-                              {tunnelHealth[session.id].last_probe_output}
-                            
- )} -
- )} -
-
- {session.url ? ( - - - Open - - ) : ( - - )} - {!isTerminalOnly && tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && ( - - )} - {stopConfirmId === session.id ? ( -
- Stop? - - -
- ) : ( - - )} - {deleteConfirmId === session.id ? ( -
- - -
- ) : ( - - )} -
-
- )})} -
- )} +
- {/* Recent Sessions */} - {recentSessions.length > 0 && ( -
-

Recent Sessions

-
- {recentSessions.map((session) => ( -
-
- {session.display_name} - - {session.tool_type_name} · {session.project_name} - -
-
- {session.url ? ( - - Open - - ) : ( - - )} - {deleteConfirmId === session.id ? ( -
- - -
- ) : ( - - )} -
-
- ))} -
-
- )} - {/* Create Session */}

Create New Session

@@ -612,14 +344,7 @@ export const SessionsPage = () => {