From 2e9ca52cdbd9687c2facb22736bc5ebd01586ab7 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 16:26:35 +0200 Subject: [PATCH] refactor: unify session creation form into CreateSessionForm component --- .../src/components/create-session-form.tsx | 283 ++++++++++++++++++ apps/web/src/components/instance-list.tsx | 71 +---- apps/web/src/pages/dashboard.tsx | 71 +---- apps/web/src/pages/sessions.tsx | 251 ++-------------- 4 files changed, 327 insertions(+), 349 deletions(-) create mode 100644 apps/web/src/components/create-session-form.tsx diff --git a/apps/web/src/components/create-session-form.tsx b/apps/web/src/components/create-session-form.tsx new file mode 100644 index 0000000..8f20af0 --- /dev/null +++ b/apps/web/src/components/create-session-form.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from "react"; +import { Icon } from "./icon"; +import { createInstance, startInstance, type ToolInstance } from "../api/sessions"; +import type { Project } from "../types"; +import type { GitRepository } from "../api/git_repositories"; +import type { ToolType } from "../api/tool_types"; +import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; + +interface CreateSessionFormProps { + projects: Project[]; + repositories: GitRepository[]; + toolTypes: ToolType[]; + fixedProjectId?: string; + fixedRepoId?: string; + showCloneMode?: boolean; + onProjectChange?: (projectId: string) => void; + onSuccess?: (instance: ToolInstance) => void; + onCancel?: () => void; + submitLabel?: string; + className?: string; +} + +export const CreateSessionForm = ({ + projects, + repositories, + toolTypes, + fixedProjectId, + fixedRepoId, + showCloneMode = false, + onProjectChange, + onSuccess, + onCancel, + submitLabel = "Create Session", + className = "", +}: CreateSessionFormProps) => { + const [selectedProject, setSelectedProject] = useState(fixedProjectId || ""); + const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || ""); + const [selectedToolType, setSelectedToolType] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); + const [branch, setBranch] = useState("main"); + const [sshKeys, setSshKeys] = useState([]); + + const [status, setStatus] = useState<"idle" | "creating" | "error">("idle"); + const [progress, setProgress] = useState(""); + const [error, setError] = useState(null); + + // Load SSH keys when clone mode is shown + useEffect(() => { + if (!showCloneMode) return; + const loadKeys = async () => { + try { + const keys = await listSSHKeys(); + setSshKeys(keys); + } catch { + // ignore + } + }; + void loadKeys(); + }, [showCloneMode]); + + // Filter repositories by selected project + const availableRepos = selectedProject + ? repositories.filter((r) => r.project_id === selectedProject) + : []; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + const projectId = fixedProjectId || selectedProject; + const repoId = fixedRepoId || selectedRepo; + + if (!projectId || !repoId || !selectedToolType) { + setError("Project, repository, and tool type are required"); + return; + } + + if (showCloneMode && cloneMode === "clone") { + const repo = repositories.find((r) => r.id === repoId); + if (!repo?.ssh_key_id) { + setError("Repository must have an SSH key assigned for clone mode"); + return; + } + } + + setStatus("creating"); + setProgress("Creating instance..."); + + try { + const instance = await createInstance( + projectId, + repoId, + selectedToolType, + displayName || undefined, + showCloneMode ? cloneMode : undefined, + showCloneMode && cloneMode === "clone" ? branch : undefined + ); + + setProgress("Starting container..."); + await startInstance(projectId, repoId, instance.id); + + // Reset form + if (!fixedProjectId) setSelectedProject(""); + if (!fixedRepoId) setSelectedRepo(""); + setSelectedToolType(""); + setDisplayName(""); + setCloneMode("mount"); + setBranch("main"); + setStatus("idle"); + + onSuccess?.(instance); + } catch { + setStatus("error"); + setError("Failed to create session"); + setProgress(""); + } + }; + + const isSubmitting = status === "creating"; + + return ( +
+ {isSubmitting && ( +
+
+ +

{progress || "Creating session..."}

+
+
+ )} + +
+
+ {!fixedProjectId && ( + + )} + + {!fixedRepoId && ( + + )} + + +
+ + {showCloneMode && ( +
+ + + {cloneMode === "clone" && ( + + )} +
+ )} + + + + {error &&

{error}

} + +
+ {onCancel && ( + + )} + +
+
+
+ ); +}; diff --git a/apps/web/src/components/instance-list.tsx b/apps/web/src/components/instance-list.tsx index d9ec05a..7cba02b 100644 --- a/apps/web/src/components/instance-list.tsx +++ b/apps/web/src/components/instance-list.tsx @@ -4,7 +4,6 @@ import { Icon } from "./icon"; import type { ToolInstance } from "../api/sessions"; import { checkInstanceHealth, - createInstance, deleteInstance, listInstances, recreateInstanceTunnel, @@ -13,6 +12,7 @@ import { stopInstance, } from "../api/sessions"; import type { ToolType } from "../api/tool_types"; +import { CreateSessionForm } from "./create-session-form"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; @@ -27,8 +27,6 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); - const [selectedToolType, setSelectedToolType] = useState(""); - const [displayName, setDisplayName] = useState(""); const [error, setError] = useState(null); // Stop confirmation @@ -83,18 +81,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps return () => clearInterval(interval); }, [instances, projectId, repoId]); - const handleCreate = async () => { - if (!selectedToolType) return; - setError(null); - try { - await createInstance(projectId, repoId, selectedToolType, displayName || undefined); - setShowCreate(false); - setSelectedToolType(""); - setDisplayName(""); - await loadInstances(); - } catch { - setError("Failed to create instance"); - } + const handleCreateSuccess = async () => { + setShowCreate(false); + await loadInstances(); }; const handleStart = async (instanceId: string) => { @@ -309,48 +298,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps

