From 7cbbb41661eebcd9be6afe4e189b4f8c455d68c1 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 22 May 2026 19:30:09 +0000 Subject: [PATCH] feat: redesign tool workshop with split-pane layout - Replace tabbed interface with split-pane layout - Left sidebar: scrollable tool type list with selection and create button - Right panel: editable tool type details with tabs for configs and folders - Add dirty state tracking with unsaved changes warning - Improve mobile responsiveness Quality gates: npm run build passed --- apps/web/src/pages/tool-workshop.tsx | 1144 +++++++++++++++----------- 1 file changed, 667 insertions(+), 477 deletions(-) diff --git a/apps/web/src/pages/tool-workshop.tsx b/apps/web/src/pages/tool-workshop.tsx index e3980f8..a51800c 100644 --- a/apps/web/src/pages/tool-workshop.tsx +++ b/apps/web/src/pages/tool-workshop.tsx @@ -28,18 +28,21 @@ import { type UpdateConfigFolderRequest, } from "../api/config_folders"; -type Tab = "types" | "configs" | "folders"; +type RightPanelTab = "details" | "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); + // Selection state + const [selectedToolTypeId, setSelectedToolTypeId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [rightPanelTab, setRightPanelTab] = useState("details"); + + // Tool Type form state const [toolTypeForm, setToolTypeForm] = useState({ name: "", display_name: "", @@ -56,12 +59,10 @@ export const ToolWorkshopPage = () => { required_variables: "", }); const [toolTypeError, setToolTypeError] = useState(null); - const [showToolTypeForm, setShowToolTypeForm] = useState(false); + const [toolTypeDirty, setToolTypeDirty] = useState(false); - // Config state - const [selectedConfig, setSelectedConfig] = useState(null); + // Config form state const [configForm, setConfigForm] = useState({ - tool_type_id: "", key: "", value: "", config_type: "env", @@ -74,9 +75,9 @@ export const ToolWorkshopPage = () => { }); const [configError, setConfigError] = useState(null); const [showConfigForm, setShowConfigForm] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); - // Folder state - const [selectedFolder, setSelectedFolder] = useState(null); + // Folder form state const [folderForm, setFolderForm] = useState({ name: "", description: "", @@ -86,6 +87,11 @@ export const ToolWorkshopPage = () => { }); const [folderError, setFolderError] = useState(null); const [showFolderForm, setShowFolderForm] = useState(false); + const [selectedFolder, setSelectedFolder] = useState(null); + + const selectedToolType = toolTypes.find((t) => t.id === selectedToolTypeId) || null; + const toolConfigs = configs.filter((c) => c.tool_type_id === selectedToolTypeId); + const toolFolders = folders; // Config folders are global, not per-tool-type in current API const loadData = useCallback(async () => { setStatus("loading"); @@ -108,8 +114,7 @@ export const ToolWorkshopPage = () => { void loadData(); }, [loadData]); - // Tool Type handlers - const openCreateToolType = () => { + const resetToolTypeForm = () => { setToolTypeForm({ name: "", display_name: "", @@ -126,11 +131,10 @@ export const ToolWorkshopPage = () => { required_variables: "", }); setToolTypeError(null); - setShowToolTypeForm(true); - setSelectedToolType(null); + setToolTypeDirty(false); }; - const openEditToolType = (toolType: ToolType) => { + const populateToolTypeForm = (toolType: ToolType) => { setToolTypeForm({ name: toolType.name, display_name: toolType.display_name, @@ -147,8 +151,39 @@ export const ToolWorkshopPage = () => { required_variables: toolType.required_variables?.join(", ") || "", }); setToolTypeError(null); - setShowToolTypeForm(true); - setSelectedToolType(toolType); + setToolTypeDirty(false); + }; + + const handleSelectToolType = (toolType: ToolType | null) => { + if (toolTypeDirty) { + if (!window.confirm("You have unsaved changes. Discard them?")) { + return; + } + } + if (toolType) { + setSelectedToolTypeId(toolType.id); + setIsCreating(false); + populateToolTypeForm(toolType); + } else { + setSelectedToolTypeId(null); + } + setRightPanelTab("details"); + setShowConfigForm(false); + setShowFolderForm(false); + }; + + const handleCreateNew = () => { + if (toolTypeDirty) { + if (!window.confirm("You have unsaved changes. Discard them?")) { + return; + } + } + setSelectedToolTypeId(null); + setIsCreating(true); + resetToolTypeForm(); + setRightPanelTab("details"); + setShowConfigForm(false); + setShowFolderForm(false); }; const handleToolTypeSubmit = async (e: React.FormEvent) => { @@ -188,21 +223,7 @@ export const ToolWorkshopPage = () => { : 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 { + if (isCreating) { const input: CreateToolTypeRequest = { name: toolTypeForm.name.trim(), display_name: toolTypeForm.display_name.trim(), @@ -216,10 +237,26 @@ export const ToolWorkshopPage = () => { readiness_probe: readinessProbe, required_variables: variables, }; - await createToolType(input); + const newTool = await createToolType(input); + setIsCreating(false); + setSelectedToolTypeId(newTool.id); + setToolTypeDirty(false); + } else 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); + setToolTypeDirty(false); } - setShowToolTypeForm(false); - setSelectedToolType(null); await loadData(); } catch (err) { const axiosError = err as { response?: { data?: { detail?: string } } }; @@ -231,6 +268,11 @@ export const ToolWorkshopPage = () => { if (!window.confirm("Delete this tool type? All associated configs will be removed.")) return; try { await deleteToolType(id); + if (selectedToolTypeId === id) { + setSelectedToolTypeId(null); + setIsCreating(false); + resetToolTypeForm(); + } await loadData(); } catch { alert("Failed to delete tool type"); @@ -238,9 +280,8 @@ export const ToolWorkshopPage = () => { }; // Config handlers - const openCreateConfig = () => { + const resetConfigForm = () => { setConfigForm({ - tool_type_id: toolTypes[0]?.id || "", key: "", value: "", config_type: "env", @@ -252,13 +293,16 @@ export const ToolWorkshopPage = () => { volumes_json: "[]", }); setConfigError(null); - setShowConfigForm(true); setSelectedConfig(null); }; + const openCreateConfig = () => { + resetConfigForm(); + setShowConfigForm(true); + }; + const openEditConfig = (config: ToolConfig) => { setConfigForm({ - tool_type_id: config.tool_type_id, key: config.key, value: config.value, config_type: config.config_type, @@ -278,12 +322,11 @@ export const ToolWorkshopPage = () => { e.preventDefault(); setConfigError(null); - if (!configForm.tool_type_id || !configForm.key.trim()) { + if (!selectedToolTypeId || !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; @@ -306,7 +349,7 @@ export const ToolWorkshopPage = () => { } const data: CreateToolConfigRequest = { - tool_type_id: configForm.tool_type_id, + tool_type_id: selectedToolTypeId, key: configForm.key.trim(), value: configForm.value, config_type: configForm.config_type, @@ -326,6 +369,7 @@ export const ToolWorkshopPage = () => { } setShowConfigForm(false); setSelectedConfig(null); + resetConfigForm(); await loadData(); } catch (err) { const axiosError = err as { response?: { data?: { detail?: string } } }; @@ -344,7 +388,7 @@ export const ToolWorkshopPage = () => { }; // Folder handlers - const openCreateFolder = () => { + const resetFolderForm = () => { setFolderForm({ name: "", description: "", @@ -353,10 +397,14 @@ export const ToolWorkshopPage = () => { is_active: true, }); setFolderError(null); - setShowFolderForm(true); setSelectedFolder(null); }; + const openCreateFolder = () => { + resetFolderForm(); + setShowFolderForm(true); + }; + const openEditFolder = (folder: ConfigFolder) => { setFolderForm({ name: folder.name, @@ -405,6 +453,7 @@ export const ToolWorkshopPage = () => { } setShowFolderForm(false); setSelectedFolder(null); + resetFolderForm(); await loadData(); } catch (err) { const axiosError = err as { response?: { data?: { detail?: string } } }; @@ -442,86 +491,238 @@ export const ToolWorkshopPage = () => { } return ( -
-
-

