From c8b169da8cb7eae0957e0ab5e7370b071093a224 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Tue, 28 Apr 2026 00:18:40 +0100
Subject: feat: Document UI for directive orchestration with Lexical editor
 (#93)

* WIP: heartbeat checkpoint

* feat: soryu-co/soryu - makima: Save previous goal on update and include history in re-planning prompt

* feat: soryu-co/soryu - makima: Install Lexical and create base document editor component

* feat: soryu-co/soryu - makima: Create directive file system sidebar and document layout

* feat: soryu-co/soryu - makima: Create custom Lexical step diagram block

* feat: soryu-co/soryu - makima: Add context menu and goal auto-update integration

* WIP: heartbeat checkpoint
---
 .../src/components/document/DocumentLayout.tsx     | 316 +++++++++++++++++++++
 1 file changed, 316 insertions(+)
 create mode 100644 frontend/src/components/document/DocumentLayout.tsx

(limited to 'frontend/src/components/document/DocumentLayout.tsx')

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>
+  )
+}
-- 
cgit v1.2.3

