summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-28 17:35:08 +0100
committerGitHub <noreply@github.com>2026-04-28 17:35:08 +0100
commitd513f93c84ae985738e0f696fcb72fa1153046ef (patch)
treed169fa48ce93f1e204a80b60ca9295772bc2fa63
parent5aa3fafb4acfa89c7d04e84abf7861607733e8ce (diff)
downloadsoryu-d513f93c84ae985738e0f696fcb72fa1153046ef.tar.gz
soryu-d513f93c84ae985738e0f696fcb72fa1153046ef.zip
feat: document UI with contract blocks, expandable logs, and interaction controls (#97)
* feat: soryu-co/soryu - makima: Rename tasks to contracts in directive API and types * feat: soryu-co/soryu - makima: Add contract interaction panel with comment and interrupt * feat: soryu-co/soryu - makima: Build expandable contract log feed in StepsDiagram * feat: soryu-co/soryu - makima: Rename tasks to contracts throughout document UI and add contract block support * feat: soryu-co/soryu - makima: Add comment and interrupt controls to expanded step log feed * feat: soryu-co/soryu - makima: Audit and fix Document UI feature flag visibility and missing implementations * feat: soryu-co/soryu - makima: Add expandable step rows with live log feed in StepsDiagram * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Integrate all document UI components and final polish
-rw-r--r--frontend/src/components/VNInterface.tsx16
-rw-r--r--frontend/src/components/document/DirectiveFileTree.tsx12
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx7
-rw-r--r--frontend/src/components/document/DocumentLayout.css16
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx4
-rw-r--r--frontend/src/components/document/index.ts9
-rw-r--r--frontend/src/components/document/nodes/ContractBlock.css123
-rw-r--r--frontend/src/components/document/nodes/ContractBlockComponent.tsx117
-rw-r--r--frontend/src/components/document/nodes/ContractBlockNode.tsx106
-rw-r--r--frontend/src/components/document/nodes/ContractLogFeed.css346
-rw-r--r--frontend/src/components/document/nodes/ContractLogFeed.tsx225
-rw-r--r--frontend/src/components/document/nodes/StepLogFeed.tsx277
-rw-r--r--frontend/src/components/document/nodes/StepsDiagram.css331
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramComponent.tsx142
-rw-r--r--frontend/src/main.tsx24
-rw-r--r--frontend/src/services/directiveApi.ts34
-rw-r--r--frontend/src/services/taskWs.ts88
-rw-r--r--frontend/src/stores/index.ts2
18 files changed, 1819 insertions, 60 deletions
diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx
index 051c210..48b150a 100644
--- a/frontend/src/components/VNInterface.tsx
+++ b/frontend/src/components/VNInterface.tsx
@@ -110,20 +110,20 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
<span className="info-value">Daemons</span>
</Link>
</div>
- <div className="status-item">
- <Link to="/contracts" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}>
- <span className="info-label">View:</span>
- <span className="info-value">Contracts</span>
- </Link>
- </div>
{documentUiEnabled && (
<div className="status-item">
- <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}>
- <span className="info-label">Docs:</span>
+ <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px', fontWeight: 600 }}>
+ <span className="info-label">View:</span>
<span className="info-value">Directives</span>
</Link>
</div>
)}
+ <div className="status-item">
+ <Link to="/contracts" style={{ color: documentUiEnabled ? '#8899bb' : '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px', fontSize: documentUiEnabled ? '0.85em' : undefined }}>
+ <span className="info-label">{documentUiEnabled ? 'Legacy:' : 'View:'}</span>
+ <span className="info-value">Contracts</span>
+ </Link>
+ </div>
</div>
</div>
</div>
diff --git a/frontend/src/components/document/DirectiveFileTree.tsx b/frontend/src/components/document/DirectiveFileTree.tsx
index 21050ca..bacffe6 100644
--- a/frontend/src/components/document/DirectiveFileTree.tsx
+++ b/frontend/src/components/document/DirectiveFileTree.tsx
@@ -140,6 +140,18 @@ export function DirectiveFileTree({ selectedDirectiveId, onSelectDirective, onNe
/>
<span className="file-tree-doc-icon">{'\u{1F4C4}'}</span>
<span className="file-tree-item-title">{directive.title || 'Untitled'}</span>
+ {directive.stepCounts && (
+ <span className="file-tree-step-count" title="Contract steps">
+ {directive.stepCounts.completed}/{
+ directive.stepCounts.pending +
+ directive.stepCounts.ready +
+ directive.stepCounts.running +
+ directive.stepCounts.completed +
+ directive.stepCounts.failed +
+ directive.stepCounts.skipped
+ }
+ </span>
+ )}
</button>
))}
</div>
diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx
index d50c093..2ef37fe 100644
--- a/frontend/src/components/document/DocumentEditor.tsx
+++ b/frontend/src/components/document/DocumentEditor.tsx
@@ -18,11 +18,13 @@ import {
import { $createHeadingNode } from '@lexical/rich-text';
import { StepsDiagramNode, $isStepsDiagramNode, $createStepsDiagramNode } from './nodes/StepsDiagramNode';
+import { ContractBlockNode, $isContractBlockNode } from './nodes/ContractBlockNode';
import editorTheme from './EditorTheme';
import AutoSavePlugin from './AutoSavePlugin';
import ContextMenu, { type ContextMenuAction } from './ContextMenu';
import './DocumentEditor.css';
import './nodes/StepsDiagram.css';
+import './nodes/ContractBlock.css';
interface DocumentEditorProps {
directiveId: string;
@@ -97,7 +99,7 @@ export default function DocumentEditor({
namespace: `DocumentEditor-${directiveId}`,
theme: editorTheme,
editorState: buildInitialEditorState(directiveId, title, goal),
- nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode],
+ nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode, ContractBlockNode],
onError,
editable: !readOnly,
};
@@ -115,8 +117,9 @@ export default function DocumentEditor({
for (let i = 0; i < children.length; i++) {
const child = children[i];
- // Skip the steps diagram node when extracting text
+ // Skip decorator nodes (steps diagram, contract blocks) when extracting text
if ($isStepsDiagramNode(child)) continue;
+ if ($isContractBlockNode(child)) continue;
const text = child.getTextContent();
diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css
index b18bb81..ae73e7a 100644
--- a/frontend/src/components/document/DocumentLayout.css
+++ b/frontend/src/components/document/DocumentLayout.css
@@ -127,9 +127,13 @@
.document-content {
flex: 1;
overflow-y: auto;
+ overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
+ scroll-behavior: smooth;
+ /* Ensure expanded log feeds don't break layout */
+ min-height: 0;
}
/* Placeholder / empty state */
@@ -328,6 +332,18 @@
.file-tree-item-title {
overflow: hidden;
text-overflow: ellipsis;
+ flex: 1;
+}
+
+.file-tree-step-count {
+ margin-left: auto;
+ font-size: 10px;
+ color: #666;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 8px;
+ padding: 1px 6px;
+ flex-shrink: 0;
+ white-space: nowrap;
}
/* Responsive: mobile */
diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx
index a555ad0..05f4190 100644
--- a/frontend/src/components/document/DocumentLayout.tsx
+++ b/frontend/src/components/document/DocumentLayout.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback, useRef } from 'react'
+import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { DirectiveFileTree } from './DirectiveFileTree'
import DocumentEditor from './DocumentEditor'
@@ -152,7 +152,7 @@ function DocumentLayoutInner() {
if (!selectedId) return
try {
await cleanupDirective(selectedId)
- addToast('Cleanup task spawned', 'success')
+ addToast('Cleanup contract spawned', 'success')
startStepPolling()
} catch (err) {
addToast(`Cleanup failed: ${(err as Error).message}`, 'error')
diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts
index af9e362..3217a1b 100644
--- a/frontend/src/components/document/index.ts
+++ b/frontend/src/components/document/index.ts
@@ -2,3 +2,12 @@ export { default as DocumentLayout } from './DocumentLayout'
export { default as DocumentEditor } from './DocumentEditor'
export { DirectiveFileTree } from './DirectiveFileTree'
export { default as DocumentSettings } from './DocumentSettings'
+
+// Lexical Nodes
+export { StepsDiagramNode, $createStepsDiagramNode, $isStepsDiagramNode } from './nodes/StepsDiagramNode'
+export { ContractBlockNode, $createContractBlockNode, $isContractBlockNode } from './nodes/ContractBlockNode'
+
+// Sub-components
+export { StepsDiagramComponent } from './nodes/StepsDiagramComponent'
+export { ContractBlockComponent } from './nodes/ContractBlockComponent'
+export { StepLogFeed } from './nodes/StepLogFeed'
diff --git a/frontend/src/components/document/nodes/ContractBlock.css b/frontend/src/components/document/nodes/ContractBlock.css
new file mode 100644
index 0000000..80edb74
--- /dev/null
+++ b/frontend/src/components/document/nodes/ContractBlock.css
@@ -0,0 +1,123 @@
+/* ============================================
+ Contract Block - Inline contract reference
+ ============================================ */
+
+.contract-block-wrapper {
+ margin: 1rem 0;
+ user-select: none;
+}
+
+.contract-block {
+ background: #fafbff;
+ border: 1px solid #e2e5ef;
+ border-radius: 8px;
+ padding: 0.65rem 0.85rem;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ font-size: 13px;
+ color: #374151;
+ transition: box-shadow 0.2s ease, border-color 0.2s ease;
+ animation: contractBlockAppear 0.25s ease-out both;
+}
+
+@keyframes contractBlockAppear {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.contract-block:hover {
+ border-color: #c7cce0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+}
+
+.contract-block--error {
+ border-color: #fecaca;
+ background: #fef2f2;
+}
+
+/* Header */
+.contract-block-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.contract-block-icon {
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+
+.contract-block-name {
+ font-weight: 600;
+ font-size: 0.88rem;
+ color: #1f2937;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.contract-block-phase-badge {
+ font-size: 0.68rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ padding: 0.1rem 0.4rem;
+ border-radius: 8px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.contract-block-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* Meta */
+.contract-block-meta {
+ margin-top: 0.3rem;
+ padding-left: 1.5rem;
+}
+
+.contract-block-type {
+ font-size: 0.75rem;
+ color: #9ca3af;
+ font-style: italic;
+}
+
+.contract-block-error-msg {
+ margin-top: 0.25rem;
+ font-size: 0.78rem;
+ color: #dc2626;
+ padding-left: 1.5rem;
+}
+
+/* Loading state */
+.contract-block-loading {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0;
+ color: #9ca3af;
+ font-size: 0.82rem;
+}
+
+.contract-block-spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid #e5e7eb;
+ border-top-color: #6b7280;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/frontend/src/components/document/nodes/ContractBlockComponent.tsx b/frontend/src/components/document/nodes/ContractBlockComponent.tsx
new file mode 100644
index 0000000..0d9a25a
--- /dev/null
+++ b/frontend/src/components/document/nodes/ContractBlockComponent.tsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useState } from 'react';
+import './ContractBlock.css';
+
+interface ContractBlockComponentProps {
+ contractId: string;
+ contractName: string;
+}
+
+interface ContractInfo {
+ id: string;
+ name: string;
+ status: string;
+ phase: string;
+ contract_type: string;
+}
+
+const PHASE_COLORS: Record<string, string> = {
+ planning: '#3b82f6',
+ execution: '#f59e0b',
+ review: '#8b5cf6',
+ completed: '#10b981',
+ failed: '#ef4444',
+};
+
+const STATUS_COLORS: Record<string, string> = {
+ active: '#10b981',
+ running: '#10b981',
+ idle: '#f59e0b',
+ paused: '#f59e0b',
+ completed: '#10b981',
+ failed: '#ef4444',
+ archived: '#6b7280',
+};
+
+export function ContractBlockComponent({ contractId, contractName }: ContractBlockComponentProps) {
+ const [contract, setContract] = useState<ContractInfo | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function fetchContract() {
+ try {
+ const response = await fetch(`/api/v1/contracts/${contractId}`);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data = await response.json();
+ if (!cancelled) {
+ setContract(data.contract || data);
+ setError(null);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : 'Failed to load');
+ }
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ fetchContract();
+ return () => { cancelled = true; };
+ }, [contractId]);
+
+ if (loading) {
+ return (
+ <div className="contract-block" contentEditable={false}>
+ <div className="contract-block-loading">
+ <div className="contract-block-spinner" />
+ <span>Loading contract...</span>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="contract-block contract-block--error" contentEditable={false}>
+ <div className="contract-block-header">
+ <span className="contract-block-icon">&#x1F4E6;</span>
+ <span className="contract-block-name">{contractName}</span>
+ </div>
+ <div className="contract-block-error-msg">Unable to load: {error}</div>
+ </div>
+ );
+ }
+
+ const phase = contract?.phase?.toLowerCase() || 'unknown';
+ const status = contract?.status?.toLowerCase() || 'unknown';
+ const phaseColor = PHASE_COLORS[phase] || '#6b7280';
+ const statusColor = STATUS_COLORS[status] || '#6b7280';
+
+ return (
+ <div className="contract-block" contentEditable={false}>
+ <div className="contract-block-header">
+ <span className="contract-block-icon">&#x1F4E6;</span>
+ <span className="contract-block-name">{contract?.name || contractName}</span>
+ <span
+ className="contract-block-phase-badge"
+ style={{ backgroundColor: phaseColor + '20', color: phaseColor }}
+ >
+ {phase}
+ </span>
+ <span
+ className="contract-block-status-dot"
+ style={{ backgroundColor: statusColor }}
+ title={status}
+ />
+ </div>
+ {contract?.contract_type && (
+ <div className="contract-block-meta">
+ <span className="contract-block-type">{contract.contract_type}</span>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/nodes/ContractBlockNode.tsx b/frontend/src/components/document/nodes/ContractBlockNode.tsx
new file mode 100644
index 0000000..86e4c9d
--- /dev/null
+++ b/frontend/src/components/document/nodes/ContractBlockNode.tsx
@@ -0,0 +1,106 @@
+import {
+ DecoratorNode,
+ DOMExportOutput,
+ LexicalNode,
+ NodeKey,
+ SerializedLexicalNode,
+ Spread,
+} from 'lexical';
+import React from 'react';
+import { ContractBlockComponent } from './ContractBlockComponent';
+
+export type SerializedContractBlockNode = Spread<
+ {
+ contractId: string;
+ contractName: string;
+ },
+ SerializedLexicalNode
+>;
+
+export class ContractBlockNode extends DecoratorNode<JSX.Element> {
+ __contractId: string;
+ __contractName: string;
+
+ static getType(): string {
+ return 'contract-block';
+ }
+
+ static clone(node: ContractBlockNode): ContractBlockNode {
+ return new ContractBlockNode(node.__contractId, node.__contractName, node.__key);
+ }
+
+ constructor(contractId: string, contractName: string, key?: NodeKey) {
+ super(key);
+ this.__contractId = contractId;
+ this.__contractName = contractName;
+ }
+
+ createDOM(): HTMLElement {
+ const div = document.createElement('div');
+ div.className = 'contract-block-wrapper';
+ return div;
+ }
+
+ updateDOM(): boolean {
+ return false;
+ }
+
+ decorate(): JSX.Element {
+ return (
+ <ContractBlockComponent
+ contractId={this.__contractId}
+ contractName={this.__contractName}
+ />
+ );
+ }
+
+ exportJSON(): SerializedContractBlockNode {
+ return {
+ ...super.exportJSON(),
+ type: 'contract-block',
+ contractId: this.__contractId,
+ contractName: this.__contractName,
+ version: 1,
+ };
+ }
+
+ static importJSON(serializedNode: SerializedContractBlockNode): ContractBlockNode {
+ return $createContractBlockNode(
+ serializedNode.contractId,
+ serializedNode.contractName
+ );
+ }
+
+ isInline(): boolean {
+ return false;
+ }
+
+ canInsertTextBefore(): boolean {
+ return false;
+ }
+
+ canInsertTextAfter(): boolean {
+ return false;
+ }
+
+ exportDOM(): DOMExportOutput {
+ const element = document.createElement('div');
+ element.className = 'contract-block-wrapper';
+ element.setAttribute('data-contract-id', this.__contractId);
+ element.textContent = `[Contract: ${this.__contractName}]`;
+ return { element };
+ }
+}
+
+export function $createContractBlockNode(
+ contractId: string,
+ contractName: string
+): ContractBlockNode {
+ return new ContractBlockNode(contractId, contractName);
+}
+
+export function $isContractBlockNode(
+ node: LexicalNode | null | undefined,
+): node is ContractBlockNode {
+ return node instanceof ContractBlockNode;
+}
diff --git a/frontend/src/components/document/nodes/ContractLogFeed.css b/frontend/src/components/document/nodes/ContractLogFeed.css
new file mode 100644
index 0000000..b5dd15d
--- /dev/null
+++ b/frontend/src/components/document/nodes/ContractLogFeed.css
@@ -0,0 +1,346 @@
+/* ============================================
+ Contract Log Feed
+ ============================================ */
+
+.contract-log-feed {
+ display: flex;
+ flex-direction: column;
+ background: #1a1d23;
+ border: 1px solid #2d3039;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-top: 0.5rem;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ font-size: 13px;
+ max-height: 420px;
+ animation: logFeedSlideIn 0.25s ease-out;
+}
+
+@keyframes logFeedSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ---- Header ---- */
+.contract-log-feed-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ background: #22252b;
+ border-bottom: 1px solid #2d3039;
+}
+
+.contract-log-feed-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.contract-log-feed-name {
+ font-weight: 600;
+ font-size: 0.82rem;
+ color: #e5e7eb;
+}
+
+.contract-log-feed-status {
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 0.1rem 0.4rem;
+ border-radius: 8px;
+}
+
+.contract-log-feed-status--running,
+.contract-log-feed-status--starting {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+ animation: statusPulse 2s ease-in-out infinite;
+}
+
+.contract-log-feed-status--completed {
+ background: rgba(16, 185, 129, 0.2);
+ color: #34d399;
+}
+
+.contract-log-feed-status--failed {
+ background: rgba(239, 68, 68, 0.2);
+ color: #f87171;
+}
+
+.contract-log-feed-status--pending,
+.contract-log-feed-status--ready {
+ background: rgba(107, 114, 128, 0.2);
+ color: #9ca3af;
+}
+
+.contract-log-feed-close {
+ background: none;
+ border: none;
+ color: #6b7280;
+ font-size: 1.1rem;
+ cursor: pointer;
+ padding: 0 0.25rem;
+ line-height: 1;
+ border-radius: 3px;
+ transition: color 0.15s, background 0.15s;
+}
+
+.contract-log-feed-close:hover {
+ color: #e5e7eb;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+/* ---- Log Content ---- */
+.contract-log-feed-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem 0.75rem;
+ min-height: 80px;
+ max-height: 240px;
+ scrollbar-width: thin;
+ scrollbar-color: #3a3f4b transparent;
+}
+
+.contract-log-feed-content::-webkit-scrollbar {
+ width: 5px;
+}
+
+.contract-log-feed-content::-webkit-scrollbar-thumb {
+ background: #3a3f4b;
+ border-radius: 3px;
+}
+
+.contract-log-feed-empty {
+ color: #6b7280;
+ font-size: 0.82rem;
+ font-style: italic;
+ text-align: center;
+ padding: 1.5rem 0;
+}
+
+/* ---- Log Entry ---- */
+.contract-log-entry {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.2rem 0;
+ line-height: 1.5;
+ animation: entryFadeIn 0.2s ease-out;
+}
+
+@keyframes entryFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.contract-log-entry-time {
+ flex-shrink: 0;
+ font-size: 0.7rem;
+ color: #4b5563;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+ line-height: 1.65;
+}
+
+.contract-log-entry-text {
+ color: #d1d5db;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+ font-size: 0.78rem;
+}
+
+.contract-log-entry--user .contract-log-entry-text {
+ color: #93c5fd;
+}
+
+.contract-log-entry--user::before {
+ content: '>';
+ color: #3b82f6;
+ font-weight: 700;
+ font-family: monospace;
+ flex-shrink: 0;
+ line-height: 1.5;
+}
+
+.contract-log-entry--system .contract-log-entry-text {
+ color: #fbbf24;
+ font-style: italic;
+}
+
+/* ---- Error ---- */
+.contract-log-feed-error {
+ padding: 0.4rem 0.75rem;
+ background: rgba(239, 68, 68, 0.12);
+ border-top: 1px solid rgba(239, 68, 68, 0.25);
+ color: #f87171;
+ font-size: 0.78rem;
+}
+
+/* ---- Interaction Bar ---- */
+.contract-interaction-bar {
+ border-top: 1px solid #2d3039;
+ padding: 0.5rem 0.75rem;
+ background: #22252b;
+}
+
+.contract-interaction-bar--disabled {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.6rem 0.75rem;
+}
+
+.contract-interaction-disabled-text {
+ color: #6b7280;
+ font-size: 0.78rem;
+ font-style: italic;
+}
+
+.contract-interaction-message-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.4rem;
+ position: relative;
+}
+
+.contract-message-input {
+ flex: 1;
+ background: #1a1d23;
+ border: 1px solid #3a3f4b;
+ border-radius: 6px;
+ color: #e5e7eb;
+ padding: 0.4rem 0.6rem;
+ font-size: 0.82rem;
+ font-family: inherit;
+ resize: none;
+ min-height: 32px;
+ max-height: 80px;
+ line-height: 1.4;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.contract-message-input::placeholder {
+ color: #4b5563;
+ font-size: 0.78rem;
+}
+
+.contract-message-input:focus {
+ border-color: #3b82f6;
+}
+
+.contract-message-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.contract-send-btn {
+ flex-shrink: 0;
+ background: #3b82f6;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.4rem 0.85rem;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s, opacity 0.15s;
+ min-height: 32px;
+}
+
+.contract-send-btn:hover:not(:disabled) {
+ background: #2563eb;
+}
+
+.contract-send-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.contract-sent-indicator {
+ position: absolute;
+ right: 0;
+ top: -1.4rem;
+ font-size: 0.7rem;
+ color: #34d399;
+ font-weight: 500;
+ animation: sentFlash 1.5s ease-out forwards;
+}
+
+@keyframes sentFlash {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 70% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+}
+
+/* ---- Actions Row ---- */
+.contract-interaction-actions-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.4rem;
+}
+
+.contract-interrupt-btn {
+ background: transparent;
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ padding: 0.3rem 0.7rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.contract-interrupt-btn:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: rgba(239, 68, 68, 0.5);
+}
+
+.contract-interrupt-btn--confirm {
+ background: rgba(239, 68, 68, 0.15);
+ border-color: #ef4444;
+ color: #f87171;
+ animation: confirmPulse 0.8s ease-in-out infinite;
+}
+
+@keyframes confirmPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.contract-interrupt-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* ---- Responsive ---- */
+@media (max-width: 640px) {
+ .contract-log-feed {
+ max-height: 360px;
+ }
+
+ .contract-log-feed-content {
+ max-height: 180px;
+ }
+
+ .contract-message-input::placeholder {
+ font-size: 0.72rem;
+ }
+}
diff --git a/frontend/src/components/document/nodes/ContractLogFeed.tsx b/frontend/src/components/document/nodes/ContractLogFeed.tsx
new file mode 100644
index 0000000..79af91c
--- /dev/null
+++ b/frontend/src/components/document/nodes/ContractLogFeed.tsx
@@ -0,0 +1,225 @@
+import React, { useState, useRef, useEffect, useCallback } from 'react';
+import {
+ sendContractMessage,
+ interruptContract,
+ getContractOutput,
+} from '../../../services/directiveApi';
+import './ContractLogFeed.css';
+
+interface ContractLogFeedProps {
+ taskId: string;
+ contractName: string;
+ status: string;
+ onClose?: () => void;
+}
+
+interface LogEntry {
+ id: string;
+ text: string;
+ type: 'output' | 'user' | 'system';
+ timestamp: Date;
+}
+
+const INTERACTIVE_STATUSES = ['running', 'starting'];
+
+export function ContractLogFeed({ taskId, contractName, status, onClose }: ContractLogFeedProps) {
+ const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
+ const [message, setMessage] = useState('');
+ const [sending, setSending] = useState(false);
+ const [sentIndicator, setSentIndicator] = useState(false);
+ const [interruptConfirm, setInterruptConfirm] = useState(false);
+ const [interrupting, setInterrupting] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const logEndRef = useRef<HTMLDivElement>(null);
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
+ const lastOutputRef = useRef<string>('');
+ const entryIdRef = useRef(0);
+
+ const isInteractive = INTERACTIVE_STATUSES.includes(status.toLowerCase());
+
+ const addLogEntry = useCallback((text: string, type: LogEntry['type']) => {
+ entryIdRef.current += 1;
+ setLogEntries(prev => [
+ ...prev,
+ { id: `entry-${entryIdRef.current}`, text, type, timestamp: new Date() },
+ ]);
+ }, []);
+
+ // Poll for contract output
+ const fetchOutput = useCallback(async () => {
+ if (!taskId) return;
+ try {
+ const data = await getContractOutput(taskId);
+ const output = data.output || '';
+ if (output && output !== lastOutputRef.current) {
+ // Find new content
+ const newContent = output.startsWith(lastOutputRef.current)
+ ? output.slice(lastOutputRef.current.length).trim()
+ : output.trim();
+ lastOutputRef.current = output;
+ if (newContent) {
+ addLogEntry(newContent, 'output');
+ }
+ }
+ } catch {
+ // Silently ignore fetch errors for output polling
+ }
+ }, [taskId, addLogEntry]);
+
+ useEffect(() => {
+ fetchOutput();
+ pollRef.current = setInterval(fetchOutput, 3000);
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
+ }, [fetchOutput]);
+
+ // Auto-scroll to bottom on new entries
+ useEffect(() => {
+ logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logEntries]);
+
+ // Reset interrupt confirm after timeout
+ useEffect(() => {
+ if (!interruptConfirm) return;
+ const timer = setTimeout(() => setInterruptConfirm(false), 3000);
+ return () => clearTimeout(timer);
+ }, [interruptConfirm]);
+
+ const handleSendMessage = async () => {
+ const trimmed = message.trim();
+ if (!trimmed || sending || !isInteractive) return;
+
+ setSending(true);
+ setError(null);
+ try {
+ await sendContractMessage(taskId, trimmed);
+ addLogEntry(trimmed, 'user');
+ setMessage('');
+ setSentIndicator(true);
+ setTimeout(() => setSentIndicator(false), 1500);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to send message');
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const handleInterrupt = async () => {
+ if (!interruptConfirm) {
+ setInterruptConfirm(true);
+ return;
+ }
+
+ setInterrupting(true);
+ setError(null);
+ setInterruptConfirm(false);
+ try {
+ await interruptContract(taskId);
+ addLogEntry('Contract interrupted by user', 'system');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to interrupt contract');
+ } finally {
+ setInterrupting(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ const statusLower = status.toLowerCase();
+
+ return (
+ <div className="contract-log-feed">
+ <div className="contract-log-feed-header">
+ <div className="contract-log-feed-title">
+ <span className="contract-log-feed-name">{contractName}</span>
+ <span className={`contract-log-feed-status contract-log-feed-status--${statusLower}`}>
+ {status}
+ </span>
+ </div>
+ {onClose && (
+ <button className="contract-log-feed-close" onClick={onClose} title="Close">
+ &times;
+ </button>
+ )}
+ </div>
+
+ <div className="contract-log-feed-content">
+ {logEntries.length === 0 && (
+ <div className="contract-log-feed-empty">
+ {isInteractive ? 'Waiting for output...' : 'No output available.'}
+ </div>
+ )}
+ {logEntries.map(entry => (
+ <div key={entry.id} className={`contract-log-entry contract-log-entry--${entry.type}`}>
+ <span className="contract-log-entry-time">
+ {entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
+ </span>
+ <span className="contract-log-entry-text">{entry.text}</span>
+ </div>
+ ))}
+ <div ref={logEndRef} />
+ </div>
+
+ {error && (
+ <div className="contract-log-feed-error">{error}</div>
+ )}
+
+ {isInteractive && (
+ <div className="contract-interaction-bar">
+ <div className="contract-interaction-message-row">
+ <textarea
+ ref={textareaRef}
+ className="contract-message-input"
+ value={message}
+ onChange={e => setMessage(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Send a message to the contract... (Enter to send, Shift+Enter for newline)"
+ rows={1}
+ disabled={sending}
+ />
+ <button
+ className="contract-send-btn"
+ onClick={handleSendMessage}
+ disabled={sending || !message.trim()}
+ title="Send message"
+ >
+ {sending ? 'Sending...' : 'Send'}
+ </button>
+ {sentIndicator && (
+ <span className="contract-sent-indicator">Sent</span>
+ )}
+ </div>
+ <div className="contract-interaction-actions-row">
+ <button
+ className={`contract-interrupt-btn ${interruptConfirm ? 'contract-interrupt-btn--confirm' : ''}`}
+ onClick={handleInterrupt}
+ disabled={interrupting}
+ title={interruptConfirm ? 'Click again to confirm interrupt' : 'Interrupt contract'}
+ >
+ {interrupting
+ ? 'Interrupting...'
+ : interruptConfirm
+ ? 'Click again to confirm interrupt'
+ : 'Interrupt'}
+ </button>
+ </div>
+ </div>
+ )}
+
+ {!isInteractive && statusLower !== 'pending' && statusLower !== 'ready' && (
+ <div className="contract-interaction-bar contract-interaction-bar--disabled">
+ <span className="contract-interaction-disabled-text">
+ Contract is {status.toLowerCase()} - interaction unavailable
+ </span>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx
new file mode 100644
index 0000000..0357de8
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepLogFeed.tsx
@@ -0,0 +1,277 @@
+import React, { useEffect, useRef, useState, useCallback } from 'react';
+
+interface StepLogFeedProps {
+ taskId: string;
+ stepName: string;
+ stepStatus: string;
+ onCollapse: () => void;
+}
+
+interface LogEntry {
+ timestamp: string;
+ content: string;
+ type: 'stdout' | 'stderr' | 'system' | 'user';
+}
+
+/**
+ * Live log feed for an expanded step row.
+ * Connects via WebSocket to stream task output and allows
+ * sending messages (comments) and interrupting the task.
+ */
+export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLogFeedProps) {
+ const [logs, setLogs] = useState<LogEntry[]>([]);
+ const [message, setMessage] = useState('');
+ const [sending, setSending] = useState(false);
+ const [connected, setConnected] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const logsEndRef = useRef<HTMLDivElement>(null);
+ const wsRef = useRef<WebSocket | null>(null);
+ const logContainerRef = useRef<HTMLDivElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ const isActive = ['running', 'starting'].includes(stepStatus.toLowerCase());
+
+ // Auto-scroll to bottom when new logs arrive
+ useEffect(() => {
+ logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs]);
+
+ // Connect to WebSocket for live streaming
+ useEffect(() => {
+ if (!taskId) return;
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/api/v1/mesh/tasks/subscribe`;
+
+ let ws: WebSocket;
+ let reconnectTimer: ReturnType<typeof setTimeout>;
+ let shouldReconnect = true;
+
+ function connect() {
+ try {
+ ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.addEventListener('open', () => {
+ setConnected(true);
+ setError(null);
+ // Subscribe to this specific task
+ ws.send(JSON.stringify({ type: 'subscribe', taskId }));
+ });
+
+ ws.addEventListener('message', (evt) => {
+ try {
+ const data = JSON.parse(evt.data);
+ // Handle different message formats from the backend
+ if (data.taskId === taskId || data.task_id === taskId) {
+ const entry: LogEntry = {
+ timestamp: data.timestamp || new Date().toISOString(),
+ content: data.content || data.output || data.message || JSON.stringify(data),
+ type: data.type || data.stream || 'stdout',
+ };
+ setLogs(prev => [...prev, entry]);
+ }
+ } catch {
+ // Non-JSON message, treat as raw log
+ setLogs(prev => [...prev, {
+ timestamp: new Date().toISOString(),
+ content: evt.data,
+ type: 'stdout',
+ }]);
+ }
+ });
+
+ ws.addEventListener('close', () => {
+ setConnected(false);
+ wsRef.current = null;
+ if (shouldReconnect && isActive) {
+ reconnectTimer = setTimeout(connect, 3000);
+ }
+ });
+
+ ws.addEventListener('error', () => {
+ setConnected(false);
+ setError('WebSocket connection failed');
+ });
+ } catch (err) {
+ setError('Failed to connect to log stream');
+ }
+ }
+
+ connect();
+
+ return () => {
+ shouldReconnect = false;
+ clearTimeout(reconnectTimer);
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ };
+ }, [taskId, isActive]);
+
+ // Keyboard shortcut: Escape to collapse
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onCollapse();
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [onCollapse]);
+
+ // Send a message/comment to the task
+ const handleSendMessage = useCallback(async () => {
+ if (!message.trim() || !taskId || sending) return;
+
+ setSending(true);
+ try {
+ const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: message.trim() }),
+ });
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({ message: response.statusText }));
+ throw new Error(body.message || body.error || `HTTP ${response.status}`);
+ }
+
+ // Add as a user message in the log
+ setLogs(prev => [...prev, {
+ timestamp: new Date().toISOString(),
+ content: message.trim(),
+ type: 'user',
+ }]);
+ setMessage('');
+ inputRef.current?.focus();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to send message');
+ } finally {
+ setSending(false);
+ }
+ }, [message, taskId, sending]);
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ // Prevent Escape from bubbling when input is focused
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ inputRef.current?.blur();
+ }
+ }, [handleSendMessage]);
+
+ // Interrupt the running task
+ const handleInterrupt = useCallback(async () => {
+ if (!taskId) return;
+ try {
+ // Send a special interrupt message
+ const response = await fetch(`/api/v1/mesh/tasks/${taskId}/message`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: '/interrupt' }),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ setLogs(prev => [...prev, {
+ timestamp: new Date().toISOString(),
+ content: 'Interrupt signal sent',
+ type: 'system',
+ }]);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to interrupt');
+ }
+ }, [taskId]);
+
+ const formatTimestamp = (ts: string) => {
+ try {
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ } catch {
+ return '';
+ }
+ };
+
+ return (
+ <div className="step-log-feed">
+ {/* Header */}
+ <div className="step-log-feed-header">
+ <div className="step-log-feed-header-left">
+ <span className="step-log-feed-title">{stepName} - Logs</span>
+ <span className={`step-log-feed-status ${connected ? 'connected' : 'disconnected'}`}>
+ {connected ? 'Live' : 'Disconnected'}
+ </span>
+ </div>
+ <div className="step-log-feed-header-right">
+ {isActive && (
+ <button
+ className="step-log-feed-interrupt-btn"
+ onClick={handleInterrupt}
+ title="Interrupt this task"
+ >
+ &#x23F9; Interrupt
+ </button>
+ )}
+ <button
+ className="step-log-feed-collapse-btn"
+ onClick={onCollapse}
+ title="Collapse (Esc)"
+ >
+ &#x2715;
+ </button>
+ </div>
+ </div>
+
+ {/* Log content */}
+ <div className="step-log-feed-content" ref={logContainerRef}>
+ {logs.length === 0 && !error && (
+ <div className="step-log-feed-empty">
+ {isActive
+ ? 'Waiting for log output...'
+ : 'No logs available for this step.'}
+ </div>
+ )}
+
+ {error && (
+ <div className="step-log-feed-error">{error}</div>
+ )}
+
+ {logs.map((entry, idx) => (
+ <div key={idx} className={`step-log-entry step-log-entry--${entry.type}`}>
+ <span className="step-log-entry-time">{formatTimestamp(entry.timestamp)}</span>
+ <span className="step-log-entry-content">{entry.content}</span>
+ </div>
+ ))}
+ <div ref={logsEndRef} />
+ </div>
+
+ {/* Message input (comment/interrupt controls) */}
+ {isActive && (
+ <div className="step-log-feed-input">
+ <input
+ ref={inputRef}
+ type="text"
+ className="step-log-feed-input-field"
+ placeholder="Send a message to this task..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ onKeyDown={handleKeyDown}
+ disabled={sending}
+ />
+ <button
+ className="step-log-feed-send-btn"
+ onClick={handleSendMessage}
+ disabled={!message.trim() || sending}
+ title="Send message (Enter)"
+ >
+ {sending ? '...' : '&#x27A4;'}
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css
index f3e9305..9856c6d 100644
--- a/frontend/src/components/document/nodes/StepsDiagram.css
+++ b/frontend/src/components/document/nodes/StepsDiagram.css
@@ -15,6 +15,12 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
color: #374151;
+ transition: max-height 0.3s ease;
+}
+
+.steps-diagram--has-expanded {
+ /* Allow more vertical space when a step is expanded */
+ max-height: none;
}
/* ---- Header ---- */
@@ -92,18 +98,29 @@
border-top: 6px solid #cbd5e1;
}
-/* ---- Step Card ---- */
-.steps-diagram-card {
+/* ---- Step Card Wrapper ---- */
+.steps-diagram-card-wrapper {
flex: 1 1 180px;
max-width: 280px;
+}
+
+/* ---- Step Card ---- */
+.steps-diagram-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.65rem 0.8rem;
- transition: box-shadow 0.2s ease, border-color 0.2s ease;
+ transition: box-shadow 0.2s ease, border-color 0.2s ease, max-width 0.3s ease;
animation: stepCardAppear 0.35s ease-out both;
}
+.steps-diagram-card--expanded {
+ flex: 1 1 100%;
+ max-width: 100%;
+ border-color: #93c5fd;
+ box-shadow: 0 2px 12px rgba(59, 130, 246, 0.1);
+}
+
@keyframes stepCardAppear {
from {
opacity: 0;
@@ -119,6 +136,19 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
+.steps-diagram-card--expandable {
+ cursor: pointer;
+}
+
+.steps-diagram-card--expandable:hover {
+ border-color: #c7cbd5;
+}
+
+.steps-diagram-card--expanded {
+ border-radius: 8px 8px 0 0;
+ border-bottom-color: transparent;
+}
+
.steps-diagram-card-header {
display: flex;
align-items: center;
@@ -127,6 +157,22 @@
margin-bottom: 0.3rem;
}
+.steps-diagram-card-header--clickable {
+ cursor: pointer;
+ user-select: none;
+}
+
+.steps-diagram-card-header--clickable:hover .steps-diagram-card-name {
+ color: #2563eb;
+}
+
+.steps-diagram-card-header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ flex-shrink: 0;
+}
+
.steps-diagram-card-name {
font-weight: 600;
font-size: 0.85rem;
@@ -135,6 +181,7 @@
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
+ transition: color 0.15s;
}
.steps-diagram-card-desc {
@@ -160,6 +207,16 @@
font-weight: 500;
}
+.steps-diagram-card-contract-ref {
+ font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, monospace;
+ font-size: 0.68rem;
+ color: #6b7280;
+ background: #f3f4f6;
+ padding: 0.08rem 0.35rem;
+ border-radius: 4px;
+ cursor: default;
+}
+
.steps-diagram-card-progress {
color: #d97706;
font-style: italic;
@@ -169,6 +226,23 @@
color: #6b7280;
}
+/* ---- Expand Icon ---- */
+.steps-diagram-expand-icon {
+ font-size: 0.6rem;
+ color: #9ca3af;
+ transition: transform 0.2s ease, color 0.15s;
+ display: inline-block;
+}
+
+.steps-diagram-expand-icon.expanded {
+ transform: rotate(90deg);
+ color: #3b82f6;
+}
+
+.steps-diagram-card-header--clickable:hover .steps-diagram-expand-icon {
+ color: #3b82f6;
+}
+
/* ---- Status Badge ---- */
.steps-diagram-status-badge {
font-size: 0.68rem;
@@ -245,6 +319,19 @@
opacity: 0.7;
}
+/* ---- Expanded Card ---- */
+.steps-diagram-card--expanded {
+ flex: 1 1 100%;
+ max-width: 100%;
+}
+
+.steps-diagram-card-expand {
+ flex-shrink: 0;
+ font-size: 0.7rem;
+ color: #9ca3af;
+ margin-left: 0.25rem;
+}
+
/* ---- Animations ---- */
@keyframes statusPulse {
0%, 100% {
@@ -347,14 +434,250 @@
font-size: 0.82rem;
}
+/* ============================================
+ Step Log Feed (Expandable)
+ ============================================ */
+
+.step-log-feed {
+ margin-top: 0.5rem;
+ border-top: 1px solid #e5e7eb;
+ padding-top: 0.5rem;
+ animation: logFeedSlideIn 0.25s ease-out both;
+}
+
+@keyframes logFeedSlideIn {
+ from {
+ opacity: 0;
+ max-height: 0;
+ }
+ to {
+ opacity: 1;
+ max-height: 500px;
+ }
+}
+
+.step-log-feed-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 0.4rem;
+ margin-bottom: 0.4rem;
+ border-bottom: 1px solid #f3f4f6;
+}
+
+.step-log-feed-header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.step-log-feed-header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.step-log-feed-title {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #4b5563;
+}
+
+.step-log-feed-status {
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 0.1rem 0.4rem;
+ border-radius: 8px;
+}
+
+.step-log-feed-status.connected {
+ background: #d1fae5;
+ color: #059669;
+}
+
+.step-log-feed-status.disconnected {
+ background: #f3f4f6;
+ color: #9ca3af;
+}
+
+.step-log-feed-interrupt-btn {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.2rem 0.5rem;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.step-log-feed-interrupt-btn:hover {
+ background: #fee2e2;
+ border-color: #f87171;
+}
+
+.step-log-feed-collapse-btn {
+ background: none;
+ border: 1px solid #e5e7eb;
+ color: #6b7280;
+ font-size: 0.75rem;
+ width: 22px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s;
+ padding: 0;
+}
+
+.step-log-feed-collapse-btn:hover {
+ background: #f3f4f6;
+ color: #1f2937;
+ border-color: #d1d5db;
+}
+
+/* Log content area */
+.step-log-feed-content {
+ max-height: 280px;
+ overflow-y: auto;
+ background: #1a1b26;
+ border-radius: 6px;
+ padding: 0.5rem;
+ font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
+ font-size: 0.75rem;
+ line-height: 1.5;
+}
+
+.step-log-feed-empty {
+ color: #6b7280;
+ font-style: italic;
+ padding: 1rem;
+ text-align: center;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+.step-log-feed-error {
+ color: #f87171;
+ padding: 0.25rem;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ font-size: 0.78rem;
+}
+
+/* Log entries */
+.step-log-entry {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.1rem 0.25rem;
+ border-radius: 2px;
+}
+
+.step-log-entry:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.step-log-entry-time {
+ color: #565f89;
+ white-space: nowrap;
+ flex-shrink: 0;
+ min-width: 5.5em;
+}
+
+.step-log-entry-content {
+ color: #a9b1d6;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+
+.step-log-entry--stderr .step-log-entry-content {
+ color: #f7768e;
+}
+
+.step-log-entry--system .step-log-entry-content {
+ color: #7aa2f7;
+ font-style: italic;
+}
+
+.step-log-entry--user .step-log-entry-content {
+ color: #9ece6a;
+}
+
+.step-log-entry--user::before {
+ content: '> ';
+ color: #9ece6a;
+}
+
+/* Message input */
+.step-log-feed-input {
+ display: flex;
+ gap: 0.35rem;
+ margin-top: 0.4rem;
+}
+
+.step-log-feed-input-field {
+ flex: 1;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 5px;
+ padding: 0.35rem 0.6rem;
+ font-size: 0.78rem;
+ color: #1f2937;
+ outline: none;
+ transition: border-color 0.15s;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+.step-log-feed-input-field:focus {
+ border-color: #93c5fd;
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
+}
+
+.step-log-feed-input-field:disabled {
+ opacity: 0.5;
+}
+
+.step-log-feed-send-btn {
+ background: #3b82f6;
+ border: none;
+ color: #ffffff;
+ font-size: 0.82rem;
+ padding: 0.35rem 0.65rem;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: background 0.15s;
+ white-space: nowrap;
+}
+
+.step-log-feed-send-btn:hover:not(:disabled) {
+ background: #2563eb;
+}
+
+.step-log-feed-send-btn:disabled {
+ background: #93c5fd;
+ cursor: not-allowed;
+}
+
/* ---- Responsive ---- */
@media (max-width: 640px) {
.steps-diagram {
padding: 0.75rem;
}
- .steps-diagram-card {
+ .steps-diagram-card-wrapper {
+ flex: 1 1 100%;
+ max-width: 100%;
+ }
+
+ .steps-diagram-card-wrapper {
flex: 1 1 100%;
max-width: 100%;
}
+
+ .step-log-feed-content {
+ max-height: 200px;
+ }
}
diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
index 606c0ab..53f860e 100644
--- a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
+++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
@@ -1,18 +1,20 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi';
+import { StepLogFeed } from './StepLogFeed';
import './StepsDiagram.css';
interface StepsDiagramComponentProps {
directiveId: string;
+ onExpandContract?: (step: DirectiveStep) => void;
}
type StepStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped';
const STATUS_LABELS: Record<string, string> = {
- pending: 'Pending',
+ pending: 'Queued',
ready: 'Ready',
- running: 'Running',
- completed: 'Done',
+ running: 'Executing',
+ completed: 'Fulfilled',
failed: 'Failed',
skipped: 'Skipped',
};
@@ -23,18 +25,40 @@ function formatTime(dateStr: string): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
-function StepCard({ step }: { step: DirectiveStep }) {
+interface StepCardProps {
+ step: DirectiveStep;
+ isExpanded: boolean;
+ onToggleExpand: () => void;
+ onCollapse: () => void;
+}
+
+function StepCard({ step, isExpanded, onToggleExpand, onCollapse }: StepCardProps) {
const status = (step.status || 'pending').toLowerCase() as StepStatus;
+ const hasTask = !!step.taskId || !!step.contractId;
+ const canExpand = hasTask && ['running', 'completed', 'failed'].includes(status);
return (
- <div className={`steps-diagram-card steps-diagram-card--${status}`}>
- <div className="steps-diagram-card-header">
+ <div className={`steps-diagram-card steps-diagram-card--${status} ${isExpanded ? 'steps-diagram-card--expanded' : ''}`}>
+ <div
+ className={`steps-diagram-card-header ${canExpand ? 'steps-diagram-card-header--clickable' : ''}`}
+ onClick={canExpand ? onToggleExpand : undefined}
+ role={canExpand ? 'button' : undefined}
+ tabIndex={canExpand ? 0 : undefined}
+ onKeyDown={canExpand ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleExpand(); } } : undefined}
+ >
<span className="steps-diagram-card-name">{step.name}</span>
- <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}>
- {STATUS_LABELS[status] || status}
- </span>
+ <div className="steps-diagram-card-header-right">
+ <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}>
+ {STATUS_LABELS[status] || status}
+ </span>
+ {canExpand && (
+ <span className={`steps-diagram-expand-icon ${isExpanded ? 'expanded' : ''}`}>
+ &#x25B6;
+ </span>
+ )}
+ </div>
</div>
- {step.description && (
+ {step.description && !isExpanded && (
<p className="steps-diagram-card-desc">{step.description}</p>
)}
<div className="steps-diagram-card-footer">
@@ -46,20 +70,68 @@ function StepCard({ step }: { step: DirectiveStep }) {
<span className="steps-diagram-card-time">
Completed {formatTime(step.completedAt)}
</span>
+ {hasTask && (
+ <button
+ className={`steps-diagram-card-expand-btn ${expanded ? 'steps-diagram-card-expand-btn--open' : ''}`}
+ onClick={() => setExpanded((v) => !v)}
+ title={expanded ? 'Collapse log feed' : 'Expand log feed'}
+ >
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <polyline points="6 9 12 15 18 9" />
+ </svg>
+ </button>
+ )}
+ </div>
+ {step.description && (
+ <p className="steps-diagram-card-desc">{step.description}</p>
)}
+ <div className="steps-diagram-card-footer">
+ <span className="steps-diagram-card-index">#{step.orderIndex}</span>
+ {status === 'running' && (
+ <span className="steps-diagram-card-progress">In progress...</span>
+ )}
+ {status === 'completed' && step.completedAt && (
+ <span className="steps-diagram-card-time">
+ Completed {formatTime(step.completedAt)}
+ </span>
+ )}
+ </div>
</div>
+
+ {/* Expandable log feed */}
+ {isExpanded && hasTask && (
+ <StepLogFeed
+ taskId={step.taskId || step.contractId}
+ stepName={step.name}
+ stepStatus={status}
+ onCollapse={onCollapse}
+ />
+ )}
</div>
);
}
-export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProps) {
+export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDiagramComponentProps) {
const [steps, setSteps] = useState<DirectiveStep[]>([]);
const [directiveStatus, setDirectiveStatus] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
+ const [expandedStepId, setExpandedStepId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevStepCountRef = useRef(0);
+ const toggleStep = useCallback((stepId: string) => {
+ setExpandedSteps((prev) => {
+ const next = new Set(prev);
+ if (next.has(stepId)) {
+ next.delete(stepId);
+ } else {
+ next.add(stepId);
+ }
+ return next;
+ });
+ }, []);
+
const fetchSteps = useCallback(async () => {
try {
const data: DirectiveWithSteps = await getDirective(directiveId);
@@ -67,7 +139,7 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp
setDirectiveStatus(data.status || '');
setError(null);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load steps');
+ setError(err instanceof Error ? err.message : 'Failed to load contracts');
} finally {
setLoading(false);
}
@@ -86,11 +158,29 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp
prevStepCountRef.current = steps.length;
}, [steps.length]);
+ // Keyboard shortcut: Escape to collapse expanded step
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && expandedStepId) {
+ setExpandedStepId(null);
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [expandedStepId]);
+
+ const toggleExpand = useCallback((stepId: string) => {
+ setExpandedStepId(prev => prev === stepId ? null : stepId);
+ }, []);
+
+ const collapseExpanded = useCallback(() => {
+ setExpandedStepId(null);
+ }, []);
+
const completedCount = steps.filter(s => s.status?.toLowerCase() === 'completed').length;
const totalCount = steps.length;
const isActive = ['active', 'running', 'planning'].includes(directiveStatus.toLowerCase());
const isBuilding = isActive && steps.length === 0;
- const isAddingSteps = isActive && steps.length > 0 && steps.length > prevStepCountRef.current;
// Group steps by orderIndex
const groupedSteps: Map<number, DirectiveStep[]> = new Map();
@@ -106,12 +196,12 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp
return (
<div className="steps-diagram" contentEditable={false}>
<div className="steps-diagram-header">
- <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-title">Contract Steps</span>
<span className="steps-diagram-header-author">Authored by Makima</span>
</div>
<div className="steps-diagram-loading">
<div className="steps-diagram-spinner" />
- <span>Loading steps...</span>
+ <span>Loading contracts...</span>
</div>
</div>
);
@@ -121,22 +211,22 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp
return (
<div className="steps-diagram" contentEditable={false}>
<div className="steps-diagram-header">
- <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-title">Contract Steps</span>
<span className="steps-diagram-header-author">Authored by Makima</span>
</div>
- <div className="steps-diagram-error">Failed to load steps: {error}</div>
+ <div className="steps-diagram-error">Failed to load contracts: {error}</div>
</div>
);
}
return (
- <div className="steps-diagram" contentEditable={false}>
+ <div className={`steps-diagram ${expandedStepId ? 'steps-diagram--has-expanded' : ''}`} contentEditable={false}>
<div className="steps-diagram-header">
<div className="steps-diagram-header-left">
- <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-title">Contract Steps</span>
{totalCount > 0 && (
<span className="steps-diagram-header-count">
- {completedCount}/{totalCount} completed
+ {completedCount}/{totalCount} fulfilled
</span>
)}
</div>
@@ -148,12 +238,12 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp
<div className="steps-diagram-planning-dots">
<span /><span /><span />
</div>
- <span>Makima is building the plan...</span>
+ <span>Makima is drafting contracts...</span>
</div>
)}
{totalCount === 0 && !isBuilding && (
- <div className="steps-diagram-empty">No steps defined yet.</div>
+ <div className="steps-diagram-empty">No contract steps defined yet.</div>
)}
{totalCount > 0 && (
@@ -168,7 +258,13 @@ export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProp
)}
<div className="steps-diagram-group">
{groupSteps.map((step) => (
- <StepCard key={step.id} step={step} />
+ <StepCard
+ key={step.id}
+ step={step}
+ isExpanded={expandedStepId === step.id}
+ onToggleExpand={() => toggleExpand(step.id)}
+ onCollapse={collapseExpanded}
+ />
))}
</div>
</React.Fragment>
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 3987f30..9527d8f 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,9 +2,6 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App'
-import { ContractList } from './components/ContractList'
-import { ContractDetail } from './components/ContractDetail'
-import { FileDetail } from './components/FileDetail'
import { DaemonList } from './components/DaemonList'
import { DaemonDetail } from './components/DaemonDetail'
import { DocumentLayout } from './components/document'
@@ -12,12 +9,17 @@ import './styles/pc98.css'
import './styles/mobile.css'
// Route configuration:
+// Primary (Document UI - when feature flag enabled):
+// - /directives - Document layout with file tree sidebar and Lexical editor
+// - /directives/:id - Open a specific directive in the document editor
+//
+// Legacy (Contract UI - kept for backward compatibility):
// - /contracts - List all contracts
// - /contracts/:id - View contract details with tabs (including Files tab)
// - /contracts/:contractId/files/:fileId - View a specific file within contract context
//
-// Note: Standalone file routes (/files, /files/:id) have been removed.
-// Files are now only accessible through their parent contract.
+// Note: When Document UI is enabled via Settings, /directives is the primary interface.
+// The /contracts routes remain available as a legacy fallback.
const router = createBrowserRouter([
{
@@ -25,18 +27,6 @@ const router = createBrowserRouter([
element: <App />,
},
{
- path: '/contracts',
- element: <ContractList />,
- },
- {
- path: '/contracts/:id',
- element: <ContractDetail />,
- },
- {
- path: '/contracts/:contractId/files/:fileId',
- element: <FileDetail />,
- },
- {
path: '/daemons',
element: <DaemonList />,
},
diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts
index b82f594..4d1fd82 100644
--- a/frontend/src/services/directiveApi.ts
+++ b/frontend/src/services/directiveApi.ts
@@ -35,8 +35,9 @@ export interface DirectiveStep {
taskPlan: string
dependsOn: string[]
status: string
- taskId: string
contractId: string
+ /** @deprecated Use contractId instead */
+ taskId: string
orderIndex: number
sort_order?: number
completedAt: string
@@ -124,13 +125,40 @@ export async function pauseDirective(id: string): Promise<DirectiveWithSteps> {
}
export async function getUserSetting(key: string): Promise<any> {
- const response = await apiFetch(`/api/v1/settings/${key}`)
+ const response = await apiFetch(`/api/v1/user-settings/${key}`)
return response.json()
}
export async function upsertUserSetting(key: string, value: any): Promise<void> {
- await apiFetch('/api/v1/settings', {
+ await apiFetch('/api/v1/user-settings', {
method: 'PUT',
body: JSON.stringify({ key, value }),
})
}
+
+// ---- Task control APIs ----
+
+export async function sendTaskMessage(taskId: string, message: string): Promise<void> {
+ await apiFetch(`/api/v1/mesh/tasks/${taskId}/message`, {
+ method: 'POST',
+ body: JSON.stringify({ message }),
+ })
+}
+
+export async function stopTask(taskId: string): Promise<void> {
+ await apiFetch(`/api/v1/mesh/tasks/${taskId}/stop`, {
+ method: 'POST',
+ })
+}
+
+export async function continueTask(taskId: string): Promise<void> {
+ await apiFetch(`/api/v1/mesh/tasks/${taskId}/continue`, {
+ method: 'POST',
+ })
+}
+
+export async function startTask(taskId: string): Promise<void> {
+ await apiFetch(`/api/v1/mesh/tasks/${taskId}/start`, {
+ method: 'POST',
+ })
+}
diff --git a/frontend/src/services/taskWs.ts b/frontend/src/services/taskWs.ts
new file mode 100644
index 0000000..832648e
--- /dev/null
+++ b/frontend/src/services/taskWs.ts
@@ -0,0 +1,88 @@
+import { VNWebSocket } from './ws'
+
+export interface TaskOutputMessage {
+ task_id: string
+ message_type: string
+ content: string
+ tool_name?: string
+ tool_input?: string
+ is_error?: boolean
+ cost_usd?: number
+ duration_ms?: number
+ is_partial?: boolean
+}
+
+type TaskOutputCallback = (msg: TaskOutputMessage) => void
+
+export class TaskOutputStream {
+ private ws: VNWebSocket
+ private listeners: Map<string, Set<TaskOutputCallback>> = new Map()
+ private connected = false
+
+ constructor() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ this.ws = new VNWebSocket(`${protocol}//${window.location.host}/ws/tasks`)
+
+ this.ws.on('message', (data: any) => {
+ if (data && data.type === 'TaskOutput') {
+ const payload = data.payload || data
+ const taskId = payload.task_id
+ if (taskId && this.listeners.has(taskId)) {
+ this.listeners.get(taskId)!.forEach(cb => cb(payload))
+ }
+ }
+ })
+
+ this.ws.on('open', () => {
+ this.connected = true
+ // Re-subscribe all active subscriptions on reconnect
+ for (const taskId of this.listeners.keys()) {
+ this.ws.send({ type: 'SubscribeOutput', task_id: taskId })
+ }
+ })
+
+ this.ws.on('close', () => {
+ this.connected = false
+ })
+ }
+
+ connect() {
+ this.ws.connect()
+ }
+
+ subscribe(taskId: string, callback: TaskOutputCallback) {
+ if (!this.listeners.has(taskId)) {
+ this.listeners.set(taskId, new Set())
+ // Only send subscribe if this is a new task subscription
+ this.ws.send({ type: 'SubscribeOutput', task_id: taskId })
+ }
+ this.listeners.get(taskId)!.add(callback)
+ }
+
+ unsubscribe(taskId: string, callback?: TaskOutputCallback) {
+ if (!this.listeners.has(taskId)) return
+
+ if (callback) {
+ this.listeners.get(taskId)!.delete(callback)
+ if (this.listeners.get(taskId)!.size > 0) return
+ }
+
+ this.listeners.delete(taskId)
+ this.ws.send({ type: 'UnsubscribeOutput', task_id: taskId })
+ }
+
+ close() {
+ this.ws.close()
+ this.listeners.clear()
+ }
+}
+
+let instance: TaskOutputStream | null = null
+
+export function getTaskOutputStream(): TaskOutputStream {
+ if (!instance) {
+ instance = new TaskOutputStream()
+ instance.connect()
+ }
+ return instance
+}
diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts
index 5ee9b08..e247068 100644
--- a/frontend/src/stores/index.ts
+++ b/frontend/src/stores/index.ts
@@ -52,7 +52,7 @@ export const setDocumentUiEnabled = (enabled: boolean) => {
documentUiEnabledStore.set(enabled)
localStorage.setItem('document_ui_enabled', JSON.stringify(enabled))
// Persist to backend (fire-and-forget)
- fetch('/api/v1/settings', {
+ fetch('/api/v1/user-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'document_ui_enabled', value: enabled }),