Launch Tool

-
- - -
- - -
-
+ setShowCreate(false)} + submitLabel="Launch" + />
)} diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index eea233a..77b46a1 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -2,13 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { getDashboardSummary, type DashboardSummary } from "../api/dashboard"; -import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions"; +import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions"; import { listProjects } from "../api/projects"; import { listRepositories, type GitRepository } from "../api/git_repositories"; import { listToolTypes, type ToolType } from "../api/tool_types"; import { updateUserConfig } from "../api/settings"; import type { Project } from "../types"; import { Icon } from "../components/icon"; +import { CreateSessionForm } from "../components/create-session-form"; type HomeStatus = "loading" | "ready" | "error"; @@ -29,10 +30,6 @@ export const HomePage = () => { const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); const [selectedProject, setSelectedProject] = useState(""); - const [selectedRepo, setSelectedRepo] = useState(""); - const [selectedToolType, setSelectedToolType] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle"); const [actionBusy, setActionBusy] = useState(null); const [stopConfirmId, setStopConfirmId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); @@ -133,24 +130,10 @@ export const HomePage = () => { [safeSessions] ); - const handleCreate = async (event: React.FormEvent) => { - event.preventDefault(); - if (!selectedProject || !selectedRepo || !selectedToolType) return; - - setSaveState("saving"); - try { - const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined); - await startInstance(selectedProject, selectedRepo, instance.id); - await updateUserConfig({ last_session_id: instance.id }); - setDisplayName(""); - setSelectedProject(""); - setSelectedRepo(""); - setSelectedToolType(""); - setSaveState("idle"); - await loadHome(); - } catch { - setSaveState("error"); - } + const handleCreateSuccess = async (instance: { id: string }) => { + await updateUserConfig({ last_session_id: instance.id }); + setSelectedProject(""); + await loadHome(); }; const handleOpen = (session: SessionView) => { @@ -358,41 +341,13 @@ export const HomePage = () => {

Start a session

