From ae420708f26c0f371a739042b6c4b78492ae7c05 Mon Sep 17 00:00:00 2001
From: Developer
Date: Tue, 2 Jun 2026 21:49:20 +0000
Subject: [PATCH] refactor: split tool-workshop page into tab components (Task
4.1)
- Extract ToolTypesTab, ToolConfigsTab, ConfigFoldersTab from inline page
- Each tab is self-contained with own state, API calls, and forms
- Slim page to 77 lines (tab switcher + composition only)
- Add barrel export for tool-workshop feature components
- Add tsconfig path alias for @/* imports
Quality gates: tsc (pass), eslint (pass)
Refs: repo-restructure Task 4.1
---
.../features/session/CreateSessionForm.tsx | 173 +++
.../features/session/SessionCard.tsx | 204 ++++
.../features/session/SessionList.tsx | 194 ++++
.../src/components/features/session/index.ts | 3 +
.../tool-workshop/ConfigFoldersTab.tsx | 244 ++++
.../features/tool-workshop/ToolConfigsTab.tsx | 381 ++++++
.../features/tool-workshop/ToolTypesTab.tsx | 417 +++++++
.../features/tool-workshop/index.ts | 3 +
apps/web/src/components/ui/ConfirmDialog.tsx | 48 +
apps/web/src/components/ui/index.ts | 1 +
apps/web/src/pages/sessions.tsx | 528 +--------
apps/web/src/pages/tool-workshop.tsx | 1033 +----------------
apps/web/tsconfig.json | 6 +-
.../repo-restructure/apply-3.5-report.md | 62 +
.../repo-restructure/apply-4.1-report.md | 56 +
15 files changed, 1881 insertions(+), 1472 deletions(-)
create mode 100644 apps/web/src/components/features/session/CreateSessionForm.tsx
create mode 100644 apps/web/src/components/features/session/SessionCard.tsx
create mode 100644 apps/web/src/components/features/session/SessionList.tsx
create mode 100644 apps/web/src/components/features/session/index.ts
create mode 100644 apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx
create mode 100644 apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx
create mode 100644 apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx
create mode 100644 apps/web/src/components/features/tool-workshop/index.ts
create mode 100644 apps/web/src/components/ui/ConfirmDialog.tsx
create mode 100644 openspec/changes/repo-restructure/apply-3.5-report.md
create mode 100644 openspec/changes/repo-restructure/apply-4.1-report.md
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 (
+
+ );
+};
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
+
+
+ )}
+
+
+ {folders.map((folder) => (
+
+
+
{folder.name}
+ {folder.is_active && Active}
+
+
{folder.description || "No description"}
+
+ Mount: {folder.mount_path}
+ Files: {Object.keys(folder.files || {}).length}
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx b/apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx
new file mode 100644
index 0000000..02c6cc2
--- /dev/null
+++ b/apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx
@@ -0,0 +1,381 @@
+import { useCallback, useEffect, useState } from "react";
+import { Icon } from "../../../components/icon";
+import {
+ createToolConfig,
+ deleteToolConfig,
+ listToolConfigs,
+ updateToolConfig,
+ type CreateToolConfigRequest,
+ type ToolConfig,
+} from "../../../api/tool_configs";
+import { listToolTypes, type ToolType } from "../../../api/tool_types";
+
+export const ToolConfigsTab = () => {
+ const [configs, setConfigs] = useState([]);
+ const [toolTypes, setToolTypes] = useState([]);
+ const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
+ const [selectedConfig, setSelectedConfig] = useState(null);
+ const [configForm, setConfigForm] = useState({
+ tool_type_id: "",
+ key: "",
+ value: "",
+ config_type: "env",
+ file_path: "",
+ port_override: "",
+ start_command: "",
+ working_directory: "",
+ env_vars_json: "{}",
+ volumes_json: "[]",
+ });
+ const [configError, setConfigError] = useState(null);
+ const [showConfigForm, setShowConfigForm] = useState(false);
+
+ const loadData = useCallback(async () => {
+ setStatus("loading");
+ try {
+ const [cfgs, types] = await Promise.all([
+ listToolConfigs(),
+ listToolTypes(),
+ ]);
+ setConfigs(cfgs);
+ setToolTypes(types);
+ setStatus("ready");
+ } catch {
+ setStatus("error");
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadData();
+ }, [loadData]);
+
+ const openCreateConfig = () => {
+ setConfigForm({
+ tool_type_id: toolTypes[0]?.id || "",
+ key: "",
+ value: "",
+ config_type: "env",
+ file_path: "",
+ port_override: "",
+ start_command: "",
+ working_directory: "",
+ env_vars_json: "{}",
+ volumes_json: "[]",
+ });
+ setConfigError(null);
+ setShowConfigForm(true);
+ setSelectedConfig(null);
+ };
+
+ const openEditConfig = (config: ToolConfig) => {
+ setConfigForm({
+ tool_type_id: config.tool_type_id,
+ key: config.key,
+ value: config.value,
+ config_type: config.config_type,
+ file_path: config.file_path || "",
+ port_override: config.port_override?.toString() || "",
+ start_command: config.start_command || "",
+ working_directory: config.working_directory || "",
+ env_vars_json: config.environment_variables ? JSON.stringify(config.environment_variables, null, 2) : "{}",
+ volumes_json: config.volumes ? JSON.stringify(config.volumes, null, 2) : "[]",
+ });
+ setConfigError(null);
+ setShowConfigForm(true);
+ setSelectedConfig(config);
+ };
+
+ const handleConfigSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setConfigError(null);
+
+ if (!configForm.tool_type_id || !configForm.key.trim()) {
+ setConfigError("Tool type and key are required");
+ return;
+ }
+
+ let envVars: Record | undefined;
+ let volumes: Array<{ source: string; target: string; type?: string }> | undefined;
+
+ try {
+ if (configForm.env_vars_json.trim() && configForm.env_vars_json.trim() !== "{}") {
+ envVars = JSON.parse(configForm.env_vars_json);
+ }
+ } catch {
+ setConfigError("Environment variables must be valid JSON");
+ return;
+ }
+
+ try {
+ if (configForm.volumes_json.trim() && configForm.volumes_json.trim() !== "[]") {
+ volumes = JSON.parse(configForm.volumes_json);
+ }
+ } catch {
+ setConfigError("Volumes must be valid JSON array");
+ return;
+ }
+
+ const data: CreateToolConfigRequest = {
+ tool_type_id: configForm.tool_type_id,
+ key: configForm.key.trim(),
+ value: configForm.value,
+ config_type: configForm.config_type,
+ file_path: configForm.config_type === "file" ? configForm.file_path : undefined,
+ port_override: configForm.port_override ? Number(configForm.port_override) : undefined,
+ start_command: configForm.start_command.trim() || undefined,
+ working_directory: configForm.working_directory.trim() || undefined,
+ environment_variables: envVars,
+ volumes,
+ };
+
+ try {
+ if (selectedConfig) {
+ await updateToolConfig(selectedConfig.id, data);
+ } else {
+ await createToolConfig(data);
+ }
+ setShowConfigForm(false);
+ setSelectedConfig(null);
+ await loadData();
+ } catch (err) {
+ const axiosError = err as { response?: { data?: { detail?: string } } };
+ setConfigError(axiosError?.response?.data?.detail || "Failed to save config");
+ }
+ };
+
+ const handleDeleteConfig = async (id: string) => {
+ if (!window.confirm("Delete this config?")) return;
+ try {
+ await deleteToolConfig(id);
+ await loadData();
+ } catch {
+ alert("Failed to delete config");
+ }
+ };
+
+ if (status === "loading") {
+ return Loading Configurations...
;
+ }
+
+ if (status === "error") {
+ return (
+
+
Failed to load configurations.
+
+
+ );
+ }
+
+ return (
+
+
+
Tool Configurations
+
+
+
+ {showConfigForm && (
+
+
{selectedConfig ? "Edit" : "Add"} Config
+
+
+ )}
+
+
+ {configs.length === 0 ? (
+
No configurations yet.
+ ) : (
+ configs.map((config) => (
+
+
+
+ {config.key}
+
+ {config.config_type}
+
+
+
+ {config.config_type === "file" && config.file_path
+ ? `File: ${config.file_path}`
+ : "Environment variable"}
+ {config.port_override && ` · Port: ${config.port_override}`}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx b/apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx
new file mode 100644
index 0000000..725a590
--- /dev/null
+++ b/apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx
@@ -0,0 +1,417 @@
+import { useCallback, useEffect, useState } from "react";
+import { Icon } from "../../../components/icon";
+import {
+ createToolType,
+ deleteToolType,
+ listToolTypes,
+ updateToolType,
+ type CreateToolTypeRequest,
+ type ReadinessProbe,
+ type ToolType,
+ type UpdateToolTypeRequest,
+} from "../../../api/tool_types";
+
+export const ToolTypesTab = () => {
+ const [toolTypes, setToolTypes] = useState([]);
+ const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
+ const [selectedToolType, setSelectedToolType] = useState(null);
+ const [toolTypeForm, setToolTypeForm] = useState({
+ name: "",
+ display_name: "",
+ description: "",
+ category: "",
+ interfaces: [] as string[],
+ default_port: "",
+ definition_type: "compose" as "compose" | "dockerfile",
+ compose_template: "",
+ dockerfile_template: "",
+ readiness_command: "",
+ readiness_timeout: "30",
+ readiness_interval: "2",
+ required_variables: "",
+ });
+ const [toolTypeError, setToolTypeError] = useState(null);
+ const [showToolTypeForm, setShowToolTypeForm] = useState(false);
+
+ const loadToolTypes = useCallback(async () => {
+ setStatus("loading");
+ try {
+ const data = await listToolTypes();
+ setToolTypes(data);
+ setStatus("ready");
+ } catch {
+ setStatus("error");
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadToolTypes();
+ }, [loadToolTypes]);
+
+ const openCreateToolType = () => {
+ setToolTypeForm({
+ name: "",
+ display_name: "",
+ description: "",
+ category: "",
+ interfaces: [],
+ default_port: "",
+ definition_type: "compose",
+ compose_template: "",
+ dockerfile_template: "",
+ readiness_command: "",
+ readiness_timeout: "30",
+ readiness_interval: "2",
+ required_variables: "",
+ });
+ setToolTypeError(null);
+ setShowToolTypeForm(true);
+ setSelectedToolType(null);
+ };
+
+ const openEditToolType = (toolType: ToolType) => {
+ setToolTypeForm({
+ name: toolType.name,
+ display_name: toolType.display_name,
+ description: toolType.description || "",
+ category: toolType.category || "",
+ interfaces: toolType.interfaces || [],
+ default_port: toolType.default_port?.toString() || "",
+ definition_type: toolType.definition_type || "compose",
+ compose_template: toolType.compose_template || "",
+ dockerfile_template: toolType.dockerfile_template || "",
+ readiness_command: toolType.readiness_probe?.command || "",
+ readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
+ readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
+ required_variables: toolType.required_variables?.join(", ") || "",
+ });
+ setToolTypeError(null);
+ setShowToolTypeForm(true);
+ setSelectedToolType(toolType);
+ };
+
+ const handleToolTypeSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setToolTypeError(null);
+
+ if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
+ setToolTypeError("Name and display name are required");
+ return;
+ }
+
+ if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) {
+ setToolTypeError("Default port is required and must be a number");
+ return;
+ }
+
+ const template = toolTypeForm.definition_type === "compose"
+ ? toolTypeForm.compose_template
+ : toolTypeForm.dockerfile_template;
+
+ if (!template.trim()) {
+ setToolTypeError(`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`);
+ return;
+ }
+
+ const variables = toolTypeForm.required_variables
+ .split(",")
+ .map((v) => v.trim())
+ .filter((v) => v.length > 0);
+
+ const readinessProbe: ReadinessProbe | undefined = toolTypeForm.readiness_command.trim()
+ ? {
+ command: toolTypeForm.readiness_command.trim(),
+ timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
+ interval: parseInt(toolTypeForm.readiness_interval) || 2,
+ }
+ : undefined;
+
+ try {
+ if (selectedToolType) {
+ const input: UpdateToolTypeRequest = {
+ display_name: toolTypeForm.display_name.trim(),
+ description: toolTypeForm.description.trim() || undefined,
+ category: toolTypeForm.category.trim() || undefined,
+ interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
+ default_port: Number(toolTypeForm.default_port),
+ definition_type: toolTypeForm.definition_type,
+ compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
+ dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
+ readiness_probe: readinessProbe,
+ required_variables: variables,
+ };
+ await updateToolType(selectedToolType.id, input);
+ } else {
+ const input: CreateToolTypeRequest = {
+ name: toolTypeForm.name.trim(),
+ display_name: toolTypeForm.display_name.trim(),
+ description: toolTypeForm.description.trim() || undefined,
+ category: toolTypeForm.category.trim() || undefined,
+ interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
+ default_port: Number(toolTypeForm.default_port),
+ definition_type: toolTypeForm.definition_type,
+ compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
+ dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
+ readiness_probe: readinessProbe,
+ required_variables: variables,
+ };
+ await createToolType(input);
+ }
+ setShowToolTypeForm(false);
+ setSelectedToolType(null);
+ await loadToolTypes();
+ } catch (err) {
+ const axiosError = err as { response?: { data?: { detail?: string } } };
+ setToolTypeError(axiosError?.response?.data?.detail || "Failed to save tool type");
+ }
+ };
+
+ const handleDeleteToolType = async (id: string) => {
+ if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return;
+ try {
+ await deleteToolType(id);
+ await loadToolTypes();
+ } catch {
+ alert("Failed to delete tool type");
+ }
+ };
+
+ if (status === "loading") {
+ return Loading Tool Types...
;
+ }
+
+ if (status === "error") {
+ return (
+
+
Failed to load tool types.
+
+
+ );
+ }
+
+ return (
+
+
+
Tool Types
+
+
+
+ {showToolTypeForm && (
+
+
{selectedToolType ? "Edit" : "Create"} Tool Type
+
+
+ )}
+
+
+ {toolTypes.map((toolType) => (
+
+
+
{toolType.display_name}
+ {toolType.is_builtin && Built-in}
+
+
{toolType.description || "No description"}
+
+ Type: {toolType.definition_type}
+ Port: {toolType.default_port || "N/A"}
+ {toolType.interfaces?.length > 0 && (
+ Interfaces: {toolType.interfaces.join(", ")}
+ )}
+ {toolType.category && Category: {toolType.category}}
+ {toolType.readiness_probe && (
+ Probe: {toolType.readiness_probe.command}
+ )}
+
+
+ {!toolType.is_builtin && (
+ <>
+
+
+ >
+ )}
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/web/src/components/features/tool-workshop/index.ts b/apps/web/src/components/features/tool-workshop/index.ts
new file mode 100644
index 0000000..c844614
--- /dev/null
+++ b/apps/web/src/components/features/tool-workshop/index.ts
@@ -0,0 +1,3 @@
+export { ToolTypesTab } from "./ToolTypesTab";
+export { ToolConfigsTab } from "./ToolConfigsTab";
+export { ConfigFoldersTab } from "./ConfigFoldersTab";
diff --git a/apps/web/src/components/ui/ConfirmDialog.tsx b/apps/web/src/components/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..630ac26
--- /dev/null
+++ b/apps/web/src/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ title: string;
+ message?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ confirmText?: string;
+ cancelText?: string;
+}
+
+export const ConfirmDialog: React.FC = ({
+ isOpen,
+ title,
+ message,
+ onConfirm,
+ onCancel,
+ confirmText = "Confirm",
+ cancelText = "Cancel",
+}) => {
+ if (!isOpen) return null;
+
+ return (
+
+
+
{title}
+ {message &&
{message}
}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts
index 55657a1..c2507a7 100644
--- a/apps/web/src/components/ui/index.ts
+++ b/apps/web/src/components/ui/index.ts
@@ -1,3 +1,4 @@
export { LoadingState } from "./LoadingState";
export { ErrorState } from "./ErrorState";
export { StatusBadge } from "./StatusBadge";
+export { ConfirmDialog } from "./ConfirmDialog";
diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx
index 154ec45..f67951f 100644
--- a/apps/web/src/pages/sessions.tsx
+++ b/apps/web/src/pages/sessions.tsx
@@ -2,28 +2,20 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects";
-import { listRepositories } from "../api/git_repositories";
-import {
- getUserSessions,
- deleteInstance,
- stopInstance,
- startInstance,
- checkInstanceHealth,
- recreateInstanceTunnel,
- createInstance,
-} from "../api/sessions";
+import { getUserSessions } from "../api/sessions";
import { listToolTypes } from "../api/tool_types";
-import { getUserConfig, updateUserConfig } from "../api/settings";
+import { getUserConfig } from "../api/settings";
+import { Icon } from "../components/icon";
+import { LoadingState, ErrorState } from "../components/ui";
+import {
+ CreateSessionForm,
+ SessionList,
+} from "../components/features/session";
import type { Project } from "../types/project";
import type { Session } from "../types/session";
-import type { GitRepository } from "../types/git-repository";
import type { ToolType } from "../types/tool-type";
-import { Icon } from "../components/icon";
-import { LoadingState } from "../components/ui";
-import { ErrorState } from "../components/ui";
type SessionsStatus = "loading" | "ready" | "error";
-type CreateStatus = "idle" | "creating" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
@@ -32,26 +24,8 @@ export const SessionsPage = () => {
const [lastSessionId, setLastSessionId] = useState(null);
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 [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);
-
const loadSessions = useCallback(async () => {
setStatus("loading");
try {
@@ -95,58 +69,6 @@ export const SessionsPage = () => {
void loadToolTypes();
}, []);
- // Poll tunnel health every 30 seconds for running instances
- useEffect(() => {
- 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",
- },
- }));
- }
- }
- };
-
- // Check immediately and then every 30 seconds
- void checkHealth();
- const interval = setInterval(() => void checkHealth(), 30000);
- return () => clearInterval(interval);
- }, [sessions]);
-
- useEffect(() => {
- if (!selectedProject) {
- setRepositories([]);
- return;
- }
- const loadRepos = async () => {
- try {
- const data = await listRepositories(selectedProject);
- setRepositories(data);
- } catch {
- setRepositories([]);
- }
- };
- void loadRepos();
- }, [selectedProject]);
-
const activeSessions = useMemo(
() =>
sessions.filter((s) =>
@@ -168,104 +90,26 @@ export const SessionsPage = () => {
[sessions, lastSessionId],
);
- const handleCreate = async (e: React.FormEvent) => {
- e.preventDefault();
- setCreateError(null);
+ const handleOpen = useCallback(
+ (session: Session) => {
+ if (session.tool_type_interfaces?.includes("terminal")) {
+ navigate(`/instances/${session.id}/terminal`);
+ } else {
+ navigate(`/projects/${session.project_id}`);
+ }
+ },
+ [navigate],
+ );
- 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,
- );
-
- // Auto-start the instance
- await startInstance(selectedProject, selectedRepo, instance.id);
-
- await updateUserConfig({ last_session_id: instance.id });
- setCreateStatus("idle");
- setSelectedProject("");
- setSelectedRepo("");
- setSelectedToolType("");
- setDisplayName("");
- await loadSessions();
- } catch {
- setCreateStatus("error");
- setCreateError("Failed to create session");
- }
- };
-
- const handleStop = async (
- sessionId: string,
- projectId: string,
- repoId: string,
- ) => {
- try {
- await stopInstance(projectId, repoId, sessionId);
- setStopConfirmId(null);
- await loadSessions();
- } catch {
- setStopConfirmId(null);
- }
- };
-
- const handleDelete = async (
- sessionId: string,
- projectId: string,
- repoId: string,
- ) => {
- try {
- await deleteInstance(projectId, repoId, sessionId);
- setDeleteConfirmId(null);
- // Remove from local state immediately
- setSessions((prev) => prev.filter((s) => s.id !== sessionId));
- } catch {
- setDeleteConfirmId(null);
- }
- };
-
- const handleRecreateTunnel = async (session: Session) => {
- setRecreatingId(session.id);
- try {
- await recreateInstanceTunnel(
- session.project_id,
- session.repository_id,
- session.id,
- );
- // Refresh sessions to get new URL
- await loadSessions();
- } catch {
- // ignore
- } finally {
- setRecreatingId(null);
- }
- };
-
- const handleOpen = (session: Session) => {
- if (session.url) {
- window.open(session.url, "_blank", "noopener,noreferrer");
- } else if (session.tool_type_interfaces?.includes("terminal")) {
- navigate(`/instances/${session.id}/terminal`);
- } else {
- navigate(`/projects/${session.project_id}`);
- }
- };
-
- const handleResumeLast = async () => {
+ const handleResumeLast = useCallback(() => {
if (!lastSession) return;
- // Find the project and repo IDs
- const project = projects.find((p) => p.name === lastSession.project_name);
+ const project = projects.find(
+ (p) => p.name === lastSession.project_name,
+ );
if (project) {
navigate(`/projects/${project.id}`);
}
- };
+ }, [lastSession, projects, navigate]);
return (
@@ -298,7 +142,8 @@ export const SessionsPage = () => {
"Unnamed Session"}
- {lastSession.tool_type_name} · {lastSession.project_name} ·{" "}
+ {lastSession.tool_type_name} ·{" "}
+ {lastSession.project_name} ·{" "}
{lastSession.repository_name}
{lastSession.url && (
@@ -312,7 +157,9 @@ export const SessionsPage = () => {
)}
-
+
{lastSession.status}
@@ -350,316 +197,33 @@ export const SessionsPage = () => {
{activeSessions.length}
)}
- {activeSessions.length === 0 ? (
- No active sessions
- ) : (
-
- {activeSessions.map((session) => (
-
-
-
- {session.display_name ||
- session.tool_type_name ||
- "Unnamed Session"}
-
-
- {session.tool_type_name} · {session.project_name}
-
- {session.url && (
-
-
- {session.url}
-
-
- )}
-
- {session.status}
-
- {tunnelHealth[session.id] &&
- !tunnelHealth[session.id].healthy && (
-
- tunnel error
-
- )}
-
-
- {session.url ? (
-
-
- Open
-
- ) : (
-
- )}
- {tunnelHealth[session.id] &&
- !tunnelHealth[session.id].healthy && (
-
- )}
- {stopConfirmId === session.id ? (
-
- Stop?
-
-
-
- ) : (
-
- )}
- {deleteConfirmId === session.id ? (
-
-
-
-
- ) : (
-
- )}
-
-
- ))}
-
- )}
+
{/* Recent Sessions */}
{recentSessions.length > 0 && (
Recent Sessions
-
- {recentSessions.map((session) => (
-
-
-
- {session.display_name ||
- session.tool_type_name ||
- "Unnamed Session"}
-
-
- {session.tool_type_name} · {session.project_name}
-
-
-
- {session.url ? (
-
- Open
-
- ) : (
-
- )}
- {deleteConfirmId === session.id ? (
-
-
-
-
- ) : (
-
- )}
-
-
- ))}
+
-
- )}
+ )}
{/* Create Session */}
-
-
Create New Session
-
-
+
>
)}
diff --git a/apps/web/src/pages/tool-workshop.tsx b/apps/web/src/pages/tool-workshop.tsx
index e3980f8..239903f 100644
--- a/apps/web/src/pages/tool-workshop.tsx
+++ b/apps/web/src/pages/tool-workshop.tsx
@@ -1,454 +1,38 @@
-import { useCallback, useEffect, useState } from "react";
+import { useState } from "react";
import { Icon } from "../components/icon";
import {
- createToolType,
- deleteToolType,
- listToolTypes,
- updateToolType,
- type CreateToolTypeRequest,
- type ReadinessProbe,
- type ToolType,
- type UpdateToolTypeRequest,
-} from "../api/tool_types";
-import {
- createToolConfig,
- deleteToolConfig,
- listToolConfigs,
- updateToolConfig,
- type CreateToolConfigRequest,
- type ToolConfig,
-} from "../api/tool_configs";
-import {
- createConfigFolder,
- deleteConfigFolder,
- listConfigFolders,
- updateConfigFolder,
- type ConfigFolder,
- type CreateConfigFolderRequest,
- type UpdateConfigFolderRequest,
-} from "../api/config_folders";
+ ToolTypesTab,
+ ToolConfigsTab,
+ ConfigFoldersTab,
+} from "../components/features/tool-workshop";
type Tab = "types" | "configs" | "folders";
-type Status = "loading" | "ready" | "error";
export const ToolWorkshopPage = () => {
const [activeTab, setActiveTab] = useState("types");
- const [status, setStatus] = useState("loading");
- const [toolTypes, setToolTypes] = useState([]);
- const [configs, setConfigs] = useState([]);
- const [folders, setFolders] = useState([]);
-
- // Tool Type state
- const [selectedToolType, setSelectedToolType] = useState(null);
- const [toolTypeForm, setToolTypeForm] = useState({
- name: "",
- display_name: "",
- description: "",
- category: "",
- interfaces: [] as string[],
- default_port: "",
- definition_type: "compose" as "compose" | "dockerfile",
- compose_template: "",
- dockerfile_template: "",
- readiness_command: "",
- readiness_timeout: "30",
- readiness_interval: "2",
- required_variables: "",
- });
- const [toolTypeError, setToolTypeError] = useState(null);
- const [showToolTypeForm, setShowToolTypeForm] = useState(false);
-
- // Config state
- const [selectedConfig, setSelectedConfig] = useState(null);
- const [configForm, setConfigForm] = useState({
- tool_type_id: "",
- key: "",
- value: "",
- config_type: "env",
- file_path: "",
- port_override: "",
- start_command: "",
- working_directory: "",
- env_vars_json: "{}",
- volumes_json: "[]",
- });
- const [configError, setConfigError] = useState(null);
- const [showConfigForm, setShowConfigForm] = useState(false);
-
- // Folder state
- 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 loadData = useCallback(async () => {
- setStatus("loading");
- try {
- const [types, cfgs, fldrs] = await Promise.all([
- listToolTypes(),
- listToolConfigs(),
- listConfigFolders(),
- ]);
- setToolTypes(types);
- setConfigs(cfgs);
- setFolders(fldrs);
- setStatus("ready");
- } catch {
- setStatus("error");
- }
- }, []);
-
- useEffect(() => {
- void loadData();
- }, [loadData]);
-
- // Tool Type handlers
- const openCreateToolType = () => {
- setToolTypeForm({
- name: "",
- display_name: "",
- description: "",
- category: "",
- interfaces: [],
- default_port: "",
- definition_type: "compose",
- compose_template: "",
- dockerfile_template: "",
- readiness_command: "",
- readiness_timeout: "30",
- readiness_interval: "2",
- required_variables: "",
- });
- setToolTypeError(null);
- setShowToolTypeForm(true);
- setSelectedToolType(null);
- };
-
- const openEditToolType = (toolType: ToolType) => {
- setToolTypeForm({
- name: toolType.name,
- display_name: toolType.display_name,
- description: toolType.description || "",
- category: toolType.category || "",
- interfaces: toolType.interfaces || [],
- default_port: toolType.default_port?.toString() || "",
- definition_type: toolType.definition_type || "compose",
- compose_template: toolType.compose_template || "",
- dockerfile_template: toolType.dockerfile_template || "",
- readiness_command: toolType.readiness_probe?.command || "",
- readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
- readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
- required_variables: toolType.required_variables?.join(", ") || "",
- });
- setToolTypeError(null);
- setShowToolTypeForm(true);
- setSelectedToolType(toolType);
- };
-
- const handleToolTypeSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setToolTypeError(null);
-
- if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
- setToolTypeError("Name and display name are required");
- return;
- }
-
- if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) {
- setToolTypeError("Default port is required and must be a number");
- return;
- }
-
- const template = toolTypeForm.definition_type === "compose"
- ? toolTypeForm.compose_template
- : toolTypeForm.dockerfile_template;
-
- if (!template.trim()) {
- setToolTypeError(`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`);
- return;
- }
-
- const variables = toolTypeForm.required_variables
- .split(",")
- .map((v) => v.trim())
- .filter((v) => v.length > 0);
-
- const readinessProbe: ReadinessProbe | undefined = toolTypeForm.readiness_command.trim()
- ? {
- command: toolTypeForm.readiness_command.trim(),
- timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
- interval: parseInt(toolTypeForm.readiness_interval) || 2,
- }
- : undefined;
-
- try {
- if (selectedToolType) {
- const input: UpdateToolTypeRequest = {
- display_name: toolTypeForm.display_name.trim(),
- description: toolTypeForm.description.trim() || undefined,
- category: toolTypeForm.category.trim() || undefined,
- interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
- default_port: Number(toolTypeForm.default_port),
- definition_type: toolTypeForm.definition_type,
- compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
- dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
- readiness_probe: readinessProbe,
- required_variables: variables,
- };
- await updateToolType(selectedToolType.id, input);
- } else {
- const input: CreateToolTypeRequest = {
- name: toolTypeForm.name.trim(),
- display_name: toolTypeForm.display_name.trim(),
- description: toolTypeForm.description.trim() || undefined,
- category: toolTypeForm.category.trim() || undefined,
- interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
- default_port: Number(toolTypeForm.default_port),
- definition_type: toolTypeForm.definition_type,
- compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
- dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
- readiness_probe: readinessProbe,
- required_variables: variables,
- };
- await createToolType(input);
- }
- setShowToolTypeForm(false);
- setSelectedToolType(null);
- await loadData();
- } catch (err) {
- const axiosError = err as { response?: { data?: { detail?: string } } };
- setToolTypeError(axiosError?.response?.data?.detail || "Failed to save tool type");
- }
- };
-
- const handleDeleteToolType = async (id: string) => {
- if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return;
- try {
- await deleteToolType(id);
- await loadData();
- } catch {
- alert("Failed to delete tool type");
- }
- };
-
- // Config handlers
- const openCreateConfig = () => {
- setConfigForm({
- tool_type_id: toolTypes[0]?.id || "",
- key: "",
- value: "",
- config_type: "env",
- file_path: "",
- port_override: "",
- start_command: "",
- working_directory: "",
- env_vars_json: "{}",
- volumes_json: "[]",
- });
- setConfigError(null);
- setShowConfigForm(true);
- setSelectedConfig(null);
- };
-
- const openEditConfig = (config: ToolConfig) => {
- setConfigForm({
- tool_type_id: config.tool_type_id,
- key: config.key,
- value: config.value,
- config_type: config.config_type,
- file_path: config.file_path || "",
- port_override: config.port_override?.toString() || "",
- start_command: config.start_command || "",
- working_directory: config.working_directory || "",
- env_vars_json: config.environment_variables ? JSON.stringify(config.environment_variables, null, 2) : "{}",
- volumes_json: config.volumes ? JSON.stringify(config.volumes, null, 2) : "[]",
- });
- setConfigError(null);
- setShowConfigForm(true);
- setSelectedConfig(config);
- };
-
- const handleConfigSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setConfigError(null);
-
- if (!configForm.tool_type_id || !configForm.key.trim()) {
- setConfigError("Tool type and key are required");
- return;
- }
-
- // Validate JSON fields
- let envVars: Record | undefined;
- let volumes: Array<{ source: string; target: string; type?: string }> | undefined;
-
- try {
- if (configForm.env_vars_json.trim() && configForm.env_vars_json.trim() !== "{}") {
- envVars = JSON.parse(configForm.env_vars_json);
- }
- } catch {
- setConfigError("Environment variables must be valid JSON");
- return;
- }
-
- try {
- if (configForm.volumes_json.trim() && configForm.volumes_json.trim() !== "[]") {
- volumes = JSON.parse(configForm.volumes_json);
- }
- } catch {
- setConfigError("Volumes must be valid JSON array");
- return;
- }
-
- const data: CreateToolConfigRequest = {
- tool_type_id: configForm.tool_type_id,
- key: configForm.key.trim(),
- value: configForm.value,
- config_type: configForm.config_type,
- file_path: configForm.config_type === "file" ? configForm.file_path : undefined,
- port_override: configForm.port_override ? Number(configForm.port_override) : undefined,
- start_command: configForm.start_command.trim() || undefined,
- working_directory: configForm.working_directory.trim() || undefined,
- environment_variables: envVars,
- volumes,
- };
-
- try {
- if (selectedConfig) {
- await updateToolConfig(selectedConfig.id, data);
- } else {
- await createToolConfig(data);
- }
- setShowConfigForm(false);
- setSelectedConfig(null);
- await loadData();
- } catch (err) {
- const axiosError = err as { response?: { data?: { detail?: string } } };
- setConfigError(axiosError?.response?.data?.detail || "Failed to save config");
- }
- };
-
- const handleDeleteConfig = async (id: string) => {
- if (!window.confirm("Delete this config?")) return;
- try {
- await deleteToolConfig(id);
- await loadData();
- } catch {
- alert("Failed to delete config");
- }
- };
-
- // Folder handlers
- 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 loadData();
- } 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 loadData();
- } catch {
- alert("Failed to delete folder");
- }
- };
-
- if (status === "loading") {
- return (
-
-
Loading Tool Workshop...
-
- );
- }
-
- if (status === "error") {
- return (
-
-
Failed to load Tool Workshop.
-
-
- );
- }
return (
-
+
Tool Workshop
- {/* Tabs */}
-
+
{(["types", "configs", "folders"] as Tab[]).map((tab) => (
))}
- {/* Tool Types Tab */}
- {activeTab === "types" && (
-
-
-
Tool Types
-
-
-
- {showToolTypeForm && (
-
-
{selectedToolType ? "Edit" : "Create"} Tool Type
-
-
- )}
-
-
- {toolTypes.map((toolType) => (
-
-
-
{toolType.display_name}
- {toolType.is_builtin && Built-in}
-
-
{toolType.description || "No description"}
-
- Type: {toolType.definition_type}
- Port: {toolType.default_port || "N/A"}
- {toolType.interfaces?.length > 0 && (
- Interfaces: {toolType.interfaces.join(", ")}
- )}
- {toolType.category && Category: {toolType.category}}
- {toolType.readiness_probe && (
- Probe: {toolType.readiness_probe.command}
- )}
-
-
- {!toolType.is_builtin && (
- <>
-
-
- >
- )}
-
-
- ))}
-
-
- )}
-
- {/* Configs Tab */}
- {activeTab === "configs" && (
-
-
-
Tool Configurations
-
-
-
- {showConfigForm && (
-
-
{selectedConfig ? "Edit" : "Add"} Config
-
-
- )}
-
-
- {configs.length === 0 ? (
-
No configurations yet.
- ) : (
- configs.map((config) => (
-
-
-
- {config.key}
-
- {config.config_type}
-
-
-
- {config.config_type === "file" && config.file_path
- ? `File: ${config.file_path}`
- : "Environment variable"}
- {config.port_override && ` · Port: ${config.port_override}`}
-
-
-
-
-
-
-
- ))
- )}
-
-
- )}
-
- {/* Config Folders Tab */}
- {activeTab === "folders" && (
-
-
-
Config Folders
-
-
-
- {showFolderForm && (
-
- )}
-
-
- {folders.map((folder) => (
-
-
-
{folder.name}
- {folder.is_active && Active}
-
-
{folder.description || "No description"}
-
- Mount: {folder.mount_path}
- Files: {Object.keys(folder.files || {}).length}
-
-
-
-
-
-
- ))}
-
-
- )}
+ {activeTab === "types" &&
}
+ {activeTab === "configs" &&
}
+ {activeTab === "folders" &&
}
);
};
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 5dad5fe..60c5f20 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -10,7 +10,11 @@
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
- "types": ["vite/client"]
+ "types": ["vite/client"],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
},
"include": ["src"]
}
diff --git a/openspec/changes/repo-restructure/apply-3.5-report.md b/openspec/changes/repo-restructure/apply-3.5-report.md
new file mode 100644
index 0000000..df4428b
--- /dev/null
+++ b/openspec/changes/repo-restructure/apply-3.5-report.md
@@ -0,0 +1,62 @@
+# Task 3.5 Apply Report: Slim git_repositories and config_profiles Routers
+
+**Status:** Success
+
+## Files Created (4)
+
+- `apps/api/src/services/git/__init__.py` — Package marker
+- `apps/api/src/services/git/repository.py` — Repository lifecycle (create, delete, list, path helpers, clone/init)
+- `apps/api/src/services/git/control.py` — Git control operations with repo validation (branch, commit, fetch, pull, push, merge, status)
+- `apps/api/src/services/git/files.py` — Git file operations with repo validation (list files, get file, update file, list branches)
+
+## Files Modified (2)
+
+- `apps/api/src/services/config_profiles.py` — Expanded with:
+ - `check_duplicate_name()` — name uniqueness validation
+ - `profile_to_dict()` — serialization helper
+ - `check_duplicate_include()` — include uniqueness validation
+ - `include_to_dict()` — serialization helper
+ - `check_duplicate_mount_path()` — mount path uniqueness validation
+ - `mount_to_dict()` — serialization helper
+ - `get_or_create_user_config()` — user config retrieval/creation
+ - `validate_default_profiles()` — validate profile ownership for defaults
+ - `get_default_profiles()` / `set_default_profiles()` / `get_default_profile_for_tool_type()` — default profile management
+ - `list_includes_for_profile()` / `list_mounts_for_profile()` — list helpers
+
+- `apps/api/src/api/git_repositories.py` — Slimmed from ~1,050 to **276 lines**
+ - Removed all subprocess calls (clone, init, preflight)
+ - Removed all inline git utility calls with error handling
+ - Removed verbose docstrings from endpoints
+ - Router now contains only: imports, endpoint definitions, thin handlers delegating to services
+
+- `apps/api/src/api/config_profiles.py` — Slimmed from ~765 to **299 lines**
+ - Removed inline cycle detection logic (moved to service)
+ - Removed inline duplicate validation (moved to service)
+ - Removed inline response serialization (moved to service)
+ - Removed default profile management logic (moved to service)
+ - Removed include/mount list building logic (moved to service)
+ - Router now contains only: imports, endpoint definitions, thin handlers
+
+## Quality Gate Results
+
+| Gate | Result |
+|------|--------|
+| `python3 -m py_compile api/git_repositories.py` | ✅ PASS |
+| `python3 -m py_compile api/config_profiles.py` | ✅ PASS |
+| `python3 -m py_compile services/git/repository.py` | ✅ PASS |
+| `python3 -m py_compile services/git/control.py` | ✅ PASS |
+| `python3 -m py_compile services/git/files.py` | ✅ PASS |
+| `python3 -m py_compile services/config_profiles.py` | ✅ PASS |
+| `wc -l api/git_repositories.py` | ✅ 276 lines (≤300) |
+| `wc -l api/config_profiles.py` | ✅ 299 lines (≤300) |
+| `grep -n "subprocess" api/git_repositories.py` | ✅ 0 results |
+| `grep -n "subprocess" api/config_profiles.py` | ✅ 0 results |
+
+## Blockers/Deviations
+
+- None. Both routers successfully slimmed to under 300 lines.
+
+## Notes
+
+- The history endpoints (get_repository_history, get_repository_commit) still do inline repo validation + git history calls because `services/git/history.py` doesn't exist yet and the existing utility functions in `utils/git_history.py` are already thin wrappers.
+- `ProjectOverrideWithId` remains in `api/config_folders.py` as noted in Task 3.2 (Pydantic type invariance issue).
diff --git a/openspec/changes/repo-restructure/apply-4.1-report.md b/openspec/changes/repo-restructure/apply-4.1-report.md
new file mode 100644
index 0000000..cea191f
--- /dev/null
+++ b/openspec/changes/repo-restructure/apply-4.1-report.md
@@ -0,0 +1,56 @@
+# Task 4.1 Apply Report: Split tool-workshop Page into Tab Components
+
+**Status:** Success (with deviation noted)
+
+## Files Created (4)
+
+- `apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx` (417 lines)
+ - Self-contained tool types list + create/edit form
+ - Manages own `toolTypes`, form state, loading/error state
+ - Imports from `api/tool_types`
+
+- `apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx` (381 lines)
+ - Self-contained configs list + create/edit form
+ - Loads both `toolTypes` (for dropdown) and `configs`
+ - Imports from `api/tool_configs` and `api/tool_types`
+
+- `apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx` (244 lines)
+ - Self-contained folders list + create/edit form
+ - Imports from `api/config_folders`
+
+- `apps/web/src/components/features/tool-workshop/index.ts` (barrel export)
+
+## Files Modified (2)
+
+- `apps/web/src/pages/tool-workshop.tsx` — Slimmed from ~700 lines to **77 lines**
+ - Removed all inline tab state and JSX
+ - Keeps only: `activeTab` state, tab navigation, component composition
+ - Imports tabs from `@/components/features/tool-workshop`
+
+- `apps/web/tsconfig.json` — Added `baseUrl` and `paths` for `@/*` alias
+ - Required because parallel Task 4.2 files use `@/` imports
+ - Standard Vite path mapping, no build behavior change
+
+## Deviation from Target
+
+| File | Target | Actual | Note |
+|------|--------|--------|------|
+| ToolTypesTab.tsx | ~250 | 417 | Form has 15+ fields; each field is ~8 lines of JSX |
+| ToolConfigsTab.tsx | ~200 | 381 | Form has 10+ fields plus JSON validation |
+| ConfigFoldersTab.tsx | ~200 | 244 | Within acceptable range |
+
+**Rationale:** The tabs are form-heavy components. Each form field requires ~6-10 lines of JSX (label + input + props). Further splitting would create micro-components for individual form fields, which may not improve readability. The page itself is well under target at 77 lines.
+
+## Quality Gate Results
+
+| Gate | Result |
+|------|--------|
+| `npm run typecheck` | ✅ PASS — zero errors |
+| `npm run lint` | ✅ PASS — zero warnings |
+| `wc -l pages/tool-workshop.tsx` | ✅ 77 lines (≤150 target) |
+| All 3 tabs compile and import | ✅ PASS |
+
+## Next Steps
+
+- Further decompose ToolTypesTab and ToolConfigsTab into form-field sub-components if desired (optional, out of current task scope)
+- Task 4.2 (sessions page split) is in progress in parallel