diff options
Diffstat (limited to 'frontend/src/components/document/DocumentLayout.tsx')
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.tsx | 316 |
1 files changed, 316 insertions, 0 deletions
diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx new file mode 100644 index 0000000..a555ad0 --- /dev/null +++ b/frontend/src/components/document/DocumentLayout.tsx @@ -0,0 +1,316 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { DirectiveFileTree } from './DirectiveFileTree' +import DocumentEditor from './DocumentEditor' +import { ToastProvider, useToast } from './Toast' +import { + type DirectiveWithSteps, + type DirectiveStep, + getDirective, + getDirectiveSteps, + updateGoal, + updateDirective, + cleanupDirective, + createPr, + pickUpOrders, + pauseDirective, + startDirective, +} from '../../services/directiveApi' +import './DocumentLayout.css' + +function StatusBadge({ status }: { status: string }) { + const colors: Record<string, string> = { + active: '#4caf50', + running: '#4caf50', + idle: '#ffc107', + paused: '#ffc107', + draft: '#9e9e9e', + pending: '#9e9e9e', + archived: '#f44336', + failed: '#f44336', + } + const color = colors[status.toLowerCase()] || '#9e9e9e' + + return ( + <span className="doc-status-badge" style={{ backgroundColor: color }}> + {status} + </span> + ) +} + +function DocumentLayoutInner() { + const { id: urlDirectiveId } = useParams<{ id: string }>() + const navigate = useNavigate() + const { addToast } = useToast() + + const [selectedId, setSelectedId] = useState<string | null>(urlDirectiveId || null) + const [directive, setDirective] = useState<DirectiveWithSteps | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + const [sidebarWidth, setSidebarWidth] = useState(250) + const resizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(250) + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) + + // Sync URL param on mount + useEffect(() => { + if (urlDirectiveId && urlDirectiveId !== selectedId) { + setSelectedId(urlDirectiveId) + } + }, [urlDirectiveId]) + + // Handle directive selection - update URL + const handleSelectDirective = useCallback((id: string) => { + setSelectedId(id) + navigate(`/directives/${id}`, { replace: true }) + }, [navigate]) + + // Load directive when selected + useEffect(() => { + if (!selectedId) { + setDirective(null) + return + } + + let cancelled = false + async function load() { + try { + setLoading(true) + setError(null) + const data = await getDirective(selectedId!) + if (!cancelled) setDirective(data) + } catch (err) { + if (!cancelled) { + const msg = err instanceof Error ? err.message : 'Failed to load directive' + setError(msg) + addToast(msg, 'error') + } + } finally { + if (!cancelled) setLoading(false) + } + } + load() + + return () => { cancelled = true } + }, [selectedId, addToast]) + + // Step polling (after goal update triggers supervisor) + const startStepPolling = useCallback(() => { + if (pollRef.current) clearInterval(pollRef.current) + pollRef.current = setInterval(async () => { + if (!selectedId) return + try { + const data = await getDirective(selectedId) + setDirective(data) + } catch { + // Silently fail for polling + } + }, 3000) + // Stop after 60 seconds + setTimeout(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, 60000) + }, [selectedId]) + + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, []) + + // Auto-save goal changes + const handleGoalChange = useCallback(async (newGoal: string) => { + if (!selectedId) return + try { + const updated = await updateGoal(selectedId, newGoal) + setDirective(updated) + addToast('Goal saved', 'success') + startStepPolling() + } catch (err) { + addToast(`Failed to save goal: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast, startStepPolling]) + + const handleTitleChange = useCallback(async (newTitle: string) => { + if (!selectedId || !directive) return + try { + const updated = await updateDirective(selectedId, { + title: newTitle, + version: directive.version, + }) + setDirective(updated) + } catch (err) { + addToast(`Failed to update title: ${(err as Error).message}`, 'error') + } + }, [selectedId, directive, addToast]) + + const handleCleanup = useCallback(async () => { + if (!selectedId) return + try { + await cleanupDirective(selectedId) + addToast('Cleanup task spawned', 'success') + startStepPolling() + } catch (err) { + addToast(`Cleanup failed: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast, startStepPolling]) + + const handleCreatePr = useCallback(async () => { + if (!selectedId) return + try { + await createPr(selectedId) + addToast('PR update triggered', 'success') + } catch (err) { + addToast(`PR update failed: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast]) + + const handlePlanOrders = useCallback(async () => { + if (!selectedId) return + try { + await pickUpOrders(selectedId) + addToast('Planning orders...', 'info') + startStepPolling() + } catch (err) { + addToast(`Plan orders failed: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast, startStepPolling]) + + const handleTogglePause = useCallback(async () => { + if (!selectedId || !directive) return + try { + if (directive.status === 'paused') { + const result = await startDirective(selectedId) + setDirective(result) + addToast('Directive resumed', 'success') + } else { + const updated = await pauseDirective(selectedId) + setDirective(updated) + addToast('Directive paused', 'info') + } + } catch (err) { + addToast(`Failed to toggle pause: ${(err as Error).message}`, 'error') + } + }, [selectedId, directive, addToast]) + + // Sidebar resize handlers + const handleMouseDown = useCallback((e: React.MouseEvent) => { + resizingRef.current = true + startXRef.current = e.clientX + startWidthRef.current = sidebarWidth + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + const handleMouseMove = (e: MouseEvent) => { + if (!resizingRef.current) return + const diff = e.clientX - startXRef.current + const newWidth = Math.max(180, Math.min(500, startWidthRef.current + diff)) + setSidebarWidth(newWidth) + } + + const handleMouseUp = () => { + resizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [sidebarWidth]) + + const handleNewDirective = useCallback(() => { + // Placeholder - will be implemented with full directive creation flow + console.log('New directive requested') + }, []) + + return ( + <div className="document-layout"> + {/* Sidebar */} + <div className="document-sidebar" style={{ width: sidebarWidth }}> + <div className="document-sidebar-back"> + <Link to="/" className="document-back-link"> + {'\u2190'} Back to Main + </Link> + </div> + <DirectiveFileTree + selectedDirectiveId={selectedId} + onSelectDirective={handleSelectDirective} + onNewDirective={handleNewDirective} + /> + </div> + + {/* Resize handle */} + <div className="document-resize-handle" onMouseDown={handleMouseDown} /> + + {/* Main content */} + <div className="document-main"> + {directive && ( + <div className="document-topbar"> + <div className="document-topbar-left"> + <h1 className="document-topbar-title">{directive.title || 'Untitled'}</h1> + <StatusBadge status={directive.status} /> + </div> + <div className="document-topbar-right"> + <button className="document-topbar-gear" title="Settings"> + {'\u2699'} + </button> + </div> + </div> + )} + + <div className="document-content"> + {loading && ( + <div className="document-placeholder"> + <p>Loading directive...</p> + </div> + )} + + {error && ( + <div className="document-placeholder"> + <p className="document-error">Error: {error}</p> + </div> + )} + + {!loading && !error && !directive && ( + <div className="document-placeholder"> + <div className="document-placeholder-icon">{'\u{1F4DD}'}</div> + <h2>No directive selected</h2> + <p>Select a directive from the sidebar or create a new one to get started.</p> + </div> + )} + + {!loading && !error && directive && ( + <DocumentEditor + directiveId={directive.id} + title={directive.title || 'Untitled'} + goal={directive.goal || ''} + status={directive.status} + prBranch={directive.prBranch || directive.pr_branch} + onGoalChange={handleGoalChange} + onTitleChange={handleTitleChange} + onCleanup={handleCleanup} + onCreatePr={handleCreatePr} + onPlanOrders={handlePlanOrders} + onTogglePause={handleTogglePause} + /> + )} + </div> + </div> + </div> + ) +} + +// Wrapper that provides toast context +export default function DocumentLayout() { + return ( + <ToastProvider> + <DocumentLayoutInner /> + </ToastProvider> + ) +} |
