diff options
| author | soryu <soryu@soryu.co> | 2026-04-27 17:17:56 +0100 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-04-27 17:17:56 +0100 |
| commit | 04572ad347ba2bd02207bd7b1b6ba5d35705c956 (patch) | |
| tree | 0f7a8312c3f8c85a41f8fbd9e70dd33223d2db46 | |
| parent | 3679ceb3325033faa2f889ef3dfee5668ef7aeea (diff) | |
| download | soryu-04572ad347ba2bd02207bd7b1b6ba5d35705c956.tar.gz soryu-04572ad347ba2bd02207bd7b1b6ba5d35705c956.zip | |
feat: soryu-co/soryu - makima: Create directive file system sidebar and document layoutmakima/soryu-co-soryu---makima--create-directive-file-sys-b432b46e
| -rw-r--r-- | frontend/package-lock.json | 8 | ||||
| -rw-r--r-- | frontend/src/components/document/DirectiveFileTree.tsx | 154 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentEditor.tsx | 20 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.css | 327 | ||||
| -rw-r--r-- | frontend/src/components/document/DocumentLayout.tsx | 184 | ||||
| -rw-r--r-- | frontend/src/services/directiveApi.ts | 117 |
6 files changed, 802 insertions, 8 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 230ed07..2114b2c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -66,7 +66,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1060,7 +1059,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -1076,7 +1074,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "dev": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1159,7 +1156,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -1391,7 +1387,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -1440,7 +1435,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1452,7 +1446,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1626,7 +1619,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/document/DirectiveFileTree.tsx b/frontend/src/components/document/DirectiveFileTree.tsx new file mode 100644 index 0000000..21050ca --- /dev/null +++ b/frontend/src/components/document/DirectiveFileTree.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from 'react' +import { listDirectives, DirectiveSummary } from '../../services/directiveApi' + +interface DirectiveFileTreeProps { + selectedDirectiveId: string | null + onSelectDirective: (id: string) => void + onNewDirective: () => void +} + +interface GroupState { + [key: string]: boolean +} + +const STATUS_GROUPS = [ + { key: 'active', label: 'Active', defaultExpanded: true }, + { key: 'idle', label: 'Idle', defaultExpanded: true }, + { key: 'draft', label: 'Draft', defaultExpanded: false }, + { key: 'archived', label: 'Archived', defaultExpanded: false }, +] as const + +function statusColor(status: string): string { + switch (status.toLowerCase()) { + case 'active': + case 'running': + return '#4caf50' + case 'idle': + case 'paused': + return '#ffc107' + case 'draft': + case 'pending': + return '#9e9e9e' + case 'archived': + case 'failed': + return '#f44336' + default: + return '#9e9e9e' + } +} + +function groupDirectives(directives: DirectiveSummary[]): Record<string, DirectiveSummary[]> { + const groups: Record<string, DirectiveSummary[]> = { + active: [], + idle: [], + draft: [], + archived: [], + } + + for (const d of directives) { + const s = d.status.toLowerCase() + if (s === 'active' || s === 'running') { + groups.active.push(d) + } else if (s === 'idle' || s === 'paused') { + groups.idle.push(d) + } else if (s === 'draft' || s === 'pending') { + groups.draft.push(d) + } else { + groups.archived.push(d) + } + } + + return groups +} + +export function DirectiveFileTree({ selectedDirectiveId, onSelectDirective, onNewDirective }: DirectiveFileTreeProps) { + const [directives, setDirectives] = useState<DirectiveSummary[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + const [expanded, setExpanded] = useState<GroupState>(() => { + const state: GroupState = {} + for (const g of STATUS_GROUPS) { + state[g.key] = g.defaultExpanded + } + return state + }) + + useEffect(() => { + async function load() { + try { + setLoading(true) + const data = await listDirectives() + setDirectives(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load directives') + } finally { + setLoading(false) + } + } + load() + }, []) + + const toggleGroup = (key: string) => { + setExpanded(prev => ({ ...prev, [key]: !prev[key] })) + } + + const grouped = groupDirectives(directives) + + return ( + <div className="directive-file-tree"> + <div className="file-tree-header"> + <span className="file-tree-title">Directives</span> + <button className="file-tree-new-btn" onClick={onNewDirective} title="New Directive"> + + + </button> + </div> + + {loading && <div className="file-tree-loading">Loading...</div>} + {error && <div className="file-tree-error">{error}</div>} + + {!loading && !error && ( + <div className="file-tree-groups"> + {STATUS_GROUPS.map(group => { + const items = grouped[group.key] + if (!items || items.length === 0) return null + + return ( + <div key={group.key} className="file-tree-group"> + <button + className="file-tree-group-header" + onClick={() => toggleGroup(group.key)} + > + <span className={`file-tree-chevron ${expanded[group.key] ? 'expanded' : ''}`}> + {'\u25B6'} + </span> + <span className="file-tree-group-label">{group.label}</span> + <span className="file-tree-group-count">{items.length}</span> + </button> + + {expanded[group.key] && ( + <div className="file-tree-items"> + {items.map(directive => ( + <button + key={directive.id} + className={`file-tree-item ${selectedDirectiveId === directive.id ? 'selected' : ''}`} + onClick={() => onSelectDirective(directive.id)} + title={directive.title} + > + <span + className="file-tree-status-dot" + style={{ backgroundColor: statusColor(directive.status) }} + /> + <span className="file-tree-doc-icon">{'\u{1F4C4}'}</span> + <span className="file-tree-item-title">{directive.title || 'Untitled'}</span> + </button> + ))} + </div> + )} + </div> + ) + })} + </div> + )} + </div> + ) +} diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx new file mode 100644 index 0000000..ec7cd6b --- /dev/null +++ b/frontend/src/components/document/DocumentEditor.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { DirectiveWithSteps } from '../../services/directiveApi' + +// Stub component - will be replaced by the parallel task that builds the full editor +interface DocumentEditorProps { + directive: DirectiveWithSteps + onGoalChange: (goal: string) => void +} + +export function DocumentEditor({ directive }: DocumentEditorProps) { + return ( + <div className="document-placeholder"> + <h2>{directive.title}</h2> + <p>Editor loading...</p> + <pre style={{ textAlign: 'left', maxWidth: 600, margin: '0 auto', whiteSpace: 'pre-wrap' }}> + {directive.goal} + </pre> + </div> + ) +} diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css new file mode 100644 index 0000000..5a6d8d5 --- /dev/null +++ b/frontend/src/components/document/DocumentLayout.css @@ -0,0 +1,327 @@ +/* Document Layout - Main container */ +.document-layout { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; + background: #1a1a2e; + color: #e0e0e0; +} + +/* Sidebar */ +.document-sidebar { + flex-shrink: 0; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + background: #16162a; + border-right: 1px solid #2a2a4a; +} + +/* Resize handle */ +.document-resize-handle { + width: 4px; + cursor: col-resize; + background: transparent; + flex-shrink: 0; + transition: background 0.15s; +} + +.document-resize-handle:hover, +.document-resize-handle:active { + background: #4a4a8a; +} + +/* Main content area */ +.document-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +/* Top bar */ +.document-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 20px; + border-bottom: 1px solid #2a2a4a; + background: #1e1e38; + flex-shrink: 0; +} + +.document-topbar-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.document-topbar-title { + font-size: 16px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #f0f0f0; +} + +.document-topbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.document-topbar-gear { + background: none; + border: none; + color: #888; + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} + +.document-topbar-gear:hover { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} + +/* Status badge */ +.doc-status-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: #fff; + letter-spacing: 0.5px; +} + +/* Content area */ +.document-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Placeholder / empty state */ +.document-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: #888; + padding: 40px; +} + +.document-placeholder-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.document-placeholder h2 { + font-size: 20px; + font-weight: 500; + margin: 0 0 8px; + color: #aaa; +} + +.document-placeholder p { + font-size: 14px; + margin: 0; + max-width: 400px; + line-height: 1.5; +} + +.document-error { + color: #f44336; +} + +/* File Tree styles */ +.directive-file-tree { + display: flex; + flex-direction: column; + height: 100%; + font-size: 13px; +} + +.file-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid #2a2a4a; + flex-shrink: 0; +} + +.file-tree-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; +} + +.file-tree-new-btn { + background: none; + border: 1px solid #3a3a6a; + color: #aaa; + font-size: 16px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: all 0.15s; +} + +.file-tree-new-btn:hover { + background: #3a3a6a; + color: #fff; + border-color: #5a5a9a; +} + +.file-tree-loading, +.file-tree-error { + padding: 16px; + color: #888; + font-size: 12px; + text-align: center; +} + +.file-tree-error { + color: #f44336; +} + +.file-tree-groups { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +/* Group header */ +.file-tree-group { + margin-bottom: 2px; +} + +.file-tree-group-header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 14px; + background: none; + border: none; + color: #999; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + text-align: left; + transition: color 0.15s; +} + +.file-tree-group-header:hover { + color: #ccc; +} + +.file-tree-chevron { + font-size: 8px; + transition: transform 0.15s; + display: inline-block; +} + +.file-tree-chevron.expanded { + transform: rotate(90deg); +} + +.file-tree-group-count { + margin-left: auto; + color: #666; + font-size: 10px; +} + +.file-tree-group-label { + flex: 1; +} + +/* Tree items */ +.file-tree-items { + padding: 0; +} + +.file-tree-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 14px 5px 28px; + background: none; + border: none; + color: #ccc; + font-size: 13px; + cursor: pointer; + text-align: left; + transition: background 0.1s; + white-space: nowrap; + overflow: hidden; +} + +.file-tree-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.file-tree-item.selected { + background: rgba(100, 100, 200, 0.15); + color: #fff; +} + +.file-tree-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.file-tree-doc-icon { + font-size: 14px; + flex-shrink: 0; + opacity: 0.7; +} + +.file-tree-item-title { + overflow: hidden; + text-overflow: ellipsis; +} + +/* Responsive: mobile */ +@media (max-width: 768px) { + .document-sidebar { + position: absolute; + z-index: 100; + left: 0; + top: 0; + height: 100%; + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); + } + + .document-resize-handle { + display: none; + } +} diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx new file mode 100644 index 0000000..271ed04 --- /dev/null +++ b/frontend/src/components/document/DocumentLayout.tsx @@ -0,0 +1,184 @@ +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> + ) +} + +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) + } + }, [urlDirectiveId]) + + // Load directive when selected + useEffect(() => { + if (!selectedId) { + setDirective(null) + return + } + + let cancelled = false + async function load() { + try { + setLoading(true) + setError(null) + const data = await getDirective(selectedId!) + if (!cancelled) setDirective(data) + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load directive') + } finally { + if (!cancelled) setLoading(false) + } + } + load() + + return () => { cancelled = true } + }, [selectedId]) + + // Auto-save goal changes + const handleGoalChange = useCallback(async (goal: string) => { + if (!selectedId) return + try { + const updated = await updateGoal(selectedId, goal) + setDirective(updated) + } catch (err) { + console.error('Failed to save goal:', err) + } + }, [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) + } + + const handleMouseUp = () => { + resizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [sidebarWidth]) + + const handleNewDirective = useCallback(() => { + // Placeholder - will be implemented with full directive creation flow + console.log('New directive requested') + }, []) + + return ( + <div className="document-layout"> + {/* Sidebar */} + <div className="document-sidebar" style={{ width: sidebarWidth }}> + <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> + ) +} diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts new file mode 100644 index 0000000..2c8baec --- /dev/null +++ b/frontend/src/services/directiveApi.ts @@ -0,0 +1,117 @@ +// API service for directive operations + +export interface DirectiveStepCounts { + pending: number + ready: number + running: number + completed: number + failed: number + skipped: number +} + +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 +} + +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 +} + +export interface DirectiveWithSteps extends DirectiveSummary { + steps: DirectiveStep[] + reconcileMode: string +} + +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 +} + +export async function listDirectives(): Promise<DirectiveSummary[]> { + const response = await apiFetch('/api/v1/directives') + const data = await response.json() + return data.directives || [] +} + +export async function getDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}`) + return response.json() +} + +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, updates: Partial<DirectiveSummary>): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }) + return response.json() +} + +export async function cleanupDirective(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/cleanup`, { method: 'POST' }) +} + +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 pickUpOrders(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/pick-up-orders`, { method: 'POST' }) +} + +export async function startDirective(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/start`, { method: 'POST' }) +} + +export async function pauseDirective(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/pause`, { method: 'POST' }) +} + +export async function getUserSetting(key: string): Promise<any> { + const response = await apiFetch(`/api/v1/settings/${key}`) + return response.json() +} + +export async function upsertUserSetting(key: string, value: any): Promise<void> { + await apiFetch('/api/v1/settings', { + method: 'PUT', + body: JSON.stringify({ key, value }), + }) +} |
