create-harness-vibe-coding 0.8.18 → 0.8.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -0
  3. package/package.json +5 -2
  4. package/src/index.js +383 -267
  5. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  6. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  7. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  8. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  9. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  10. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  11. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  12. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  13. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  14. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  15. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  16. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  17. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  18. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  19. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  20. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  21. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  22. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  23. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  24. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  25. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  26. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  27. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  28. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  29. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  30. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  31. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  32. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  33. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  34. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  35. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  36. package/src/ui/dist/index.html +18 -0
  37. package/src/ui/index.html +17 -0
  38. package/src/ui/package.json +33 -0
  39. package/src/ui/pnpm-lock.yaml +1582 -0
  40. package/src/ui/pnpm-workspace.yaml +2 -0
  41. package/src/ui/src/App.tsx +86 -0
  42. package/src/ui/src/api.ts +93 -0
  43. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  44. package/src/ui/src/components/Footer.tsx +69 -0
  45. package/src/ui/src/components/Header.tsx +175 -0
  46. package/src/ui/src/components/LoadingView.tsx +22 -0
  47. package/src/ui/src/components/RolesRoute.tsx +169 -0
  48. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  49. package/src/ui/src/components/TaskList.tsx +388 -0
  50. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  51. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  52. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  53. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  54. package/src/ui/src/index.css +281 -0
  55. package/src/ui/src/main.tsx +11 -0
  56. package/src/ui/src/types.ts +108 -0
  57. package/src/ui/tsconfig.json +21 -0
  58. package/src/ui/vite.config.ts +19 -0
  59. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  60. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  61. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  62. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  63. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  64. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  65. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  66. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  67. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  68. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  69. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  70. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  71. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  72. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  73. package/src/wf-ui-server/a2a-store.mjs +296 -0
  74. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  75. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  76. package/src/wf-ui-server/runtime-config.mjs +153 -0
  77. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  78. package/src/wf-ui-server/security.mjs +67 -0
  79. package/src/wf-ui-server/server.mjs +849 -0
  80. package/src/wf-ui-server/session-registry.mjs +204 -0
  81. package/src/wf-ui-server/settings.mjs +100 -0
  82. package/src/wf-ui-server/task-parser.mjs +133 -0
  83. package/src/wf-ui-server/terminal-store.mjs +225 -0
  84. package/src/wf-ui-server/token.mjs +41 -0
  85. package/src/wf-ui-server/ws-events.mjs +354 -0
  86. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  87. package/templates/common/.claude/commands/wf-help.md +1 -0
  88. package/templates/common/.claude/commands/wf-task-list.md +2 -0
  89. package/templates/common/.claude/commands/wf-ui.md +26 -0
  90. package/templates/common/.claude/commands/wf-update.md +54 -9
  91. package/templates/common/.claude/rules/ecc/common.md +1 -1
  92. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  93. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  94. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  95. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  96. package/templates/common/.harness-version +63 -39
  97. package/templates/common/.opencode/commands/wf-help.md +1 -0
  98. package/templates/common/.opencode/commands/wf-task-list.md +2 -0
  99. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  100. package/templates/common/.opencode/commands/wf-update.md +54 -9
  101. package/templates/common/CLAUDE.md +1 -1
  102. package/templates/common/Harness/MEMORY.md +2 -0
  103. package/templates/common/Harness/README.md +6 -4
  104. package/templates/common/Harness/a2a/role-graph.json +79 -0
  105. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  106. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  107. package/templates/common/Harness/ownership.manifest.json +57 -2
  108. package/templates/common/Harness/scripts/README.md +134 -0
  109. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  110. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  111. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  112. package/templates/common/Harness/scripts/task-state.mjs +556 -23
  113. package/templates/common/Harness/scripts/validate-harness.mjs +229 -19
  114. package/templates/common/Harness/scripts/wf-remove.mjs +8 -3
  115. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  116. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  117. package/templates/common/Harness/settings.json +35 -0
  118. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +7 -2
  119. package/templates/common/Harness/specs/runtime/command-surface.json +14 -0
  120. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  121. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  122. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  123. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  124. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  125. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  126. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  127. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  128. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,670 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
