diff options
| author | soryu <soryu@soryu.co> | 2026-04-27 18:19:06 +0100 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-04-27 18:19:06 +0100 |
| commit | a2264d327c09a8933b845cefda43b491fa4f7b49 (patch) | |
| tree | a0783e8e7ab909af220a0608e00f660c12d3e93c | |
| parent | 9d0121aba64475a4429c8dd2ff8c5c0224563829 (diff) | |
| parent | bea9ed4344c342fa5ba710d19e2cb554d9e183eb (diff) | |
| download | soryu-makima/directive-soryu-co-soryu---makima-19fd3e1d.tar.gz soryu-makima/directive-soryu-co-soryu---makima-19fd3e1d.zip | |
Merge remote-tracking branch 'origin/makima/soryu-co-soryu---makima--feature-flag-toggle-routi-d15fce4c' into makima/directive-soryu-co-soryu---makima-19fd3e1dmakima/directive-soryu-co-soryu---makima-19fd3e1d
| -rw-r--r-- | frontend/src/components/ConfigModal.tsx | 35 | ||||
| -rw-r--r-- | frontend/src/components/VNInterface.tsx | 38 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentEditor.tsx | 20 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.css | 20 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.tsx | 385 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentSettings.tsx | 76 | ||||
| -rw-r--r-- | frontend/src/components/document/index.ts | 4 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 9 | ||||
| -rw-r--r-- | frontend/src/services/directiveApi.ts | 187 | ||||
| -rw-r--r-- | frontend/src/stores/index.ts | 23 | ||||
| -rw-r--r-- | frontend/tsconfig.tsbuildinfo | 2 |
11 files changed, 575 insertions, 224 deletions
diff --git a/frontend/src/components/ConfigModal.tsx b/frontend/src/components/ConfigModal.tsx index e7b1f9f..9746e4e 100644 --- a/frontend/src/components/ConfigModal.tsx +++ b/frontend/src/components/ConfigModal.tsx @@ -1,4 +1,7 @@ import React from 'react' +import { useStore } from '@nanostores/react' +import { Link } from 'react-router-dom' +import { documentUiEnabledStore, setDocumentUiEnabled } from '../stores' type Props = { isOpen: boolean @@ -8,6 +11,8 @@ type Props = { } export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => { + const documentUiEnabled = useStore(documentUiEnabledStore) + if (!isOpen) return null return ( @@ -15,9 +20,9 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki <div className="config-modal" onClick={e => e.stopPropagation()}> <div className="modal-header"> <h2>Configuration</h2> - <button className="close-btn" onClick={onClose}>×</button> + <button className="close-btn" onClick={onClose}>{'\u00D7'}</button> </div> - + <div className="modal-content"> <div className="config-option"> <label className="config-label"> @@ -33,8 +38,32 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki Skip the loading screen animation on startup </div> </div> + + <div className="config-option" style={{ marginTop: '16px' }}> + <label className="config-label"> + <input + type="checkbox" + checked={documentUiEnabled} + onChange={e => setDocumentUiEnabled(e.target.checked)} + className="config-checkbox" + /> + <span className="config-text">Enable Document UI (Experimental)</span> + </label> + <div className="config-description"> + Replace the directive management interface with an interactive document editor. This is a proof of concept. + </div> + {documentUiEnabled && ( + <Link + to="/directives" + style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }} + onClick={onClose} + > + Open Directives Editor {'\u2192'} + </Link> + )} + </div> </div> - + <div className="modal-footer"> <button className="modal-btn" onClick={onClose}>Close</button> </div> diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx index 318a9b9..051c210 100644 --- a/frontend/src/components/VNInterface.tsx +++ b/frontend/src/components/VNInterface.tsx @@ -9,9 +9,11 @@ import { showSettingsModalStore, isVisibleStore, yenBalanceStore, + documentUiEnabledStore, toggleStandby, toggleShowChoices, - updateTime + updateTime, + setDocumentUiEnabled, } from '../stores' interface VNInterfaceProps { @@ -26,6 +28,7 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { const showSettingsModal = useStore(showSettingsModalStore) const isVisible = useStore(isVisibleStore) const yenBalance = useStore(yenBalanceStore) + const documentUiEnabled = useStore(documentUiEnabledStore) // Fade in effect on mount useEffect(() => { @@ -113,6 +116,14 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { <span className="info-value">Contracts</span> </Link> </div> + {documentUiEnabled && ( + <div className="status-item"> + <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> + <span className="info-label">Docs:</span> + <span className="info-value">Directives</span> + </Link> + </div> + )} </div> </div> </div> @@ -198,6 +209,31 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { </div> </div> <div className="settings-section"> + <h3>Experimental</h3> + <div className="setting-item"> + <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}> + <input + type="checkbox" + checked={documentUiEnabled} + onChange={(e) => setDocumentUiEnabled(e.target.checked)} + /> + Document UI (Experimental) + </label> + <p style={{ fontSize: '0.8em', color: '#9ca3af', marginTop: '4px' }}> + Replace the directive management interface with an interactive document editor. This is a proof of concept. + </p> + {documentUiEnabled && ( + <Link + to="/directives" + style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }} + onClick={() => showSettingsModalStore.set(false)} + > + Open Directives Editor {'\u2192'} + </Link> + )} + </div> + </div> + <div className="settings-section"> <h3>Audio</h3> <div className="setting-item"> <label>Master Volume</label> diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx index ea69816..d50c093 100644 --- a/frontend/src/components/document/DocumentEditor.tsx +++ b/frontend/src/components/document/DocumentEditor.tsx @@ -17,10 +17,12 @@ import { } from 'lexical'; import { $createHeadingNode } from '@lexical/rich-text'; +import { StepsDiagramNode, $isStepsDiagramNode, $createStepsDiagramNode } from './nodes/StepsDiagramNode'; import editorTheme from './EditorTheme'; import AutoSavePlugin from './AutoSavePlugin'; import ContextMenu, { type ContextMenuAction } from './ContextMenu'; import './DocumentEditor.css'; +import './nodes/StepsDiagram.css'; interface DocumentEditorProps { directiveId: string; @@ -37,7 +39,7 @@ interface DocumentEditorProps { readOnly?: boolean; } -function buildInitialEditorState(title: string, goal: string) { +function buildInitialEditorState(directiveId: string, title: string, goal: string) { return () => { const root = $getRoot(); @@ -55,6 +57,14 @@ function buildInitialEditorState(title: string, goal: string) { } root.append(paragraph); } + + // Insert steps diagram node after the goal content + const stepsNode = $createStepsDiagramNode(directiveId); + root.append(stepsNode); + + // Add a trailing paragraph so the user can type below the diagram + const trailingParagraph = $createParagraphNode(); + root.append(trailingParagraph); }; } @@ -86,8 +96,8 @@ export default function DocumentEditor({ const initialConfig = { namespace: `DocumentEditor-${directiveId}`, theme: editorTheme, - editorState: buildInitialEditorState(title, goal), - nodes: [HeadingNode, ListNode, ListItemNode, LinkNode], + editorState: buildInitialEditorState(directiveId, title, goal), + nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode], onError, editable: !readOnly, }; @@ -105,6 +115,9 @@ export default function DocumentEditor({ for (let i = 0; i < children.length; i++) { const child = children[i]; + // Skip the steps diagram node when extracting text + if ($isStepsDiagramNode(child)) continue; + const text = child.getTextContent(); if (i === 0 && child.getType() === 'heading') { @@ -217,3 +230,4 @@ export default function DocumentEditor({ </div> ); } + diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css index 5a6d8d5..b18bb81 100644 --- a/frontend/src/components/document/DocumentLayout.css +++ b/frontend/src/components/document/DocumentLayout.css @@ -18,6 +18,26 @@ border-right: 1px solid #2a2a4a; } +/* Back link */ +.document-sidebar-back { + padding: 8px 12px; + border-bottom: 1px solid #2a2a4a; +} + +.document-back-link { + color: #9ca3af; + text-decoration: none; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 4px; + transition: color 0.15s; +} + +.document-back-link:hover { + color: #e0e0e0; +} + /* Resize handle */ .document-resize-handle { width: 4px; diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx index 52c4ada..a555ad0 100644 --- a/frontend/src/components/document/DocumentLayout.tsx +++ b/frontend/src/components/document/DocumentLayout.tsx @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import DocumentEditor from './DocumentEditor'; -import { ToastProvider, useToast } from './Toast'; +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 Directive, + type DirectiveWithSteps, type DirectiveStep, getDirective, getDirectiveSteps, @@ -13,183 +15,302 @@ import { pickUpOrders, pauseDirective, startDirective, -} from '../../services/directiveApi'; +} from '../../services/directiveApi' +import './DocumentLayout.css' -interface DocumentLayoutProps { - directiveId: string; -} +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' -// Inner component that can use the toast context -function DocumentLayoutInner({ directiveId }: DocumentLayoutProps) { - const { addToast } = useToast(); + return ( + <span className="doc-status-badge" style={{ backgroundColor: color }}> + {status} + </span> + ) +} - const [directive, setDirective] = useState<Directive | null>(null); - const [steps, setSteps] = useState<DirectiveStep[]>([]); - const [loading, setLoading] = useState(true); - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); +function DocumentLayoutInner() { + const { id: urlDirectiveId } = useParams<{ id: string }>() + const navigate = useNavigate() + const { addToast } = useToast() - // -- Fetch directive + steps ----------------------------------------------- + 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) - const fetchDirective = useCallback(async () => { - try { - const d = await getDirective(directiveId); - setDirective(d); - } catch (e) { - addToast(`Failed to load directive: ${(e as Error).message}`, 'error'); + // Sync URL param on mount + useEffect(() => { + if (urlDirectiveId && urlDirectiveId !== selectedId) { + setSelectedId(urlDirectiveId) } - }, [directiveId, addToast]); + }, [urlDirectiveId]) - const fetchSteps = useCallback(async () => { - try { - const s = await getDirectiveSteps(directiveId); - setSteps(s); - } catch { - // Silently fail for step polling - } - }, [directiveId]); + // Handle directive selection - update URL + const handleSelectDirective = useCallback((id: string) => { + setSelectedId(id) + navigate(`/directives/${id}`, { replace: true }) + }, [navigate]) - // Initial load + // Load directive when selected useEffect(() => { - let cancelled = false; - (async () => { - setLoading(true); - await Promise.all([fetchDirective(), fetchSteps()]); - if (!cancelled) setLoading(false); - })(); - return () => { cancelled = true; }; - }, [fetchDirective, fetchSteps]); + if (!selectedId) { + setDirective(null) + return + } - // -- Step polling (after goal update triggers supervisor) ------------------- + 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(() => { - // Clear any existing poll - if (pollRef.current) clearInterval(pollRef.current); + if (pollRef.current) clearInterval(pollRef.current) pollRef.current = setInterval(async () => { - await fetchSteps(); - }, 3000); + 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; + clearInterval(pollRef.current) + pollRef.current = null } - }, 60000); - }, [fetchSteps]); + }, 60000) + }, [selectedId]) useEffect(() => { return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; - }, []); - - // -- Callbacks for DocumentEditor ------------------------------------------ + if (pollRef.current) clearInterval(pollRef.current) + } + }, []) - const handleGoalChange = useCallback( - async (newGoal: string) => { - try { - const updated = await updateGoal(directiveId, newGoal); - setDirective(updated); - addToast('Goal saved', 'success'); - // Start polling for new steps (supervisor will process) - startStepPolling(); - } catch (e) { - addToast(`Failed to save goal: ${(e as Error).message}`, 'error'); - } - }, - [directiveId, addToast, startStepPolling] - ); + // 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 (!directive) return; - try { - const updated = await updateDirective(directiveId, { - title: newTitle, - version: directive.version, - }); - setDirective(updated); - } catch (e) { - addToast(`Failed to update title: ${(e as Error).message}`, 'error'); - } - }, - [directiveId, directive, addToast] - ); + 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(directiveId); - addToast('Cleanup task spawned', 'success'); - startStepPolling(); - } catch (e) { - addToast(`Cleanup failed: ${(e as Error).message}`, 'error'); + await cleanupDirective(selectedId) + addToast('Cleanup task spawned', 'success') + startStepPolling() + } catch (err) { + addToast(`Cleanup failed: ${(err as Error).message}`, 'error') } - }, [directiveId, addToast, startStepPolling]); + }, [selectedId, addToast, startStepPolling]) const handleCreatePr = useCallback(async () => { + if (!selectedId) return try { - await createPr(directiveId); - addToast('PR update triggered', 'success'); - } catch (e) { - addToast(`PR update failed: ${(e as Error).message}`, 'error'); + await createPr(selectedId) + addToast('PR update triggered', 'success') + } catch (err) { + addToast(`PR update failed: ${(err as Error).message}`, 'error') } - }, [directiveId, addToast]); + }, [selectedId, addToast]) const handlePlanOrders = useCallback(async () => { + if (!selectedId) return try { - await pickUpOrders(directiveId); - addToast('Planning orders...', 'info'); - startStepPolling(); - } catch (e) { - addToast(`Plan orders failed: ${(e as Error).message}`, 'error'); + await pickUpOrders(selectedId) + addToast('Planning orders...', 'info') + startStepPolling() + } catch (err) { + addToast(`Plan orders failed: ${(err as Error).message}`, 'error') } - }, [directiveId, addToast, startStepPolling]); + }, [selectedId, addToast, startStepPolling]) const handleTogglePause = useCallback(async () => { - if (!directive) return; + if (!selectedId || !directive) return try { if (directive.status === 'paused') { - const result = await startDirective(directiveId); - setDirective(result.directive); - setSteps(result.steps); - addToast('Directive resumed', 'success'); + const result = await startDirective(selectedId) + setDirective(result) + addToast('Directive resumed', 'success') } else { - const updated = await pauseDirective(directiveId); - setDirective(updated); - addToast('Directive paused', 'info'); + const updated = await pauseDirective(selectedId) + setDirective(updated) + addToast('Directive paused', 'info') } - } catch (e) { - addToast(`Failed to toggle pause: ${(e as Error).message}`, 'error'); + } catch (err) { + addToast(`Failed to toggle pause: ${(err as Error).message}`, 'error') } - }, [directiveId, directive, addToast]); + }, [selectedId, directive, addToast]) - // -- Render ---------------------------------------------------------------- + // 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' - if (loading || !directive) { - return <div className="document-editor-container" style={{ textAlign: 'center', padding: '4rem 0', color: '#9ca3af' }}>Loading...</div>; - } + 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 ( - <DocumentEditor - directiveId={directive.id} - title={directive.title} - goal={directive.goal} - status={directive.status} - prBranch={directive.pr_branch} - onGoalChange={handleGoalChange} - onTitleChange={handleTitleChange} - onCleanup={handleCleanup} - onCreatePr={handleCreatePr} - onPlanOrders={handlePlanOrders} - onTogglePause={handleTogglePause} - /> - ); + <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({ directiveId }: DocumentLayoutProps) { +export default function DocumentLayout() { return ( <ToastProvider> - <DocumentLayoutInner directiveId={directiveId} /> + <DocumentLayoutInner /> </ToastProvider> - ); + ) } diff --git a/frontend/src/components/document/DocumentSettings.tsx b/frontend/src/components/document/DocumentSettings.tsx new file mode 100644 index 0000000..b575b3d --- /dev/null +++ b/frontend/src/components/document/DocumentSettings.tsx @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react' +import { upsertUserSetting } from '../../services/directiveApi' + +interface DocumentSettingsProps { + isOpen: boolean + onClose: () => void + enabled: boolean + onToggle: (enabled: boolean) => void +} + +export default function DocumentSettings({ + isOpen, + onClose, + enabled, + onToggle, +}: DocumentSettingsProps) { + const [saving, setSaving] = useState(false) + + const handleToggle = useCallback(async () => { + const newValue = !enabled + setSaving(true) + try { + // Update localStorage immediately for instant UI response + localStorage.setItem('document_ui_enabled', JSON.stringify(newValue)) + onToggle(newValue) + + // Persist to backend + await upsertUserSetting('document_ui_enabled', newValue) + } catch (err) { + console.error('Failed to save document UI setting:', err) + // Revert on failure + localStorage.setItem('document_ui_enabled', JSON.stringify(!newValue)) + onToggle(!newValue) + } finally { + setSaving(false) + } + }, [enabled, onToggle]) + + if (!isOpen) return null + + return ( + <div className="modal-overlay" onClick={onClose}> + <div className="config-modal" onClick={(e) => e.stopPropagation()}> + <div className="modal-header"> + <h2>Document UI Settings</h2> + <button className="close-btn" onClick={onClose}>{'\u00D7'}</button> + </div> + + <div className="modal-content"> + <div className="config-option"> + <label className="config-label" style={{ cursor: 'pointer' }}> + <input + type="checkbox" + checked={enabled} + onChange={handleToggle} + disabled={saving} + className="config-checkbox" + /> + <span className="config-text"> + Enable Document UI (Experimental) + </span> + </label> + <div className="config-description"> + Replace the directive management interface with an interactive + document editor. This is a proof of concept. + </div> + </div> + </div> + + <div className="modal-footer"> + <button className="modal-btn" onClick={onClose}>Close</button> + </div> + </div> + </div> + ) +} diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts new file mode 100644 index 0000000..af9e362 --- /dev/null +++ b/frontend/src/components/document/index.ts @@ -0,0 +1,4 @@ +export { default as DocumentLayout } from './DocumentLayout' +export { default as DocumentEditor } from './DocumentEditor' +export { DirectiveFileTree } from './DirectiveFileTree' +export { default as DocumentSettings } from './DocumentSettings' diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 04b8cde..3987f30 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { ContractDetail } from './components/ContractDetail' import { FileDetail } from './components/FileDetail' import { DaemonList } from './components/DaemonList' import { DaemonDetail } from './components/DaemonDetail' +import { DocumentLayout } from './components/document' import './styles/pc98.css' import './styles/mobile.css' @@ -43,6 +44,14 @@ const router = createBrowserRouter([ path: '/daemons/:id', element: <DaemonDetail />, }, + { + path: '/directives', + element: <DocumentLayout />, + }, + { + path: '/directives/:id', + element: <DocumentLayout />, + }, ]) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts index 71d77dd..b82f594 100644 --- a/frontend/src/services/directiveApi.ts +++ b/frontend/src/services/directiveApi.ts @@ -1,117 +1,136 @@ -// API service for directive CRUD and actions. - -const BASE = '/api/v1/directives'; +// API service for directive operations + +export interface DirectiveStepCounts { + pending: number + ready: number + running: number + completed: number + failed: number + skipped: number +} -function headers(): HeadersInit { - return { - 'Content-Type': 'application/json', - }; +export interface DirectiveSummary { + id: string + title: string + goal: string + status: string + repositoryUrl: string + prUrl: string + prBranch: string + createdAt: string + updatedAt: string + goalUpdatedAt: string + stepCounts: DirectiveStepCounts + version?: number + pr_branch?: string | null } -async function handleResponse<T>(res: Response): Promise<T> { - if (!res.ok) { - const body = await res.json().catch(() => ({ message: res.statusText })); - throw new Error(body.message ?? body.error ?? `Request failed (${res.status})`); - } - return res.json() as Promise<T>; +export interface DirectiveStep { + id: string + directiveId: string + directive_id?: string + name: string + title?: string + description: string | null + taskPlan: string + dependsOn: string[] + status: string + taskId: string + contractId: string + orderIndex: number + sort_order?: number + completedAt: string } -// -- Types ------------------------------------------------------------------- +export interface DirectiveWithSteps extends DirectiveSummary { + steps: DirectiveStep[] + reconcileMode: string +} -export interface Directive { - id: string; - title: string; - goal: string; - status: string; - pr_branch?: string | null; - version: number; - created_at: string; - updated_at: string; +// Alias for compatibility with context-menu branch types +export type Directive = DirectiveSummary + +async function apiFetch(path: string, options?: RequestInit): Promise<Response> { + const response = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + if (!response.ok) { + const body = await response.json().catch(() => ({ message: response.statusText })) + throw new Error(body.message ?? body.error ?? `API error ${response.status}: ${response.statusText}`) + } + return response } -export interface DirectiveStep { - id: string; - directive_id: string; - title: string; - description?: string | null; - status: string; - sort_order: number; +export async function listDirectives(): Promise<DirectiveSummary[]> { + const response = await apiFetch('/api/v1/directives') + const data = await response.json() + return data.directives || [] } -export interface DirectiveWithSteps { - directive: Directive; - steps: DirectiveStep[]; +export async function getDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}`) + return response.json() } -// -- Endpoints --------------------------------------------------------------- +export async function getDirectiveSteps(id: string): Promise<DirectiveStep[]> { + const response = await apiFetch(`/api/v1/directives/${id}/steps`) + return response.json() +} -export async function getDirective(id: string): Promise<Directive> { - const res = await fetch(`${BASE}/${id}`, { headers: headers() }); - return handleResponse<Directive>(res); +export async function updateGoal(id: string, goal: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}/goal`, { + method: 'PUT', + body: JSON.stringify({ goal }), + }) + return response.json() } export async function updateDirective( id: string, - body: { title?: string; goal?: string; version: number }, -): Promise<Directive> { - const res = await fetch(`${BASE}/${id}`, { + updates: { title?: string; goal?: string; version?: number }, +): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}`, { method: 'PUT', - headers: headers(), - body: JSON.stringify(body), - }); - return handleResponse<Directive>(res); + body: JSON.stringify(updates), + }) + return response.json() } -export async function updateGoal(id: string, goal: string): Promise<Directive> { - const res = await fetch(`${BASE}/${id}/goal`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify({ goal }), - }); - return handleResponse<Directive>(res); +export async function cleanupDirective(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/cleanup`, { method: 'POST' }) } -export async function getDirectiveSteps(id: string): Promise<DirectiveStep[]> { - const res = await fetch(`${BASE}/${id}/steps`, { headers: headers() }); - return handleResponse<DirectiveStep[]>(res); +export async function createPr(id: string): Promise<{ prUrl: string }> { + const response = await apiFetch(`/api/v1/directives/${id}/create-pr`, { method: 'POST' }) + return response.json() } -export async function cleanupDirective(id: string): Promise<unknown> { - const res = await fetch(`${BASE}/${id}/cleanup`, { - method: 'POST', - headers: headers(), - }); - return handleResponse<unknown>(res); +export async function pickUpOrders(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/pick-up-orders`, { method: 'POST' }) } -export async function createPr(id: string): Promise<unknown> { - const res = await fetch(`${BASE}/${id}/create-pr`, { - method: 'POST', - headers: headers(), - }); - return handleResponse<unknown>(res); +export async function startDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}/start`, { method: 'POST' }) + return response.json() } -export async function pickUpOrders(id: string): Promise<unknown> { - const res = await fetch(`${BASE}/${id}/pick-up-orders`, { - method: 'POST', - headers: headers(), - }); - return handleResponse<unknown>(res); +export async function pauseDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}/pause`, { method: 'POST' }) + return response.json() } -export async function pauseDirective(id: string): Promise<Directive> { - const res = await fetch(`${BASE}/${id}/pause`, { - method: 'POST', - headers: headers(), - }); - return handleResponse<Directive>(res); +export async function getUserSetting(key: string): Promise<any> { + const response = await apiFetch(`/api/v1/settings/${key}`) + return response.json() } -export async function startDirective(id: string): Promise<DirectiveWithSteps> { - const res = await fetch(`${BASE}/${id}/start`, { - method: 'POST', - headers: headers(), - }); - return handleResponse<DirectiveWithSteps>(res); +export async function upsertUserSetting(key: string, value: any): Promise<void> { + await apiFetch('/api/v1/settings', { + method: 'PUT', + body: JSON.stringify({ key, value }), + }) } diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 58f461c..5ee9b08 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -36,6 +36,29 @@ export const skipIntroStore = atom<boolean>( })() ) +// Document UI feature flag +export const documentUiEnabledStore = atom<boolean>( + (() => { + try { + const saved = localStorage.getItem('document_ui_enabled') + return saved === 'true' + } catch { + return false + } + })() +) + +export const setDocumentUiEnabled = (enabled: boolean) => { + documentUiEnabledStore.set(enabled) + localStorage.setItem('document_ui_enabled', JSON.stringify(enabled)) + // Persist to backend (fire-and-forget) + fetch('/api/v1/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'document_ui_enabled', value: enabled }), + }).catch((err) => console.error('Failed to persist document_ui_enabled:', err)) +} + // Actions export const login = () => { isLoggedInStore.set(true) diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 9a49e49..83d0161 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/services/directiveapi.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/directivefiletree.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/documentsettings.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/components/document/index.ts","./src/components/document/nodes/stepsdiagramcomponent.tsx","./src/components/document/nodes/stepsdiagramnode.tsx","./src/services/directiveapi.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file |
