diff --git a/dashboard/components/SpawnModal.js b/dashboard/components/SpawnModal.js new file mode 100644 index 0000000..f57e3db --- /dev/null +++ b/dashboard/components/SpawnModal.js @@ -0,0 +1,224 @@ +import { html, useState, useEffect, useCallback } from '../lib/preact.js'; +import { API_PROJECTS, API_SPAWN, fetchWithTimeout } from '../utils/api.js'; + +export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) { + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(''); + const [agentType, setAgentType] = useState('claude'); + const [loading, setLoading] = useState(false); + const [loadingProjects, setLoadingProjects] = useState(false); + const [closing, setClosing] = useState(false); + const [error, setError] = useState(null); + + const needsProjectPicker = !currentProject; + + // Lock body scroll when modal is open + useEffect(() => { + if (!isOpen) return; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, [isOpen]); + + // Reset state on open + useEffect(() => { + if (isOpen) { + setAgentType('claude'); + setError(null); + setLoading(false); + setClosing(false); + } + }, [isOpen]); + + // Fetch projects when needed + useEffect(() => { + if (isOpen && needsProjectPicker) { + setLoadingProjects(true); + fetchWithTimeout(API_PROJECTS) + .then(r => r.json()) + .then(data => { + setProjects(data.projects || []); + setSelectedProject(''); + }) + .catch(err => setError(err.message)) + .finally(() => setLoadingProjects(false)); + } + }, [isOpen, needsProjectPicker]); + + // Animated close handler + const handleClose = useCallback(() => { + if (loading) return; + setClosing(true); + setTimeout(() => { + setClosing(false); + onClose(); + }, 200); + }, [loading, onClose]); + + // Handle escape key + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e) => { + if (e.key === 'Escape') handleClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, handleClose]); + + const handleSpawn = async () => { + const project = currentProject || selectedProject; + if (!project) { + setError('Please select a project'); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await fetchWithTimeout(API_SPAWN, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${window.AMC_AUTH_TOKEN}`, + }, + body: JSON.stringify({ project, agent_type: agentType }), + }); + const data = await response.json(); + + if (data.ok) { + onSpawn({ success: true, project, agentType }); + handleClose(); + } else { + setError(data.error || 'Spawn failed'); + onSpawn({ error: data.error }); + } + } catch (err) { + const msg = err.name === 'AbortError' ? 'Request timed out' : err.message; + setError(msg); + onSpawn({ error: msg }); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + const canSpawn = !loading && (currentProject || selectedProject); + + return html` +
e.target === e.currentTarget && handleClose()} + > +
e.stopPropagation()} + > + +
+

Spawn Agent

+ +
+ + +
+ + ${needsProjectPicker && html` +
+ + ${loadingProjects ? html` +
+ ... + Loading projects +
+ ` : html` + + `} +
+ `} + + ${currentProject && html` +
+ +
+ ${currentProject} +
+
+ `} + + +
+ +
+ + +
+
+ + ${error && html` +
+ ${error} +
+ `} +
+ + +
+ + +
+
+
+ `; +}