diff options
| author | soryu <soryu@soryu.co> | 2026-04-27 18:19:01 +0100 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-04-27 18:19:01 +0100 |
| commit | 9d0121aba64475a4429c8dd2ff8c5c0224563829 (patch) | |
| tree | 5814e2bf334e6763922474eb7b06268d141aac52 | |
| parent | e04bd6c630ef3573efbfdc08780dcbf48a469c69 (diff) | |
| parent | 525f01f03b61afea5d553024073e50b1c3ea6b30 (diff) | |
| download | soryu-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.css | 79 | ||||
| -rw-r--r-- | frontend/src/components/document/ContextMenu.tsx | 98 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentEditor.tsx | 288 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.tsx | 339 | ||||
| -rw-r--r-- | frontend/src/components/document/Toast.css | 100 | ||||
| -rw-r--r-- | frontend/src/components/document/Toast.tsx | 97 | ||||
| -rw-r--r-- | frontend/src/services/directiveApi.ts | 174 | ||||
| -rw-r--r-- | frontend/tsconfig.tsbuildinfo | 2 |
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 |
