summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-27 18:19:06 +0100
committersoryu <soryu@soryu.co>2026-04-27 18:19:06 +0100
commita2264d327c09a8933b845cefda43b491fa4f7b49 (patch)
treea0783e8e7ab909af220a0608e00f660c12d3e93c
parent9d0121aba64475a4429c8dd2ff8c5c0224563829 (diff)
parentbea9ed4344c342fa5ba710d19e2cb554d9e183eb (diff)
downloadsoryu-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.tsx35
-rw-r--r--frontend/src/components/VNInterface.tsx38
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx20
-rw-r--r--frontend/src/components/document/DocumentLayout.css20
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx385
-rw-r--r--frontend/src/components/document/DocumentSettings.tsx76
-rw-r--r--frontend/src/components/document/index.ts4
-rw-r--r--frontend/src/main.tsx9
-rw-r--r--frontend/src/services/directiveApi.ts187
-rw-r--r--frontend/src/stores/index.ts23
-rw-r--r--frontend/tsconfig.tsbuildinfo2
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