summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-27 18:19:01 +0100
committersoryu <soryu@soryu.co>2026-04-27 18:19:01 +0100
commit9d0121aba64475a4429c8dd2ff8c5c0224563829 (patch)
tree5814e2bf334e6763922474eb7b06268d141aac52
parente04bd6c630ef3573efbfdc08780dcbf48a469c69 (diff)
parent525f01f03b61afea5d553024073e50b1c3ea6b30 (diff)
downloadsoryu-9d0121aba64475a4429c8dd2ff8c5c0224563829.tar.gz
soryu-9d0121aba64475a4429c8dd2ff8c5c0224563829.zip
Merge remote-tracking branch 'origin/makima/soryu-co-soryu---makima--add-context-menu-and-goal-2271a289' into makima/directive-soryu-co-soryu---makima-19fd3e1d
-rw-r--r--frontend/src/components/document/ContextMenu.css79
-rw-r--r--frontend/src/components/document/ContextMenu.tsx98
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx288
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx339
-rw-r--r--frontend/src/components/document/Toast.css100
-rw-r--r--frontend/src/components/document/Toast.tsx97
-rw-r--r--frontend/src/services/directiveApi.ts174
-rw-r--r--frontend/tsconfig.tsbuildinfo2
8 files changed, 822 insertions, 355 deletions
diff --git a/frontend/src/components/document/ContextMenu.css b/frontend/src/components/document/ContextMenu.css
new file mode 100644
index 0000000..4eed119
--- /dev/null
+++ b/frontend/src/components/document/ContextMenu.css
@@ -0,0 +1,79 @@
+/* ============================================
+ Custom Context Menu
+ ============================================ */
+
+.ctx-menu {
+ position: fixed;
+ z-index: 10000;
+ min-width: 200px;
+ background: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
+ padding: 4px 0;
+ animation: ctxFadeIn 0.12s ease-out;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+/* Menu item */
+.ctx-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ width: 100%;
+ padding: 0.5rem 0.85rem;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 0.875rem;
+ color: #1f2937;
+ text-align: left;
+ border-radius: 0;
+ transition: background 0.1s ease;
+}
+
+.ctx-menu-item:hover:not(:disabled) {
+ background: #f3f4f6;
+}
+
+.ctx-menu-item:active:not(:disabled) {
+ background: #e5e7eb;
+}
+
+/* Disabled state */
+.ctx-menu-item-disabled,
+.ctx-menu-item:disabled {
+ color: #9ca3af;
+ cursor: not-allowed;
+}
+
+/* Icon */
+.ctx-menu-icon {
+ font-size: 1rem;
+ width: 1.25rem;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.ctx-menu-label {
+ flex: 1;
+}
+
+/* Divider */
+.ctx-menu-divider {
+ height: 1px;
+ background: #e5e7eb;
+ margin: 4px 0;
+}
+
+/* Animation */
+@keyframes ctxFadeIn {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
diff --git a/frontend/src/components/document/ContextMenu.tsx b/frontend/src/components/document/ContextMenu.tsx
new file mode 100644
index 0000000..5aed940
--- /dev/null
+++ b/frontend/src/components/document/ContextMenu.tsx
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useRef } from 'react';
+import './ContextMenu.css';
+
+export interface ContextMenuAction {
+ label: string;
+ icon: string;
+ disabled?: boolean;
+ onClick: () => void;
+}
+
+export interface ContextMenuProps {
+ x: number;
+ y: number;
+ actions: ContextMenuAction[];
+ dividerAfter?: number[];
+ onClose: () => void;
+}
+
+export default function ContextMenu({
+ x,
+ y,
+ actions,
+ dividerAfter = [],
+ onClose,
+}: ContextMenuProps) {
+ const menuRef = useRef<HTMLDivElement>(null);
+
+ // Adjust position so menu stays within viewport
+ const adjustedPosition = useCallback(() => {
+ const el = menuRef.current;
+ if (!el) return { left: x, top: y };
+ const rect = el.getBoundingClientRect();
+ const left = x + rect.width > window.innerWidth ? x - rect.width : x;
+ const top = y + rect.height > window.innerHeight ? y - rect.height : y;
+ return { left: Math.max(0, left), top: Math.max(0, top) };
+ }, [x, y]);
+
+ // Close on click outside
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+ // Use capture so we catch clicks before any other handler
+ document.addEventListener('mousedown', handler, true);
+ return () => document.removeEventListener('mousedown', handler, true);
+ }, [onClose]);
+
+ // Close on Escape
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [onClose]);
+
+ // After mount, adjust position
+ useEffect(() => {
+ const el = menuRef.current;
+ if (!el) return;
+ const pos = adjustedPosition();
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ }, [adjustedPosition]);
+
+ const dividerSet = new Set(dividerAfter);
+
+ return (
+ <div
+ ref={menuRef}
+ className="ctx-menu"
+ style={{ left: x, top: y }}
+ role="menu"
+ >
+ {actions.map((action, i) => (
+ <div key={i}>
+ <button
+ className={`ctx-menu-item ${action.disabled ? 'ctx-menu-item-disabled' : ''}`}
+ role="menuitem"
+ disabled={action.disabled}
+ onClick={() => {
+ if (!action.disabled) {
+ action.onClick();
+ onClose();
+ }
+ }}
+ >
+ <span className="ctx-menu-icon">{action.icon}</span>
+ <span className="ctx-menu-label">{action.label}</span>
+ </button>
+ {dividerSet.has(i) && <div className="ctx-menu-divider" />}
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx
index d8cc27f..ea69816 100644
--- a/frontend/src/components/document/DocumentEditor.tsx
+++ b/frontend/src/components/document/DocumentEditor.tsx
@@ -1,114 +1,187 @@
-import React, { useCallback, useEffect, useMemo } from 'react'
-import { LexicalComposer } from '@lexical/react/LexicalComposer'
-import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
-import { ContentEditable } from '@lexical/react/LexicalContentEditable'
-import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
-import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
-import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
-import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
-import { HeadingNode } from '@lexical/rich-text'
-import { ListNode, ListItemNode } from '@lexical/list'
-import { LinkNode } from '@lexical/link'
-import { $getRoot, $createParagraphNode, $createTextNode, EditorState } from 'lexical'
-import { DirectiveWithSteps } from '../../services/directiveApi'
-import { StepsDiagramNode, $createStepsDiagramNode, $isStepsDiagramNode } from './nodes/StepsDiagramNode'
-import editorTheme from './EditorTheme'
-import AutoSavePlugin from './AutoSavePlugin'
-import './DocumentEditor.css'
-import './nodes/StepsDiagram.css'
+import { useCallback, useRef, useState } from 'react';
+import { LexicalComposer } from '@lexical/react/LexicalComposer';
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
+import { ContentEditable } from '@lexical/react/LexicalContentEditable';
+import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
+import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
+import { HeadingNode } from '@lexical/rich-text';
+import { ListNode, ListItemNode } from '@lexical/list';
+import { LinkNode } from '@lexical/link';
+import {
+ $getRoot,
+ $createParagraphNode,
+ $createTextNode,
+ type EditorState,
+ type LexicalEditor,
+} from 'lexical';
+import { $createHeadingNode } from '@lexical/rich-text';
+
+import editorTheme from './EditorTheme';
+import AutoSavePlugin from './AutoSavePlugin';
+import ContextMenu, { type ContextMenuAction } from './ContextMenu';
+import './DocumentEditor.css';
interface DocumentEditorProps {
- directive: DirectiveWithSteps
- onGoalChange: (goal: string) => void
+ directiveId: string;
+ title: string;
+ goal: string;
+ status: string;
+ prBranch?: string | null;
+ onGoalChange?: (newGoal: string) => void;
+ onTitleChange?: (newTitle: string) => void;
+ onCleanup?: () => void;
+ onCreatePr?: () => void;
+ onPlanOrders?: () => void;
+ onTogglePause?: () => void;
+ readOnly?: boolean;
}
-// Plugin that initializes the editor content from the directive
-function InitializePlugin({ directive }: { directive: DirectiveWithSteps }) {
- const [editor] = useLexicalComposerContext()
- const initializedRef = React.useRef(false)
-
- useEffect(() => {
- if (initializedRef.current) return
- initializedRef.current = true
-
- editor.update(() => {
- const root = $getRoot()
- root.clear()
-
- // Add goal text as paragraphs
- const goalText = directive.goal || ''
- const lines = goalText.split('\n')
- for (const line of lines) {
- const paragraph = $createParagraphNode()
- if (line.trim()) {
- paragraph.append($createTextNode(line))
- }
- root.append(paragraph)
- }
+function buildInitialEditorState(title: string, goal: string) {
+ return () => {
+ const root = $getRoot();
- // Insert steps diagram node after the goal content
- const stepsNode = $createStepsDiagramNode(directive.id)
- root.append(stepsNode)
+ // Title as H1
+ const heading = $createHeadingNode('h1');
+ heading.append($createTextNode(title));
+ root.append(heading);
- // Add a trailing paragraph so the user can type below the diagram
- const trailingParagraph = $createParagraphNode()
- root.append(trailingParagraph)
- })
- }, [editor, directive])
+ // Goal as paragraph(s), split by newlines
+ const lines = goal.split('\n');
+ for (const line of lines) {
+ const paragraph = $createParagraphNode();
+ if (line.trim()) {
+ paragraph.append($createTextNode(line));
+ }
+ root.append(paragraph);
+ }
+ };
+}
- return null
+function onError(error: Error) {
+ console.error('[DocumentEditor] Lexical error:', error);
}
-export function DocumentEditor({ directive, onGoalChange }: DocumentEditorProps) {
- const initialConfig = useMemo(
- () => ({
- namespace: 'DirectiveEditor',
- theme: editorTheme,
- nodes: [
- HeadingNode,
- ListNode,
- ListItemNode,
- LinkNode,
- StepsDiagramNode,
- ],
- onError: (error: Error) => {
- console.error('Lexical error:', error)
- },
- }),
- []
- )
+export default function DocumentEditor({
+ directiveId,
+ title,
+ goal,
+ status,
+ prBranch,
+ onGoalChange,
+ onTitleChange,
+ onCleanup,
+ onCreatePr,
+ onPlanOrders,
+ onTogglePause,
+ readOnly = false,
+}: DocumentEditorProps) {
+ const editorRef = useRef<LexicalEditor | null>(null);
+ const latestGoalRef = useRef(goal);
+ const latestTitleRef = useRef(title);
- const getContent = useCallback(() => {
- // This will be called by AutoSavePlugin - return goal text only
- // (exclude the steps diagram node content)
- return directive.goal || ''
- }, [directive.goal])
+ // Context menu state
+ const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
+
+ const initialConfig = {
+ namespace: `DocumentEditor-${directiveId}`,
+ theme: editorTheme,
+ editorState: buildInitialEditorState(title, goal),
+ nodes: [HeadingNode, ListNode, ListItemNode, LinkNode],
+ onError,
+ editable: !readOnly,
+ };
const handleChange = useCallback(
- (editorState: EditorState) => {
- editorState.read(() => {
- const root = $getRoot()
- const children = root.getChildren()
- // Extract text content from non-diagram nodes
- const textParts: string[] = []
- for (const child of children) {
- if (!$isStepsDiagramNode(child)) {
- const text = child.getTextContent()
- textParts.push(text)
+ (_editorState: EditorState, editor: LexicalEditor) => {
+ editorRef.current = editor;
+
+ editor.getEditorState().read(() => {
+ const root = $getRoot();
+ const children = root.getChildren();
+
+ let newTitle = '';
+ const goalLines: string[] = [];
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const text = child.getTextContent();
+
+ if (i === 0 && child.getType() === 'heading') {
+ newTitle = text;
+ } else {
+ goalLines.push(text);
}
}
- // Join but don't trigger save if content hasn't changed
- const newGoal = textParts.join('\n')
- if (newGoal !== directive.goal) {
- // Goal change will be debounced by AutoSavePlugin
+
+ const newGoal = goalLines.join('\n');
+
+ if (newTitle !== latestTitleRef.current) {
+ latestTitleRef.current = newTitle;
+ onTitleChange?.(newTitle);
+ }
+
+ if (newGoal !== latestGoalRef.current) {
+ latestGoalRef.current = newGoal;
+ onGoalChange?.(newGoal);
}
- })
+ });
+ },
+ [onGoalChange, onTitleChange]
+ );
+
+ const getContent = useCallback(() => {
+ return latestGoalRef.current;
+ }, []);
+
+ const handleAutoSave = useCallback(
+ (content: string) => {
+ onGoalChange?.(content);
},
- [directive.goal]
- )
+ [onGoalChange]
+ );
+
+ // Context menu handler
+ const handleContextMenu = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ setCtxMenu({ x: e.clientX, y: e.clientY });
+ },
+ []
+ );
+
+ const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
+
+ const isPaused = status === 'paused';
+ const isIdle = status === 'idle';
+
+ const ctxActions: ContextMenuAction[] = [
+ {
+ label: 'Clean Up',
+ icon: '\uD83E\uDDF9', // broom
+ disabled: !isIdle,
+ onClick: () => onCleanup?.(),
+ },
+ {
+ label: 'Update PR',
+ icon: '\uD83D\uDD00', // shuffle
+ disabled: !prBranch,
+ onClick: () => onCreatePr?.(),
+ },
+ {
+ label: 'Plan Orders',
+ icon: '\uD83D\uDCCB', // clipboard
+ onClick: () => onPlanOrders?.(),
+ },
+ {
+ label: isPaused ? 'Resume Directive' : 'Pause Directive',
+ icon: isPaused ? '\u25B6' : '\u23F8',
+ onClick: () => onTogglePause?.(),
+ },
+ ];
return (
- <div className="document-editor-container">
+ <div className="document-editor-container" onContextMenu={handleContextMenu}>
<LexicalComposer initialConfig={initialConfig}>
<div className="doc-editor-input">
<RichTextPlugin
@@ -116,22 +189,31 @@ export function DocumentEditor({ directive, onGoalChange }: DocumentEditorProps)
<ContentEditable className="doc-editor-content-editable doc-editor-root" />
}
placeholder={
- <div className="doc-editor-placeholder">
- Describe your goal...
- </div>
+ <div className="doc-editor-placeholder">Start writing...</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
<HistoryPlugin />
<OnChangePlugin onChange={handleChange} />
- <InitializePlugin directive={directive} />
- <AutoSavePlugin
- onAutoSave={onGoalChange}
- getContent={getContent}
- enabled={true}
- />
+ {!readOnly && (
+ <AutoSavePlugin
+ onAutoSave={handleAutoSave}
+ getContent={getContent}
+ enabled={!readOnly}
+ />
+ )}
</LexicalComposer>
+
+ {ctxMenu && (
+ <ContextMenu
+ x={ctxMenu.x}
+ y={ctxMenu.y}
+ actions={ctxActions}
+ dividerAfter={[2]}
+ onClose={closeCtxMenu}
+ />
+ )}
</div>
- )
+ );
}
diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx
index 271ed04..52c4ada 100644
--- a/frontend/src/components/document/DocumentLayout.tsx
+++ b/frontend/src/components/document/DocumentLayout.tsx
@@ -1,184 +1,195 @@
-import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react'
-import { useParams } from 'react-router-dom'
-import { DirectiveFileTree } from './DirectiveFileTree'
-import { getDirective, updateGoal, DirectiveWithSteps } from '../../services/directiveApi'
-import './DocumentLayout.css'
-
-// DocumentEditor is created in a parallel step - use lazy import
-const DocumentEditor = lazy(() =>
- import('./DocumentEditor').then(mod => ({
- default: mod.DocumentEditor,
- }))
-)
-
-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>
- )
+import { useCallback, useEffect, useRef, useState } from 'react';
+import DocumentEditor from './DocumentEditor';
+import { ToastProvider, useToast } from './Toast';
+import {
+ type Directive,
+ type DirectiveStep,
+ getDirective,
+ getDirectiveSteps,
+ updateGoal,
+ updateDirective,
+ cleanupDirective,
+ createPr,
+ pickUpOrders,
+ pauseDirective,
+ startDirective,
+} from '../../services/directiveApi';
+
+interface DocumentLayoutProps {
+ directiveId: string;
}
-export function DocumentLayout() {
- const { id: urlDirectiveId } = useParams<{ id: string }>()
- 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)
-
- // Sync URL param on mount
- useEffect(() => {
- if (urlDirectiveId && urlDirectiveId !== selectedId) {
- setSelectedId(urlDirectiveId)
+// Inner component that can use the toast context
+function DocumentLayoutInner({ directiveId }: DocumentLayoutProps) {
+ const { addToast } = useToast();
+
+ 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);
+
+ // -- Fetch directive + steps -----------------------------------------------
+
+ const fetchDirective = useCallback(async () => {
+ try {
+ const d = await getDirective(directiveId);
+ setDirective(d);
+ } catch (e) {
+ addToast(`Failed to load directive: ${(e as Error).message}`, 'error');
}
- }, [urlDirectiveId])
+ }, [directiveId, addToast]);
- // Load directive when selected
- useEffect(() => {
- if (!selectedId) {
- setDirective(null)
- return
+ const fetchSteps = useCallback(async () => {
+ try {
+ const s = await getDirectiveSteps(directiveId);
+ setSteps(s);
+ } catch {
+ // Silently fail for step polling
}
+ }, [directiveId]);
+
+ // Initial load
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ await Promise.all([fetchDirective(), fetchSteps()]);
+ if (!cancelled) setLoading(false);
+ })();
+ return () => { cancelled = true; };
+ }, [fetchDirective, fetchSteps]);
+
+ // -- Step polling (after goal update triggers supervisor) -------------------
+
+ const startStepPolling = useCallback(() => {
+ // Clear any existing poll
+ if (pollRef.current) clearInterval(pollRef.current);
+ pollRef.current = setInterval(async () => {
+ await fetchSteps();
+ }, 3000);
+ // Stop after 60 seconds
+ setTimeout(() => {
+ if (pollRef.current) {
+ clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ }, 60000);
+ }, [fetchSteps]);
+
+ useEffect(() => {
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
+ }, []);
- let cancelled = false
- async function load() {
+ // -- Callbacks for DocumentEditor ------------------------------------------
+
+ const handleGoalChange = useCallback(
+ async (newGoal: string) => {
try {
- setLoading(true)
- setError(null)
- const data = await getDirective(selectedId!)
- if (!cancelled) setDirective(data)
- } catch (err) {
- if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load directive')
- } finally {
- if (!cancelled) setLoading(false)
+ 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');
}
- }
- load()
+ },
+ [directiveId, addToast, startStepPolling]
+ );
- return () => { cancelled = true }
- }, [selectedId])
+ 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]
+ );
- // Auto-save goal changes
- const handleGoalChange = useCallback(async (goal: string) => {
- if (!selectedId) return
+ const handleCleanup = useCallback(async () => {
try {
- const updated = await updateGoal(selectedId, goal)
- setDirective(updated)
- } catch (err) {
- console.error('Failed to save goal:', err)
+ await cleanupDirective(directiveId);
+ addToast('Cleanup task spawned', 'success');
+ startStepPolling();
+ } catch (e) {
+ addToast(`Cleanup failed: ${(e as Error).message}`, 'error');
}
- }, [selectedId])
-
- // 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)
+ }, [directiveId, addToast, startStepPolling]);
+
+ const handleCreatePr = useCallback(async () => {
+ try {
+ await createPr(directiveId);
+ addToast('PR update triggered', 'success');
+ } catch (e) {
+ addToast(`PR update failed: ${(e as Error).message}`, 'error');
}
+ }, [directiveId, addToast]);
- const handleMouseUp = () => {
- resizingRef.current = false
- document.body.style.cursor = ''
- document.body.style.userSelect = ''
- document.removeEventListener('mousemove', handleMouseMove)
- document.removeEventListener('mouseup', handleMouseUp)
+ const handlePlanOrders = useCallback(async () => {
+ try {
+ await pickUpOrders(directiveId);
+ addToast('Planning orders...', 'info');
+ startStepPolling();
+ } catch (e) {
+ addToast(`Plan orders failed: ${(e as Error).message}`, 'error');
}
+ }, [directiveId, addToast, startStepPolling]);
- document.addEventListener('mousemove', handleMouseMove)
- document.addEventListener('mouseup', handleMouseUp)
- }, [sidebarWidth])
+ const handleTogglePause = useCallback(async () => {
+ if (!directive) return;
+ try {
+ if (directive.status === 'paused') {
+ const result = await startDirective(directiveId);
+ setDirective(result.directive);
+ setSteps(result.steps);
+ addToast('Directive resumed', 'success');
+ } else {
+ const updated = await pauseDirective(directiveId);
+ setDirective(updated);
+ addToast('Directive paused', 'info');
+ }
+ } catch (e) {
+ addToast(`Failed to toggle pause: ${(e as Error).message}`, 'error');
+ }
+ }, [directiveId, directive, addToast]);
- const handleNewDirective = useCallback(() => {
- // Placeholder - will be implemented with full directive creation flow
- console.log('New directive requested')
- }, [])
+ // -- Render ----------------------------------------------------------------
+
+ if (loading || !directive) {
+ return <div className="document-editor-container" style={{ textAlign: 'center', padding: '4rem 0', color: '#9ca3af' }}>Loading...</div>;
+ }
+
+ 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}
+ />
+ );
+}
+// Wrapper that provides toast context
+export default function DocumentLayout({ directiveId }: DocumentLayoutProps) {
return (
- <div className="document-layout">
- {/* Sidebar */}
- <div className="document-sidebar" style={{ width: sidebarWidth }}>
- <DirectiveFileTree
- selectedDirectiveId={selectedId}
- onSelectDirective={setSelectedId}
- 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 && (
- <Suspense fallback={
- <div className="document-placeholder">
- <p>Loading editor...</p>
- </div>
- }>
- <DocumentEditor directive={directive} onGoalChange={handleGoalChange} />
- </Suspense>
- )}
- </div>
- </div>
- </div>
- )
+ <ToastProvider>
+ <DocumentLayoutInner directiveId={directiveId} />
+ </ToastProvider>
+ );
}
diff --git a/frontend/src/components/document/Toast.css b/frontend/src/components/document/Toast.css
new file mode 100644
index 0000000..e97304c
--- /dev/null
+++ b/frontend/src/components/document/Toast.css
@@ -0,0 +1,100 @@
+/* ============================================
+ Toast Notifications
+ ============================================ */
+
+.toast-container {
+ position: fixed;
+ bottom: 1.5rem;
+ right: 1.5rem;
+ z-index: 9999;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 0.5rem;
+ pointer-events: none;
+}
+
+.toast-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.65rem 1rem;
+ border-radius: 8px;
+ font-size: 0.875rem;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08);
+ pointer-events: auto;
+ max-width: 360px;
+ backdrop-filter: blur(6px);
+}
+
+/* Types */
+.toast-success {
+ background: #ecfdf5;
+ color: #065f46;
+ border: 1px solid #a7f3d0;
+}
+
+.toast-error {
+ background: #fef2f2;
+ color: #991b1b;
+ border: 1px solid #fecaca;
+}
+
+.toast-info {
+ background: #eff6ff;
+ color: #1e40af;
+ border: 1px solid #bfdbfe;
+}
+
+/* Icon */
+.toast-icon {
+ flex-shrink: 0;
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.toast-message {
+ line-height: 1.4;
+}
+
+/* Animations */
+.toast-enter {
+ animation: toastSlideIn 0.25s ease-out forwards;
+}
+
+.toast-exit {
+ animation: toastSlideOut 0.3s ease-in forwards;
+}
+
+@keyframes toastSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes toastSlideOut {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(8px) scale(0.96);
+ }
+}
+
+@media (max-width: 640px) {
+ .toast-container {
+ right: 0.75rem;
+ bottom: 0.75rem;
+ }
+ .toast-item {
+ max-width: calc(100vw - 1.5rem);
+ font-size: 0.8rem;
+ }
+}
diff --git a/frontend/src/components/document/Toast.tsx b/frontend/src/components/document/Toast.tsx
new file mode 100644
index 0000000..653db8f
--- /dev/null
+++ b/frontend/src/components/document/Toast.tsx
@@ -0,0 +1,97 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import './Toast.css';
+
+// -- Types -------------------------------------------------------------------
+
+export type ToastType = 'success' | 'error' | 'info';
+
+interface ToastItem {
+ id: number;
+ message: string;
+ type: ToastType;
+}
+
+interface ToastContextValue {
+ addToast: (message: string, type?: ToastType) => void;
+}
+
+// -- Context -----------------------------------------------------------------
+
+const ToastContext = createContext<ToastContextValue | null>(null);
+
+export function useToast(): ToastContextValue {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error('useToast must be used within a ToastProvider');
+ return ctx;
+}
+
+// -- Provider ----------------------------------------------------------------
+
+const DISMISS_MS = 3000;
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
+ const nextId = useRef(0);
+
+ const addToast = useCallback((message: string, type: ToastType = 'info') => {
+ const id = nextId.current++;
+ setToasts((prev) => [...prev, { id, message, type }]);
+ }, []);
+
+ const removeToast = useCallback((id: number) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ return (
+ <ToastContext.Provider value={{ addToast }}>
+ {children}
+ <div className="toast-container">
+ {toasts.map((t) => (
+ <ToastItem key={t.id} toast={t} onDismiss={removeToast} />
+ ))}
+ </div>
+ </ToastContext.Provider>
+ );
+}
+
+// -- Single toast ------------------------------------------------------------
+
+function ToastItem({
+ toast,
+ onDismiss,
+}: {
+ toast: ToastItem;
+ onDismiss: (id: number) => void;
+}) {
+ const [exiting, setExiting] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setExiting(true), DISMISS_MS - 300);
+ const remove = setTimeout(() => onDismiss(toast.id), DISMISS_MS);
+ return () => {
+ clearTimeout(timer);
+ clearTimeout(remove);
+ };
+ }, [toast.id, onDismiss]);
+
+ const icon =
+ toast.type === 'success' ? '\u2713' : toast.type === 'error' ? '\u2717' : '\u2139';
+
+ return (
+ <div
+ className={`toast-item toast-${toast.type} ${exiting ? 'toast-exit' : 'toast-enter'}`}
+ role="status"
+ >
+ <span className="toast-icon">{icon}</span>
+ <span className="toast-message">{toast.message}</span>
+ </div>
+ );
+}
diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts
index 2c8baec..71d77dd 100644
--- a/frontend/src/services/directiveApi.ts
+++ b/frontend/src/services/directiveApi.ts
@@ -1,117 +1,117 @@
-// API service for directive operations
-
-export interface DirectiveStepCounts {
- pending: number
- ready: number
- running: number
- completed: number
- failed: number
- skipped: number
-}
+// API service for directive CRUD and actions.
-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
-}
+const BASE = '/api/v1/directives';
-export interface DirectiveStep {
- id: string
- directiveId: string
- name: string
- description: string
- taskPlan: string
- dependsOn: string[]
- status: string
- taskId: string
- contractId: string
- orderIndex: number
- completedAt: string
+function headers(): HeadersInit {
+ return {
+ 'Content-Type': 'application/json',
+ };
}
-export interface DirectiveWithSteps extends DirectiveSummary {
- steps: DirectiveStep[]
- reconcileMode: string
+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>;
}
-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) {
- throw new Error(`API error ${response.status}: ${response.statusText}`)
- }
- return response
+// -- Types -------------------------------------------------------------------
+
+export interface Directive {
+ id: string;
+ title: string;
+ goal: string;
+ status: string;
+ pr_branch?: string | null;
+ version: number;
+ created_at: string;
+ updated_at: string;
}
-export async function listDirectives(): Promise<DirectiveSummary[]> {
- const response = await apiFetch('/api/v1/directives')
- const data = await response.json()
- return data.directives || []
+export interface DirectiveStep {
+ id: string;
+ directive_id: string;
+ title: string;
+ description?: string | null;
+ status: string;
+ sort_order: number;
}
-export async function getDirective(id: string): Promise<DirectiveWithSteps> {
- const response = await apiFetch(`/api/v1/directives/${id}`)
- return response.json()
+export interface DirectiveWithSteps {
+ directive: Directive;
+ steps: DirectiveStep[];
}
-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()
+// -- Endpoints ---------------------------------------------------------------
+
+export async function getDirective(id: string): Promise<Directive> {
+ const res = await fetch(`${BASE}/${id}`, { headers: headers() });
+ return handleResponse<Directive>(res);
}
-export async function updateDirective(id: string, updates: Partial<DirectiveSummary>): Promise<DirectiveWithSteps> {
- const response = await apiFetch(`/api/v1/directives/${id}`, {
+export async function updateDirective(
+ id: string,
+ body: { title?: string; goal?: string; version: number },
+): Promise<Directive> {
+ const res = await fetch(`${BASE}/${id}`, {
method: 'PUT',
- body: JSON.stringify(updates),
- })
- return response.json()
+ headers: headers(),
+ body: JSON.stringify(body),
+ });
+ return handleResponse<Directive>(res);
}
-export async function cleanupDirective(id: string): Promise<void> {
- await apiFetch(`/api/v1/directives/${id}/cleanup`, { method: 'POST' })
+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 createPr(id: string): Promise<{ prUrl: string }> {
- const response = await apiFetch(`/api/v1/directives/${id}/create-pr`, { method: 'POST' })
- return response.json()
+export async function getDirectiveSteps(id: string): Promise<DirectiveStep[]> {
+ const res = await fetch(`${BASE}/${id}/steps`, { headers: headers() });
+ return handleResponse<DirectiveStep[]>(res);
}
-export async function pickUpOrders(id: string): Promise<void> {
- await apiFetch(`/api/v1/directives/${id}/pick-up-orders`, { method: 'POST' })
+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 startDirective(id: string): Promise<void> {
- await apiFetch(`/api/v1/directives/${id}/start`, { 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 pauseDirective(id: string): Promise<void> {
- await apiFetch(`/api/v1/directives/${id}/pause`, { method: 'POST' })
+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 getUserSetting(key: string): Promise<any> {
- const response = await apiFetch(`/api/v1/settings/${key}`)
- 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 upsertUserSetting(key: string, value: any): Promise<void> {
- await apiFetch('/api/v1/settings', {
- method: 'PUT',
- body: JSON.stringify({ key, value }),
- })
+export async function startDirective(id: string): Promise<DirectiveWithSteps> {
+ const res = await fetch(`${BASE}/${id}/start`, {
+ method: 'POST',
+ headers: headers(),
+ });
+ return handleResponse<DirectiveWithSteps>(res);
}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index 48c31c7..9a49e49 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/directivefiletree.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/editortheme.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
+{"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