diff --git a/apps/web/src/components/features/session/CreateSessionForm.tsx b/apps/web/src/components/features/session/CreateSessionForm.tsx new file mode 100644 index 0000000..bf4f94f --- /dev/null +++ b/apps/web/src/components/features/session/CreateSessionForm.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from "react"; +import { listRepositories } from "@/api/git_repositories"; +import { + createInstance, + startInstance, +} from "@/api/sessions"; +import { updateUserConfig } from "@/api/settings"; +import { Icon } from "@/components/icon"; +import type { Project } from "@/types/project"; +import type { GitRepository } from "@/types/git-repository"; +import type { ToolType } from "@/types/tool-type"; + +interface CreateSessionFormProps { + projects: Project[]; + toolTypes: ToolType[]; + onCreated: () => void; +} + +type CreateStatus = "idle" | "creating" | "error"; + +export const CreateSessionForm: React.FC = ({ + projects, + toolTypes, + onCreated, +}) => { + 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 [repositories, setRepositories] = useState([]); + + useEffect(() => { + if (!selectedProject) { + setRepositories([]); + return; + } + const loadRepos = async () => { + try { + const data = await listRepositories(selectedProject); + setRepositories(data); + } catch { + setRepositories([]); + } + }; + void loadRepos(); + }, [selectedProject]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setCreateError(null); + + if (!selectedProject || !selectedRepo || !selectedToolType) { + setCreateError("Project, repository, and tool type are required"); + return; + } + + setCreateStatus("creating"); + try { + const instance = await createInstance( + selectedProject, + selectedRepo, + selectedToolType, + displayName || undefined, + ); + await startInstance(selectedProject, selectedRepo, instance.id); + await updateUserConfig({ last_session_id: instance.id }); + setCreateStatus("idle"); + setSelectedProject(""); + setSelectedRepo(""); + setSelectedToolType(""); + setDisplayName(""); + onCreated(); + } catch { + setCreateStatus("error"); + setCreateError("Failed to create session"); + } + }; + + return ( +
+

Create New Session

+
+
+ + + + + +
+ + + + {createError &&

{createError}

} + +
+ +
+
+
+ ); +}; diff --git a/apps/web/src/components/features/session/SessionCard.tsx b/apps/web/src/components/features/session/SessionCard.tsx new file mode 100644 index 0000000..64642bd --- /dev/null +++ b/apps/web/src/components/features/session/SessionCard.tsx @@ -0,0 +1,204 @@ +import React from "react"; +import { Icon } from "@/components/icon"; +import type { Session } from "@/types/session"; + +interface SessionCardProps { + session: Session; + variant: "active" | "recent"; + tunnelHealth?: { healthy: boolean; status_code: number | null; error?: string } | null; + isRecreating?: boolean; + isStopConfirming?: boolean; + isDeleteConfirming?: boolean; + onOpen: () => void; + onStop: () => void; + onDelete: () => void; + onRecreateTunnel: () => void; + onCancelStop: () => void; + onCancelDelete: () => void; +} + +export const SessionCard: React.FC = ({ + session, + variant, + tunnelHealth, + isRecreating, + isStopConfirming, + isDeleteConfirming, + onOpen, + onStop, + onDelete, + onRecreateTunnel, + onCancelStop, + onCancelDelete, +}) => { + const displayName = session.display_name || session.tool_type_name || "Unnamed Session"; + + if (variant === "recent") { + return ( +
+
+ {displayName} + + {session.tool_type_name} · {session.project_name} + +
+
+ {session.url ? ( + + Open + + ) : ( + + )} + {isDeleteConfirming ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); + } + + // Active variant + return ( +
+
+

{displayName}

+

+ {session.tool_type_name} · {session.project_name} +

+ {session.url && ( +

+ + {session.url} + +

+ )} + {session.status} + {tunnelHealth && !tunnelHealth.healthy && ( + tunnel error + )} +
+
+ {session.url ? ( + + + Open + + ) : ( + + )} + {tunnelHealth && !tunnelHealth.healthy && ( + + )} + {isStopConfirming ? ( +
+ Stop? + + +
+ ) : ( + + )} + {isDeleteConfirming ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/components/features/session/SessionList.tsx b/apps/web/src/components/features/session/SessionList.tsx new file mode 100644 index 0000000..3e93e1c --- /dev/null +++ b/apps/web/src/components/features/session/SessionList.tsx @@ -0,0 +1,194 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + stopInstance, + deleteInstance, + checkInstanceHealth, + recreateInstanceTunnel, +} from "@/api/sessions"; +import type { Session } from "@/types/session"; +import { SessionCard } from "./SessionCard"; + +interface SessionListProps { + sessions: Session[]; + variant: "active" | "recent"; + onSessionChange?: () => void; + onOpen?: (session: Session) => void; +} + +export const SessionList: React.FC = ({ + sessions, + variant, + onSessionChange, + onOpen, +}) => { + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [stopConfirmId, setStopConfirmId] = useState(null); + const [tunnelHealth, setTunnelHealth] = useState< + Record< + string, + { healthy: boolean; status_code: number | null; error?: string } + > + >({}); + const [recreatingId, setRecreatingId] = useState(null); + + // Poll tunnel health every 30 seconds for running instances (active only) + useEffect(() => { + if (variant !== "active") return; + + const checkHealth = async () => { + const runningSessions = sessions.filter( + (s) => s.status === "running" && s.url, + ); + for (const session of runningSessions) { + 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, + status_code: null, + error: "check failed", + }, + })); + } + } + }; + + void checkHealth(); + const interval = setInterval(() => void checkHealth(), 30000); + return () => clearInterval(interval); + }, [sessions, variant]); + + const handleStop = useCallback( + async (session: Session) => { + try { + await stopInstance( + session.project_id, + session.repository_id, + session.id, + ); + setStopConfirmId(null); + onSessionChange?.(); + } catch { + setStopConfirmId(null); + } + }, + [onSessionChange], + ); + + const handleDelete = useCallback( + async (session: Session) => { + try { + await deleteInstance( + session.project_id, + session.repository_id, + session.id, + ); + setDeleteConfirmId(null); + onSessionChange?.(); + } catch { + setDeleteConfirmId(null); + } + }, + [onSessionChange], + ); + + const handleRecreateTunnel = useCallback( + async (session: Session) => { + setRecreatingId(session.id); + try { + await recreateInstanceTunnel( + session.project_id, + session.repository_id, + session.id, + ); + onSessionChange?.(); + } catch { + // ignore + } finally { + setRecreatingId(null); + } + }, + [onSessionChange], + ); + + const handleOpen = useCallback( + (session: Session) => { + if (session.url) { + window.open(session.url, "_blank", "noopener,noreferrer"); + } else { + onOpen?.(session); + } + }, + [onOpen], + ); + + if (sessions.length === 0) { + return ( +

+ {variant === "active" ? "No active sessions" : "No recent sessions"} +

+ ); + } + + return ( + <> + {variant === "active" ? ( +
+ {sessions.map((session) => ( + handleOpen(session)} + onStop={() => + stopConfirmId === session.id + ? handleStop(session) + : setStopConfirmId(session.id) + } + onDelete={() => + deleteConfirmId === session.id + ? handleDelete(session) + : setDeleteConfirmId(session.id) + } + onRecreateTunnel={() => handleRecreateTunnel(session)} + onCancelStop={() => setStopConfirmId(null)} + onCancelDelete={() => setDeleteConfirmId(null)} + /> + ))} +
+ ) : ( +
+ {sessions.map((session) => ( + handleOpen(session)} + onStop={() => {}} + onDelete={() => + deleteConfirmId === session.id + ? handleDelete(session) + : setDeleteConfirmId(session.id) + } + onRecreateTunnel={() => {}} + onCancelStop={() => {}} + onCancelDelete={() => setDeleteConfirmId(null)} + /> + ))} +
+ )} + + ); +}; diff --git a/apps/web/src/components/features/session/index.ts b/apps/web/src/components/features/session/index.ts new file mode 100644 index 0000000..7ce8008 --- /dev/null +++ b/apps/web/src/components/features/session/index.ts @@ -0,0 +1,3 @@ +export { CreateSessionForm } from "./CreateSessionForm"; +export { SessionList } from "./SessionList"; +export { SessionCard } from "./SessionCard"; diff --git a/apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx b/apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx new file mode 100644 index 0000000..2620592 --- /dev/null +++ b/apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useState } from "react"; +import { Icon } from "../../../components/icon"; +import { + createConfigFolder, + deleteConfigFolder, + listConfigFolders, + updateConfigFolder, + type ConfigFolder, + type CreateConfigFolderRequest, + type UpdateConfigFolderRequest, +} from "../../../api/config_folders"; + +export const ConfigFoldersTab = () => { + const [folders, setFolders] = useState([]); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [selectedFolder, setSelectedFolder] = useState(null); + const [folderForm, setFolderForm] = useState({ + name: "", + description: "", + mount_path: "/home/user", + files_json: "{}", + is_active: true, + }); + const [folderError, setFolderError] = useState(null); + const [showFolderForm, setShowFolderForm] = useState(false); + + const loadFolders = useCallback(async () => { + setStatus("loading"); + try { + const data = await listConfigFolders(); + setFolders(data); + setStatus("ready"); + } catch { + setStatus("error"); + } + }, []); + + useEffect(() => { + void loadFolders(); + }, [loadFolders]); + + const openCreateFolder = () => { + setFolderForm({ + name: "", + description: "", + mount_path: "/home/user", + files_json: "{}", + is_active: true, + }); + setFolderError(null); + setShowFolderForm(true); + setSelectedFolder(null); + }; + + const openEditFolder = (folder: ConfigFolder) => { + setFolderForm({ + name: folder.name, + description: folder.description || "", + mount_path: folder.mount_path, + files_json: JSON.stringify(folder.files, null, 2), + is_active: folder.is_active, + }); + setFolderError(null); + setShowFolderForm(true); + setSelectedFolder(folder); + }; + + const handleFolderSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFolderError(null); + + if (!folderForm.name.trim() || !folderForm.mount_path.trim()) { + setFolderError("Name and mount path are required"); + return; + } + + let files: Record | undefined; + try { + if (folderForm.files_json.trim() && folderForm.files_json.trim() !== "{}") { + files = JSON.parse(folderForm.files_json); + } + } catch { + setFolderError("Files must be valid JSON object"); + return; + } + + const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = { + name: folderForm.name.trim(), + description: folderForm.description.trim() || undefined, + mount_path: folderForm.mount_path.trim(), + files, + is_active: folderForm.is_active, + }; + + try { + if (selectedFolder) { + await updateConfigFolder(selectedFolder.id, data); + } else { + await createConfigFolder(data as CreateConfigFolderRequest); + } + setShowFolderForm(false); + setSelectedFolder(null); + await loadFolders(); + } catch (err) { + const axiosError = err as { response?: { data?: { detail?: string } } }; + setFolderError(axiosError?.response?.data?.detail || "Failed to save folder"); + } + }; + + const handleDeleteFolder = async (id: string) => { + if (!window.confirm("Delete this config folder?")) return; + try { + await deleteConfigFolder(id); + await loadFolders(); + } catch { + alert("Failed to delete folder"); + } + }; + + if (status === "loading") { + return

Loading Config Folders...

; + } + + if (status === "error") { + return ( +
+

Failed to load config folders.

+ +
+ ); + } + + return ( +
+
+

Config Folders

+ +
+ + {showFolderForm && ( +
+

{selectedFolder ? "Edit" : "Create"} Config Folder

+
+
+ + setFolderForm({ ...folderForm, name: e.target.value })} + placeholder="e.g., my-dotfiles" + className="form-input" + required + /> +
+ +
+ + setFolderForm({ ...folderForm, description: e.target.value })} + placeholder="Optional description" + className="form-input" + /> +
+ +
+ + setFolderForm({ ...folderForm, mount_path: e.target.value })} + placeholder="e.g., /home/user" + className="form-input" + required + /> +
+ +
+ +