3
+ import {
4
+ Background,
5
+ Controls,
6
+ Handle,
7
+ MarkerType,
8
+ MiniMap,
9
+ Panel,
10
+ Position,
11
+ ReactFlow,
12
+ ReactFlowProvider,
13
+ addEdge,
14
+ useEdgesState,
15
+ useNodesState,
16
+ } from '@xyflow/react';
17
+ import type { Connection, Edge, Node, NodeMouseHandler, NodeProps, ReactFlowInstance } from '@xyflow/react';
18
+ import '@xyflow/react/dist/style.css';
19
+ import { Bot, Boxes, GitBranch, Maximize2, Plus, RefreshCw, Route, Terminal, Workflow } from 'lucide-react';
20
+ import { apiJson, apiJsonCached, invalidateApiCache } from '../api';
21
+ import type { BuiltInWorkflow, RuntimeInfo, Session, TaskOption, WorkflowEdge, WorkflowNode, WorkflowSnapshot } from '../types';
22
+ import LoadingView from './LoadingView';
23
+
24
+ type Props = { onSelectSession: (sessionId: string) => void };
25
+
26
+ type CanvasNode = WorkflowNode & {
27
+ x: number;
28
+ y: number;
29
+ width: number;
30
+ height: number;
31
+ };
32
+
33
+ type WfNodeData = Record<string, unknown> & {
34
+ workflowNode: WorkflowNode;
35
+ onOpenSession: (sessionId: string) => void;
36
+ };
37
+
38
+ type FlowNode = Node<WfNodeData, 'wfNode'>;
39
+ type FlowEdge = Edge<Record<string, unknown>>;
40
+
41
+ const NODE_W = 188;
42
+ const NODE_H = 86;
43
+ const PANEL: CSSProperties = {
44
+ border: '1px solid var(--border)',
45
+ borderRadius: 'var(--radius)',
46
+ background: 'rgba(255,255,255,0.92)',
47
+ boxShadow: '0 12px 38px rgba(0,0,0,0.08)',
48
+ backdropFilter: 'blur(16px)',
49
+ };
50
+
51
+ function statusColor(status: string | undefined) {
52
+ if (status === 'running' || status === 'active' || status === 'ready') return '#166534';
53
+ if (status === 'blocked' || status === 'exited') return '#991b1b';
54
+ if (status === 'starting' || status === 'idle') return '#d97706';
55
+ return '#6b7280';
56
+ }
57
+
58
+ function shortId(value: string | undefined) {
59
+ if (!value) return '';
60
+ return value.length > 14 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value;
61
+ }
62
+
63
+ function defaultPrompt(workflows: BuiltInWorkflow[], workflowId: string) {
64
+ const workflow = workflows.find(item => item.id === workflowId) || workflows[0];
65
+ return workflow?.defaultCeoPrompt || 'Act as the Harness /wf CEO. Coordinate terminal agents through Harness A2A evidence.';
66
+ }
67
+
68
+ function nodeKindIcon(kind: string) {
69
+ if (kind === 'terminal-session') return Terminal;
70
+ if (kind === 'terminal-controller' || kind === 'runtime') return Route;
71
+ return Bot;
72
+ }
73
+
74
+ function layoutNodes(workflow: WorkflowSnapshot | null): CanvasNode[] {
75
+ const base = workflow?.nodes || [];
76
+ const sessionNodes = base
77
+ .filter(node => node.kind === 'terminal-session')
78
+ .filter(node => node.status === 'running' || node.status === 'starting')
79
+ .map(node => ({
80
+ ...node,
81
+ label: node.runtime ? `${node.runtime} Worker` : node.label,
82
+ level: 2,
83
+ }));
84
+ const ceo = base.find(node => node.id === 'ceo') || {
85
+ id: 'ceo',
86
+ label: 'Harness CEO',
87
+ kind: 'controller',
88
+ level: 0,
89
+ status: workflow?.phase || 'idle',
90
+ skills: ['wf'],
91
+ permissions: ['dispatch'],
92
+ };
93
+ const controller = base.find(node => node.id === 'terminal-controller') || {
94
+ id: 'terminal-controller',
95
+ label: 'Agent Manager',
96
+ kind: 'terminal-controller',
97
+ level: 1,
98
+ status: 'ready',
99
+ skills: ['terminal-control'],
100
+ permissions: ['read-terminals', 'send-terminal-input'],
101
+ };
102
+
103
+ const nodes: CanvasNode[] = [
104
+ { ...ceo, x: 420, y: 72, width: NODE_W, height: NODE_H },
105
+ { ...controller, label: 'Agent Manager', x: 420, y: 238, width: NODE_W, height: NODE_H },
106
+ ];
107
+
108
+ if (sessionNodes.length === 0) {
109
+ nodes.push({
110
+ id: 'wf-subagents-pool',
111
+ label: 'WF Subagent Pool',
112
+ kind: 'terminal-session',
113
+ level: 2,
114
+ status: 'idle',
115
+ skills: ['new-terminal'],
116
+ permissions: ['create-node'],
117
+ x: 420,
118
+ y: 406,
119
+ width: NODE_W,
120
+ height: NODE_H,
121
+ });
122
+ return nodes;
123
+ }
124
+
125
+ const gapX = 252;
126
+ const startX = 420 - sessionNodes.length * gapX / 2 + gapX / 2 - NODE_W / 2;
127
+ sessionNodes.forEach((node, index) => {
128
+ nodes.push({
129
+ ...node,
130
+ x: startX + index * gapX,
131
+ y: 406,
132
+ width: NODE_W,
133
+ height: NODE_H,
134
+ });
135
+ });
136
+
137
+ return nodes;
138
+ }
139
+
140
+ function buildEdges(workflow: WorkflowSnapshot | null, nodeIds: Set<string>): WorkflowEdge[] {
141
+ const relationByPair = new Map(
142
+ (workflow?.edges || []).map(edge => [`${edge.from}->${edge.to}`, edge.relation]),
143
+ );
144
+ const edges: WorkflowEdge[] = [];
145
+ if (nodeIds.has('ceo') && nodeIds.has('terminal-controller')) {
146
+ edges.push({
147
+ from: 'ceo',
148
+ to: 'terminal-controller',
149
+ relation: relationByPair.get('ceo->terminal-controller') || 'delegates',
150
+ });
151
+ }
152
+ const workerIds = [...nodeIds].filter(id => id !== 'ceo' && id !== 'terminal-controller');
153
+ for (const id of workerIds) {
154
+ edges.push({
155
+ from: 'terminal-controller',
156
+ to: id,
157
+ relation: relationByPair.get(`terminal-controller->${id}`) || (id === 'wf-subagents-pool' ? 'creates' : 'owns-worker'),
158
+ });
159
+ }
160
+ return edges;
161
+ }
162
+
163
+ function toFlowNodes(
164
+ canvasNodes: CanvasNode[],
165
+ onOpenSession: (sessionId: string) => void,
166
+ previous: FlowNode[],
167
+ ): FlowNode[] {
168
+ const previousPositions = new Map(previous.map(node => [node.id, node.position]));
169
+ return canvasNodes.map(node => ({
170
+ id: node.id,
171
+ type: 'wfNode',
172
+ position: previousPositions.get(node.id) || { x: node.x, y: node.y },
173
+ width: node.width,
174
+ height: node.height,
175
+ sourcePosition: Position.Bottom,
176
+ targetPosition: Position.Top,
177
+ data: { workflowNode: node, onOpenSession },
178
+ draggable: true,
179
+ }));
180
+ }
181
+
182
+ function toFlowEdges(edges: WorkflowEdge[]): FlowEdge[] {
183
+ return edges.map((edge, index) => ({
184
+ id: `${edge.from}-${edge.to}-${index}`,
185
+ source: edge.from,
186
+ target: edge.to,
187
+ type: 'smoothstep',
188
+ label: edge.relation,
189
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#9ca3af' },
190
+ style: { stroke: '#9ca3af', strokeWidth: 1.5 },
191
+ labelStyle: { fill: '#6b7280', fontSize: 10, fontWeight: 500 },
192
+ labelBgStyle: { fill: 'rgba(255,255,255,0.72)' },
193
+ labelBgPadding: [4, 2],
194
+ labelBgBorderRadius: 4,
195
+ }));
196
+ }
197
+
198
+ function useAutoLoadedWorkflow() {
199
+ const [workflow, setWorkflow] = useState<WorkflowSnapshot | null>(null);
200
+ const [runtimes, setRuntimes] = useState<RuntimeInfo[]>([]);
201
+ const [tasks, setTasks] = useState<TaskOption[]>([]);
202
+ const [loading, setLoading] = useState(true);
203
+ const [error, setError] = useState<string | null>(null);
204
+
205
+ const load = async (refresh = false) => {
206
+ try {
207
+ if (refresh) {
208
+ invalidateApiCache('/api/workflow');
209
+ invalidateApiCache('/api/runtimes');
210
+ invalidateApiCache('/api/tasks');
211
+ }
212
+ const [snapshot, runtimeRows, taskRows] = await Promise.all([
213
+ apiJsonCached<WorkflowSnapshot>('/api/workflow', { ttlMs: refresh ? 0 : 1200, refresh }),
214
+ apiJsonCached<RuntimeInfo[]>(refresh ? '/api/runtimes?refresh=1' : '/api/runtimes', { ttlMs: 12000, refresh }),
215
+ apiJsonCached<TaskOption[]>('/api/tasks', { ttlMs: 8000, refresh }).catch(() => []),
216
+ ]);
217
+ setWorkflow(snapshot);
218
+ setRuntimes(runtimeRows);
219
+ setTasks(taskRows);
220
+ setError(null);
221
+ } catch (e: any) {
222
+ setError(e?.message || 'Failed to load workflow');
223
+ } finally {
224
+ setLoading(false);
225
+ }
226
+ };
227
+
228
+ useEffect(() => {
229
+ load();
230
+ const interval = setInterval(() => load(false), 5000);
231
+ return () => clearInterval(interval);
232
+ }, []);
233
+
234
+ return { workflow, runtimes, tasks, loading, error, setError, reload: load };
235
+ }
236
+
237
+ export default function WorkflowRoute({ onSelectSession }: Props) {
238
+ return (
239
+ <ReactFlowProvider>
240
+ <WorkflowRouteInner onSelectSession={onSelectSession} />
241
+ </ReactFlowProvider>
242
+ );
243
+ }
244
+
245
+ function WorkflowRouteInner({ onSelectSession }: Props) {
246
+ const { workflow, runtimes, tasks, loading, error, setError, reload } = useAutoLoadedWorkflow();
247
+ const workflows = workflow?.availableWorkflows || [];
248
+ const [workflowId, setWorkflowId] = useState('');
249
+ const [ceoPrompt, setCeoPrompt] = useState('');
250
+ const [subagentMode, setSubagentMode] = useState('wf-subagents');
251
+ const [runtimeId, setRuntimeId] = useState('');
252
+ const [bindTask, setBindTask] = useState(false);
253
+ const [taskId, setTaskId] = useState('');
254
+ const [model, setModel] = useState('');
255
+ const [agentObjective, setAgentObjective] = useState('WF terminal subagent');
256
+ const [selectedNodeId, setSelectedNodeId] = useState('ceo');
257
+ const [running, setRunning] = useState(false);
258
+ const [launchingAgent, setLaunchingAgent] = useState(false);
259
+ const [showConfig, setShowConfig] = useState(false);
260
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
261
+ const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode>([]);
262
+ const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
263
+ const flowWrapperRef = useRef<HTMLDivElement>(null);
264
+ const flowRef = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
265
+ const fittedOnce = useRef(false);
266
+
267
+ useEffect(() => {
268
+ if (workflows.length > 0) {
269
+ setWorkflowId(current => current || workflows[0].id);
270
+ setCeoPrompt(current => current || defaultPrompt(workflows, workflows[0].id));
271
+ }
272
+ }, [workflows]);
273
+
274
+ useEffect(() => {
275
+ setRuntimeId(current => current && runtimes.some(runtime => runtime.id === current) ? current : runtimes[0]?.id || '');
276
+ }, [runtimes]);
277
+
278
+ useEffect(() => {
279
+ setTaskId(current => current && tasks.some(task => task.taskId === current) ? current : tasks[0]?.taskId || '');
280
+ }, [tasks]);
281
+
282
+ const canvasNodes = useMemo(() => layoutNodes(workflow), [workflow]);
283
+ const nodeIds = useMemo(() => new Set(canvasNodes.map(node => node.id)), [canvasNodes]);
284
+ const canvasEdges = useMemo(() => buildEdges(workflow, nodeIds), [workflow, nodeIds]);
285
+ const currentRuntime = runtimes.find(runtime => runtime.id === runtimeId) || null;
286
+ const selectedNode = nodes.find(node => node.id === selectedNodeId)?.data.workflowNode || canvasNodes[0] || null;
287
+
288
+ useEffect(() => {
289
+ setNodes(previous => toFlowNodes(canvasNodes, onSelectSession, previous));
290
+ }, [canvasNodes, onSelectSession, setNodes]);
291
+
292
+ useEffect(() => {
293
+ setEdges(toFlowEdges(canvasEdges));
294
+ }, [canvasEdges, setEdges]);
295
+
296
+ useEffect(() => {
297
+ if (!fittedOnce.current && nodes.length > 0 && flowRef.current) {
298
+ fittedOnce.current = true;
299
+ requestAnimationFrame(() => flowRef.current?.fitView({ padding: 0.28, duration: 220 }));
300
+ }
301
+ }, [nodes.length]);
302
+
303
+ const nodeTypes = useMemo(() => ({ wfNode: WfNodeCard }), []);
304
+
305
+ const chooseWorkflow = (nextWorkflowId: string) => {
306
+ setWorkflowId(nextWorkflowId);
307
+ setCeoPrompt(defaultPrompt(workflows, nextWorkflowId));
308
+ };
309
+
310
+ const runWorkflow = async () => {
311
+ if (!runtimeId) return;
312
+ setRunning(true);
313
+ setError(null);
314
+ try {
315
+ const session = await apiJson<Session>('/api/workflow/run', {
316
+ method: 'POST',
317
+ body: JSON.stringify({
318
+ workflowId,
319
+ runtime: runtimeId,
320
+ subagentMode,
321
+ ceoPrompt,
322
+ model,
323
+ taskId: bindTask ? taskId : null,
324
+ }),
325
+ });
326
+ invalidateApiCache('/api/workflow');
327
+ invalidateApiCache('/api/sessions');
328
+ await reload(true);
329
+ onSelectSession(session.sessionId);
330
+ } catch (e: any) {
331
+ setError(e?.message || 'Failed to run workflow');
332
+ } finally {
333
+ setRunning(false);
334
+ }
335
+ };
336
+
337
+ const createAgentNode = async () => {
338
+ if (!runtimeId || subagentMode !== 'wf-subagents') return;
339
+ setLaunchingAgent(true);
340
+ setError(null);
341
+ try {
342
+ const session = await apiJson<Session>('/api/sessions', {
343
+ method: 'POST',
344
+ body: JSON.stringify({
345
+ runtime: runtimeId,
346
+ model,
347
+ taskId: bindTask ? taskId : null,
348
+ role: 'wf-subagent',
349
+ objective: agentObjective,
350
+ subagentMode: 'wf-subagents',
351
+ workflowMode: 'wf',
352
+ }),
353
+ });
354
+ invalidateApiCache('/api/workflow');
355
+ invalidateApiCache('/api/sessions');
356
+ await reload(true);
357
+ onSelectSession(session.sessionId);
358
+ } catch (e: any) {
359
+ setError(e?.message || 'Failed to create agent node');
360
+ } finally {
361
+ setLaunchingAgent(false);
362
+ }
363
+ };
364
+
365
+ const onConnect = useCallback((connection: Connection) => {
366
+ setEdges(current => addEdge({
367
+ ...connection,
368
+ id: `manual-${connection.source}-${connection.target}-${Date.now()}`,
369
+ type: 'smoothstep',
370
+ label: 'manual',
371
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#111' },
372
+ style: { stroke: '#111', strokeWidth: 1.6 },
373
+ labelStyle: { fill: '#111', fontSize: 10, fontWeight: 600 },
374
+ labelBgStyle: { fill: 'rgba(255,255,255,0.82)' },
375
+ }, current));
376
+ }, [setEdges]);
377
+
378
+ const openPaneMenu = (event: ReactMouseEvent | globalThis.MouseEvent) => {
379
+ event.preventDefault();
380
+ const bounds = flowWrapperRef.current?.getBoundingClientRect();
381
+ if (!bounds) return;
382
+ setContextMenu({
383
+ x: Math.max(8, Math.min(event.clientX - bounds.left, bounds.width - 190)),
384
+ y: Math.max(8, Math.min(event.clientY - bounds.top, bounds.height - 116)),
385
+ });
386
+ };
387
+
388
+ const openNodeConfig: NodeMouseHandler<FlowNode> = (event, node) => {
389
+ event.preventDefault();
390
+ setContextMenu(null);
391
+ setSelectedNodeId(node.id);
392
+ setShowConfig(true);
393
+ };
394
+
395
+ const selectNode: NodeMouseHandler<FlowNode> = (_event, node) => {
396
+ setContextMenu(null);
397
+ setSelectedNodeId(node.id);
398
+ };
399
+
400
+ const resetView = () => {
401
+ setContextMenu(null);
402
+ flowRef.current?.fitView({ padding: 0.28, duration: 220 });
403
+ };
404
+
405
+ if (loading) return <LoadingView label="Loading workflow canvas" fullCanvas />;
406
+
407
+ return (
408
+ <div ref={flowWrapperRef} className="wf-canvas-shell" style={{ height: '100%', minHeight: 0 }}>
409
+ <ReactFlow
410
+ className="wf-flow"
411
+ data-testid="workflow-canvas"
412
+ nodes={nodes}
413
+ edges={edges}
414
+ nodeTypes={nodeTypes}
415
+ onNodesChange={onNodesChange}
416
+ onEdgesChange={onEdgesChange}
417
+ onConnect={onConnect}
418
+ onInit={(instance) => { flowRef.current = instance; }}
419
+ onPaneClick={() => setContextMenu(null)}
420
+ onPaneContextMenu={openPaneMenu}
421
+ onNodeClick={selectNode}
422
+ onNodeDoubleClick={openNodeConfig}
423
+ onNodeContextMenu={openNodeConfig}
424
+ fitView
425
+ minZoom={0.3}
426
+ maxZoom={1.7}
427
+ panOnDrag
428
+ panOnScroll
429
+ selectionOnDrag={false}
430
+ nodesDraggable
431
+ nodesConnectable
432
+ nodesFocusable={false}
433
+ edgesFocusable={false}
434
+ defaultEdgeOptions={{ type: 'smoothstep' }}
435
+ proOptions={{ hideAttribution: true }}
436
+ >
437
+ <Background color="#e5e7eb" gap={32} size={1} />
438
+ <Controls position="bottom-right" showInteractive={false} />
439
+ <MiniMap
440
+ position="bottom-left"
441
+ pannable
442
+ zoomable
443
+ nodeStrokeWidth={3}
444
+ nodeColor={(node) => statusColor((node.data as WfNodeData).workflowNode.status)}
445
+ style={{ width: 142, height: 96, borderRadius: 6, border: '1px solid var(--border)', background: 'rgba(255,255,255,0.84)' }}
446
+ />
447
+
448
+ {error && (
449
+ <Panel position="top-center">
450
+ <div data-canvas-control="true" style={{ fontSize: 11, color: '#991b1b', padding: '6px 8px', background: '#fef2f2', borderRadius: 'var(--radius)', border: '1px solid #fecaca' }}>
451
+ {error}
452
+ </div>
453
+ </Panel>
454
+ )}
455
+
456
+ <Panel position="top-left">
457
+ <aside data-canvas-control="true" className="nodrag nopan nowheel" style={{ ...PANEL, width: 292, maxHeight: 'calc(100vh - var(--header-h) - 32px)', overflow: 'auto', padding: 12 }}>
458
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
459
+ <Boxes size={14} />
460
+ <div>
461
+ <div style={{ fontSize: 12, fontWeight: 700 }}>Built-in Workflows</div>
462
+ <div style={{ fontSize: 10, color: 'var(--muted)' }}>{workflow?.workflowId || 'workflow-none'}</div>
463
+ </div>
464
+ </div>
465
+
466
+ <div style={{ display: 'grid', gap: 5, marginBottom: 10 }}>
467
+ {workflows.map(item => (
468
+ <button key={item.id} onClick={() => chooseWorkflow(item.id)}
469
+ style={{
470
+ textAlign: 'left',
471
+ border: `1px solid ${workflowId === item.id ? '#111' : 'var(--border)'}`,
472
+ borderRadius: 'var(--radius)',
473
+ padding: 8,
474
+ background: workflowId === item.id ? 'var(--surface)' : 'var(--bg)',
475
+ }}>
476
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
477
+ <span style={{ fontSize: 11, fontWeight: 700 }}>{item.label}</span>
478
+ <span style={{ fontSize: 9, color: 'var(--muted)' }}>{item.command}</span>
479
+ </div>
480
+ <div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>{item.description}</div>
481
+ </button>
482
+ ))}
483
+ </div>
484
+
485
+ <label style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 4, marginBottom: 10 }}>
486
+ CEO Prompt
487
+ <textarea value={ceoPrompt} onChange={e => setCeoPrompt(e.target.value)}
488
+ style={{ minHeight: 112, resize: 'vertical', padding: 8, fontSize: 11, lineHeight: 1.45, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }} />
489
+ </label>
490
+
491
+ <div style={{ fontSize: 10, color: 'var(--muted)', fontWeight: 700, textTransform: 'uppercase', marginBottom: 5 }}>Subagents</div>
492
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 10 }}>
493
+ {(workflow?.subagentModes || [
494
+ { id: 'built-in-subagents', label: 'Built-in' },
495
+ { id: 'wf-subagents', label: 'WF Terminals' },
496
+ ]).map(mode => (
497
+ <button key={mode.id} onClick={() => setSubagentMode(mode.id)}
498
+ style={{ padding: '6px 4px', fontSize: 10, fontWeight: subagentMode === mode.id ? 700 : 500, background: subagentMode === mode.id ? '#111' : 'var(--bg)', color: subagentMode === mode.id ? '#fff' : 'var(--muted)' }}>
499
+ {mode.label}
500
+ </button>
501
+ ))}
502
+ </div>
503
+
504
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 7, marginBottom: 10 }}>
505
+ <label style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 3 }}>
506
+ Runtime
507
+ <select value={runtimeId} onChange={e => setRuntimeId(e.target.value)}
508
+ style={{ minWidth: 0, padding: '6px 7px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }}>
509
+ {runtimes.map(runtime => <option key={runtime.id} value={runtime.id}>{runtime.label}</option>)}
510
+ </select>
511
+ </label>
512
+ <label style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 3 }}>
513
+ Model
514
+ <input value={model} onChange={e => setModel(e.target.value)} placeholder="optional"
515
+ style={{ minWidth: 0, padding: '6px 7px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }} />
516
+ </label>
517
+ </div>
518
+
519
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: 'var(--muted)', marginBottom: 8 }}>
520
+ <input type="checkbox" checked={bindTask} onChange={e => setBindTask(e.target.checked)} />
521
+ Bind a task
522
+ </label>
523
+ {bindTask && (
524
+ <select value={taskId} onChange={e => setTaskId(e.target.value)}
525
+ style={{ width: '100%', padding: '6px 7px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)', marginBottom: 10 }}>
526
+ {tasks.map(task => <option key={task.taskId} value={task.taskId}>{task.taskId}</option>)}
527
+ </select>
528
+ )}
529
+
530
+ <button onClick={runWorkflow} disabled={!currentRuntime || running}
531
+ style={{ width: '100%', padding: '8px 10px', borderRadius: 'var(--radius)', background: '#111', color: '#fff', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, opacity: !currentRuntime || running ? 0.5 : 1 }}>
532
+ <GitBranch size={12} /> Run /wf
533
+ </button>
534
+
535
+ {subagentMode === 'wf-subagents' && (
536
+ <div style={{ borderTop: '1px solid var(--border)', marginTop: 12, paddingTop: 12 }}>
537
+ <label style={{ fontSize: 10, color: 'var(--muted)', display: 'grid', gap: 3, marginBottom: 7 }}>
538
+ New Agent Objective
539
+ <input value={agentObjective} onChange={e => setAgentObjective(e.target.value)}
540
+ style={{ padding: '6px 7px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--bg)' }} />
541
+ </label>
542
+ <button onClick={createAgentNode} disabled={!currentRuntime || launchingAgent}
543
+ style={{ width: '100%', padding: '7px 10px', borderRadius: 'var(--radius)', border: '1px solid var(--border)', background: 'var(--bg)', color: 'var(--fg)', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, opacity: !currentRuntime || launchingAgent ? 0.5 : 1 }}>
544
+ <Plus size={12} /> Agent Node
545
+ </button>
546
+ </div>
547
+ )}
548
+ </aside>
549
+ </Panel>
550
+
551
+ <Panel position="top-right">
552
+ <div data-canvas-control="true" className="nodrag nopan" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
553
+ <div style={{ ...PANEL, padding: '6px 10px', fontSize: 11, fontWeight: 700, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
554
+ <Workflow size={13} /> WF Canvas
555
+ </div>
556
+ <button onClick={() => reload(true)} title="Refresh workflow" style={{ width: 30, height: 30, display: 'grid', placeItems: 'center', border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'rgba(255,255,255,0.9)', color: 'var(--muted)' }}>
557
+ <RefreshCw size={13} />
558
+ </button>
559
+ <button title="Fit view" onClick={resetView} style={{ width: 30, height: 30, display: 'grid', placeItems: 'center', border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'rgba(255,255,255,0.9)', color: 'var(--muted)' }}>
560
+ <Maximize2 size={13} />
561
+ </button>
562
+ </div>
563
+ </Panel>
564
+
565
+ {contextMenu && (
566
+ <div
567
+ data-canvas-control="true"
568
+ data-testid="workflow-context-menu"
569
+ className="nodrag nopan"
570
+ style={{ ...PANEL, position: 'absolute', zIndex: 12, left: contextMenu.x, top: contextMenu.y, width: 178, padding: 6, cursor: 'default' }}
571
+ >
572
+ <button
573
+ onClick={() => { setContextMenu(null); createAgentNode(); }}
574
+ disabled={!currentRuntime || subagentMode !== 'wf-subagents' || launchingAgent}
575
+ style={{ width: '100%', padding: '7px 8px', borderRadius: 'var(--radius)', display: 'flex', alignItems: 'center', gap: 7, color: 'var(--fg)', fontSize: 11, fontWeight: 600, opacity: !currentRuntime || subagentMode !== 'wf-subagents' || launchingAgent ? 0.5 : 1 }}
576
+ >
577
+ <Plus size={12} /> New Agent Node
578
+ </button>
579
+ <button
580
+ onClick={resetView}
581
+ style={{ width: '100%', padding: '7px 8px', borderRadius: 'var(--radius)', display: 'flex', alignItems: 'center', gap: 7, color: 'var(--muted)', fontSize: 11 }}
582
+ >
583
+ <Maximize2 size={12} /> Fit View
584
+ </button>
585
+ </div>
586
+ )}
587
+
588
+ {showConfig && selectedNode && (
589
+ <Panel position="bottom-right">
590
+ <aside data-canvas-control="true" className="nodrag nopan nowheel" style={{ ...PANEL, width: 292, padding: 12, cursor: 'default' }}>
591
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
592
+ <div>
593
+ <div style={{ fontSize: 12, fontWeight: 700 }}>{selectedNode.label}</div>
594
+ <div style={{ fontSize: 10, color: 'var(--muted)' }}>{selectedNode.id}</div>
595
+ </div>
596
+ <button onClick={() => setShowConfig(false)} style={{ fontSize: 10, color: 'var(--muted)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '3px 8px' }}>Close</button>
597
+ </div>
598
+
599
+ <dl style={{ display: 'grid', gridTemplateColumns: '76px 1fr', gap: '5px 8px', fontSize: 10, marginBottom: 10 }}>
600
+ <dt style={{ color: 'var(--muted)' }}>Kind</dt><dd>{selectedNode.kind}</dd>
601
+ <dt style={{ color: 'var(--muted)' }}>Status</dt><dd style={{ color: statusColor(selectedNode.status) }}>{selectedNode.status || 'unknown'}</dd>
602
+ {selectedNode.runtime && <><dt style={{ color: 'var(--muted)' }}>Runtime</dt><dd>{selectedNode.runtime}</dd></>}
603
+ {selectedNode.model && <><dt style={{ color: 'var(--muted)' }}>Model</dt><dd>{selectedNode.model}</dd></>}
604
+ {selectedNode.taskId && <><dt style={{ color: 'var(--muted)' }}>Task</dt><dd>{selectedNode.taskId}</dd></>}
605
+ {selectedNode.peerId && <><dt style={{ color: 'var(--muted)' }}>Peer</dt><dd>{shortId(selectedNode.peerId)}</dd></>}
606
+ </dl>
607
+
608
+ {selectedNode.sessionId ? (
609
+ <button onClick={() => onSelectSession(selectedNode.sessionId!)}
610
+ style={{ width: '100%', padding: '7px 10px', borderRadius: 'var(--radius)', background: '#111', color: '#fff', fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5 }}>
611
+ <Terminal size={12} /> Open Terminal
612
+ </button>
613
+ ) : (
614
+ <div style={{ fontSize: 10, color: 'var(--muted)' }}>
615
+ Use the left panel or right-click canvas to create a WF terminal node.
616
+ </div>
617
+ )}
618
+ </aside>
619
+ </Panel>
620
+ )}
621
+ </ReactFlow>
622
+ </div>
623
+ );
624
+ }
625
+
626
+ function WfNodeCard({ data, selected }: NodeProps<FlowNode>) {
627
+ const node = data.workflowNode;
628
+ const Icon = nodeKindIcon(node.kind);
629
+ const color = statusColor(node.status);
630
+ return (
631
+ <div
632
+ data-testid="workflow-node"
633
+ style={{
634
+ width: NODE_W,
635
+ minHeight: NODE_H,
636
+ border: `1px solid ${selected ? color : 'var(--border)'}`,
637
+ borderLeft: `3px solid ${color}`,
638
+ borderRadius: 'var(--radius)',
639
+ background: selected ? '#fff' : 'rgba(255,255,255,0.92)',
640
+ boxShadow: selected ? '0 10px 24px rgba(0,0,0,0.10)' : '0 4px 14px rgba(0,0,0,0.06)',
641
+ textAlign: 'left',
642
+ padding: 9,
643
+ display: 'grid',
644
+ alignContent: 'start',
645
+ gap: 4,
646
+ color: 'var(--fg)',
647
+ }}
648
+ >
649
+ <Handle className="wf-flow-handle" type="target" position={Position.Top} />
650
+ <Handle className="wf-flow-handle" type="source" position={Position.Bottom} />
651
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
652
+ <Icon size={14} />
653
+ <span style={{ fontSize: 12, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.label}</span>
654
+ </div>
655
+ <div style={{ fontSize: 10, color }}>{node.status || 'unknown'}</div>
656
+ <div style={{ fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
657
+ {node.runtime || node.kind}{node.model ? ` / ${node.model}` : ''}
658
+ </div>
659
+ {node.sessionId && (
660
+ <button
661
+ className="nodrag nopan"
662
+ onClick={(e) => { e.stopPropagation(); data.onOpenSession(node.sessionId!); }}
663
+ style={{ fontSize: 10, color: 'var(--fg)', textDecoration: 'underline', justifySelf: 'start', alignSelf: 'end' }}
664
+ >
665
+ terminal
666
+ </button>
667
+ )}
668
+ </div>
669
+ );
670
+ }
@@ -0,0 +1,17 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ export function useReducedMotion(): boolean {
4
+ const [reduced, setReduced] = useState(() => {
5
+ if (typeof window === 'undefined') return false;
6
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
7
+ });
8
+
9
+ useEffect(() => {
10
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
11
+ const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
12
+ mq.addEventListener('change', handler);
13
+ return () => mq.removeEventListener('change', handler);
14
+ }, []);
15
+
16
+ return reduced;
17
+ }