summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/DocumentLayout.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/DocumentLayout.tsx')
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx316
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>
+ )
+}