Tool Workshop

-
+
+ {/* Left Sidebar - Tool List */} +
+
+

Tool Workshop

+

+ {toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""} +

+
- {/* Tabs */} -
- {(["types", "configs", "folders"] as Tab[]).map((tab) => ( +
+ {toolTypes.map((toolType) => ( + + )} + + ))} +
+ +
- ))} +
- {/* Tool Types Tab */} - {activeTab === "types" && ( -
-
-

Tool Types

- + {/* Right Panel - Editor */} +
+ {!selectedToolTypeId && !isCreating ? ( +
+
+ +
+

Select a tool type

+

Choose a tool from the list to edit, or create a new one.

+ ) : ( +
+ {/* Header */} +
+

+ {isCreating ? "Create Tool Type" : selectedToolType?.display_name} +

+ {!isCreating && ( +

+ {selectedToolType?.name} · {selectedToolType?.definition_type} · Port {selectedToolType?.default_port} +

+ )} +
- {showToolTypeForm && ( -
-

{selectedToolType ? "Edit" : "Create"} Tool Type

-
+ {/* Right Panel Tabs */} +
+ {(["details", "configs", "folders"] as RightPanelTab[]).map((tab) => ( + + ))} +
+ + {/* Details Tab */} + {rightPanelTab === "details" && ( +
-
- - setToolTypeForm({ ...toolTypeForm, name: e.target.value })} - disabled={!!selectedToolType} - placeholder="e.g., code-server" - className="form-input" - required - /> -
- -
- - setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })} - placeholder="e.g., VS Code Server" - className="form-input" - required - /> +
+
+ + { + setToolTypeForm({ ...toolTypeForm, name: e.target.value }); + setToolTypeDirty(true); + }} + disabled={!isCreating} + placeholder="e.g., code-server" + className="form-input" + required + /> +
+
+ + { + setToolTypeForm({ ...toolTypeForm, display_name: e.target.value }); + setToolTypeDirty(true); + }} + placeholder="e.g., VS Code Server" + className="form-input" + required + /> +
@@ -530,22 +731,45 @@ export const ToolWorkshopPage = () => { id="tool-type-description" type="text" value={toolTypeForm.description} - onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })} + onChange={(e) => { + setToolTypeForm({ ...toolTypeForm, description: e.target.value }); + setToolTypeDirty(true); + }} placeholder="Optional description" className="form-input" />
-
- - setToolTypeForm({ ...toolTypeForm, category: e.target.value })} - placeholder="e.g., editor, notebook, ai-assistant" - className="form-input" - /> +
+
+ + { + setToolTypeForm({ ...toolTypeForm, category: e.target.value }); + setToolTypeDirty(true); + }} + placeholder="e.g., editor, notebook, ai-assistant" + className="form-input" + /> +
+
+ + { + setToolTypeForm({ ...toolTypeForm, default_port: e.target.value }); + setToolTypeDirty(true); + }} + placeholder="e.g., 8443" + className="form-input" + required + /> +
@@ -562,6 +786,7 @@ export const ToolWorkshopPage = () => { } else { setToolTypeForm({ ...toolTypeForm, interfaces: toolTypeForm.interfaces.filter((i) => i !== iface) }); } + setToolTypeDirty(true); }} /> {iface} @@ -570,19 +795,6 @@ export const ToolWorkshopPage = () => {
-
- - setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })} - placeholder="e.g., 8443" - className="form-input" - required - /> -
-