-
-
- - - -
- -
- - {saveState === "error" && Failed to create session} -
-
+ setSelectedProject(projectId)} + onSuccess={handleCreateSuccess} + /> {recentSessions.length > 0 && ( diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index 225f1e8..2ba2e54 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -14,13 +14,11 @@ import { recreateInstanceTunnel, } from "../api/sessions"; import { listToolTypes, type ToolType } from "../api/tool_types"; -import { createInstance } from "../api/sessions"; import { getUserConfig, updateUserConfig } from "../api/settings"; -import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { Icon } from "../components/icon"; +import { CreateSessionForm } from "../components/create-session-form"; type SessionsStatus = "loading" | "ready" | "error"; -type CreateStatus = "idle" | "creating" | "error"; export const SessionsPage = () => { const navigate = useNavigate(); @@ -31,18 +29,7 @@ export const SessionsPage = () => { const [projects, setProjects] = useState([]); const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); - const [selectedProject, setSelectedProject] = useState(""); - const [selectedRepo, setSelectedRepo] = useState(""); - const [selectedToolType, setSelectedToolType] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [createStatus, setCreateStatus] = useState("idle"); - const [createError, setCreateError] = useState(null); - const [createProgress, setCreateProgress] = useState(""); - - const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); - const [branch, setBranch] = useState("main"); - const [sshKeys, setSshKeys] = useState([]); const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); @@ -107,17 +94,7 @@ export const SessionsPage = () => { void loadToolTypes(); }, []); - useEffect(() => { - const loadSshKeys = async () => { - try { - const data = await listSSHKeys(); - setSshKeys(data); - } catch { - // ignore - } - }; - void loadSshKeys(); - }, []); + // Poll health every 30 seconds for active instances useEffect(() => { @@ -191,58 +168,10 @@ export const SessionsPage = () => { [sessions, lastSessionId] ); - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - setCreateError(null); - - if (!selectedProject || !selectedRepo || !selectedToolType) { - setCreateError("Project, repository, and tool type are required"); - return; - } - - if (cloneMode === "clone") { - const repo = repositories.find((r) => r.id === selectedRepo); - if (!repo?.ssh_key_id) { - setCreateError("Repository must have an SSH key assigned for clone mode"); - return; - } - } - - setCreateStatus("creating"); - setCreateProgress("Creating instance..."); - try { - const instance = await createInstance( - selectedProject, - selectedRepo, - selectedToolType, - displayName || undefined, - cloneMode, - cloneMode === "clone" ? branch : undefined - ); - - setCreateProgress("Starting container..."); - // Auto-start the instance - await startInstance(selectedProject, selectedRepo, instance.id); - - await updateUserConfig({ last_session_id: instance.id }); - setCreateStatus("idle"); - setCreateProgress(""); - setSelectedProject(""); - setSelectedRepo(""); - setSelectedToolType(""); - setDisplayName(""); - setCloneMode("mount"); - setBranch("main"); - await loadSessions(); - } catch (error) { - setCreateStatus("error"); - setCreateProgress(""); - const axiosError = error as { response?: { data?: { detail?: string } } }; - const message = axiosError.response?.data?.detail; - setCreateError( - typeof message === "string" ? message : "Failed to create session" - ); - } + const handleCreateSuccess = async (instance: { id: string }) => { + await updateUserConfig({ last_session_id: instance.id }); + setSelectedProject(""); + await loadSessions(); }; const handleStop = async (sessionId: string, projectId: string, repoId: string) => { @@ -630,164 +559,18 @@ export const SessionsPage = () => { )} {/* Create Session */} -
- {createStatus === "creating" && ( -
-
- -

{createProgress || "Creating session..."}

-
-
- )} +

Create New Session

-
-
- - - - - -
- -
- - - {cloneMode === "clone" && ( - <> - - - {selectedRepo && ( -
- {(() => { - const repo = repositories.find((r) => r.id === selectedRepo); - if (!repo) return null; - if (repo.ssh_key_id) { - const key = sshKeys.find((k) => k.id === repo.ssh_key_id); - return ( - - SSH key: {key?.name || "Assigned"} - - ); - } - return ( - - No SSH key assigned to this repository. Clone mode requires an SSH key. - - ); - })()} -
- )} - - )} -
- - - - {createError &&

{createError}

} - -
- -
-
+ { + setSelectedProject(projectId); + }} + onSuccess={handleCreateSuccess} + />
{/* Dirty Delete Confirmation Modal */}