create-harness-vibe-coding 0.8.17 → 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 (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. 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
+ }