summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-27 17:17:56 +0100
committersoryu <soryu@soryu.co>2026-04-27 17:17:56 +0100
commit04572ad347ba2bd02207bd7b1b6ba5d35705c956 (patch)
tree0f7a8312c3f8c85a41f8fbd9e70dd33223d2db46
parent3679ceb3325033faa2f889ef3dfee5668ef7aeea (diff)
downloadsoryu-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.json8
-rw-r--r--frontend/src/components/document/DirectiveFileTree.tsx154
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx20
-rw-r--r--frontend/src/components/document/DocumentLayout.css327
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx184
-rw-r--r--frontend/src/services/directiveApi.ts117
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 }),
+ })
+}