flock-core 0.5.0b50__py3-none-any.whl → 0.5.0b52__py3-none-any.whl

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.

Potentially problematic release.


This version of flock-core might be problematic. Click here for more details.

Files changed (117) hide show
  1. flock/dashboard/launcher.py +1 -1
  2. flock/frontend/README.md +678 -0
  3. flock/frontend/docs/DESIGN_SYSTEM.md +1980 -0
  4. flock/frontend/index.html +12 -0
  5. flock/frontend/package-lock.json +4347 -0
  6. flock/frontend/package.json +48 -0
  7. flock/frontend/src/App.tsx +79 -0
  8. flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +587 -0
  9. flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +387 -0
  10. flock/frontend/src/__tests__/integration/graph-rendering.test.tsx +640 -0
  11. flock/frontend/src/__tests__/integration/indexeddb-persistence.test.tsx +699 -0
  12. flock/frontend/src/components/common/BuildInfo.tsx +39 -0
  13. flock/frontend/src/components/common/EmptyState.module.css +115 -0
  14. flock/frontend/src/components/common/EmptyState.tsx +128 -0
  15. flock/frontend/src/components/common/ErrorBoundary.module.css +169 -0
  16. flock/frontend/src/components/common/ErrorBoundary.tsx +118 -0
  17. flock/frontend/src/components/common/KeyboardShortcutsDialog.css +251 -0
  18. flock/frontend/src/components/common/KeyboardShortcutsDialog.tsx +151 -0
  19. flock/frontend/src/components/common/LoadingSpinner.module.css +97 -0
  20. flock/frontend/src/components/common/LoadingSpinner.tsx +29 -0
  21. flock/frontend/src/components/controls/PublishControl.css +547 -0
  22. flock/frontend/src/components/controls/PublishControl.test.tsx +543 -0
  23. flock/frontend/src/components/controls/PublishControl.tsx +432 -0
  24. flock/frontend/src/components/details/DetailWindowContainer.tsx +62 -0
  25. flock/frontend/src/components/details/LiveOutputTab.test.tsx +792 -0
  26. flock/frontend/src/components/details/LiveOutputTab.tsx +220 -0
  27. flock/frontend/src/components/details/MessageHistoryTab.tsx +299 -0
  28. flock/frontend/src/components/details/NodeDetailWindow.test.tsx +501 -0
  29. flock/frontend/src/components/details/NodeDetailWindow.tsx +218 -0
  30. flock/frontend/src/components/details/RunStatusTab.tsx +307 -0
  31. flock/frontend/src/components/details/tabs.test.tsx +1015 -0
  32. flock/frontend/src/components/filters/CorrelationIDFilter.module.css +102 -0
  33. flock/frontend/src/components/filters/CorrelationIDFilter.test.tsx +197 -0
  34. flock/frontend/src/components/filters/CorrelationIDFilter.tsx +121 -0
  35. flock/frontend/src/components/filters/FilterBar.module.css +29 -0
  36. flock/frontend/src/components/filters/FilterBar.test.tsx +133 -0
  37. flock/frontend/src/components/filters/FilterBar.tsx +33 -0
  38. flock/frontend/src/components/filters/FilterPills.module.css +79 -0
  39. flock/frontend/src/components/filters/FilterPills.test.tsx +173 -0
  40. flock/frontend/src/components/filters/FilterPills.tsx +67 -0
  41. flock/frontend/src/components/filters/TimeRangeFilter.module.css +91 -0
  42. flock/frontend/src/components/filters/TimeRangeFilter.test.tsx +154 -0
  43. flock/frontend/src/components/filters/TimeRangeFilter.tsx +105 -0
  44. flock/frontend/src/components/graph/AgentNode.test.tsx +75 -0
  45. flock/frontend/src/components/graph/AgentNode.tsx +322 -0
  46. flock/frontend/src/components/graph/GraphCanvas.tsx +406 -0
  47. flock/frontend/src/components/graph/MessageFlowEdge.tsx +128 -0
  48. flock/frontend/src/components/graph/MessageNode.test.tsx +62 -0
  49. flock/frontend/src/components/graph/MessageNode.tsx +116 -0
  50. flock/frontend/src/components/graph/MiniMap.tsx +47 -0
  51. flock/frontend/src/components/graph/TransformEdge.tsx +123 -0
  52. flock/frontend/src/components/layout/DashboardLayout.css +407 -0
  53. flock/frontend/src/components/layout/DashboardLayout.tsx +300 -0
  54. flock/frontend/src/components/layout/Header.module.css +88 -0
  55. flock/frontend/src/components/layout/Header.tsx +52 -0
  56. flock/frontend/src/components/modules/EventLogModule.test.tsx +401 -0
  57. flock/frontend/src/components/modules/EventLogModule.tsx +396 -0
  58. flock/frontend/src/components/modules/EventLogModuleWrapper.tsx +17 -0
  59. flock/frontend/src/components/modules/ModuleRegistry.test.ts +333 -0
  60. flock/frontend/src/components/modules/ModuleRegistry.ts +85 -0
  61. flock/frontend/src/components/modules/ModuleWindow.tsx +155 -0
  62. flock/frontend/src/components/modules/registerModules.ts +20 -0
  63. flock/frontend/src/components/settings/AdvancedSettings.tsx +175 -0
  64. flock/frontend/src/components/settings/AppearanceSettings.tsx +185 -0
  65. flock/frontend/src/components/settings/GraphSettings.tsx +110 -0
  66. flock/frontend/src/components/settings/SettingsPanel.css +327 -0
  67. flock/frontend/src/components/settings/SettingsPanel.tsx +131 -0
  68. flock/frontend/src/components/settings/ThemeSelector.tsx +298 -0
  69. flock/frontend/src/hooks/useKeyboardShortcuts.ts +148 -0
  70. flock/frontend/src/hooks/useModulePersistence.test.ts +442 -0
  71. flock/frontend/src/hooks/useModulePersistence.ts +154 -0
  72. flock/frontend/src/hooks/useModules.ts +139 -0
  73. flock/frontend/src/hooks/usePersistence.ts +139 -0
  74. flock/frontend/src/main.tsx +13 -0
  75. flock/frontend/src/services/api.ts +213 -0
  76. flock/frontend/src/services/indexeddb.test.ts +793 -0
  77. flock/frontend/src/services/indexeddb.ts +794 -0
  78. flock/frontend/src/services/layout.test.ts +437 -0
  79. flock/frontend/src/services/layout.ts +146 -0
  80. flock/frontend/src/services/themeApplicator.ts +140 -0
  81. flock/frontend/src/services/themeService.ts +77 -0
  82. flock/frontend/src/services/websocket.test.ts +595 -0
  83. flock/frontend/src/services/websocket.ts +685 -0
  84. flock/frontend/src/store/filterStore.test.ts +242 -0
  85. flock/frontend/src/store/filterStore.ts +103 -0
  86. flock/frontend/src/store/graphStore.test.ts +186 -0
  87. flock/frontend/src/store/graphStore.ts +414 -0
  88. flock/frontend/src/store/moduleStore.test.ts +253 -0
  89. flock/frontend/src/store/moduleStore.ts +57 -0
  90. flock/frontend/src/store/settingsStore.ts +188 -0
  91. flock/frontend/src/store/streamStore.ts +68 -0
  92. flock/frontend/src/store/uiStore.test.ts +54 -0
  93. flock/frontend/src/store/uiStore.ts +110 -0
  94. flock/frontend/src/store/wsStore.ts +34 -0
  95. flock/frontend/src/styles/index.css +15 -0
  96. flock/frontend/src/styles/scrollbar.css +47 -0
  97. flock/frontend/src/styles/variables.css +488 -0
  98. flock/frontend/src/test/setup.ts +1 -0
  99. flock/frontend/src/types/filters.ts +14 -0
  100. flock/frontend/src/types/graph.ts +55 -0
  101. flock/frontend/src/types/modules.ts +7 -0
  102. flock/frontend/src/types/theme.ts +55 -0
  103. flock/frontend/src/utils/mockData.ts +85 -0
  104. flock/frontend/src/utils/performance.ts +16 -0
  105. flock/frontend/src/utils/transforms.test.ts +860 -0
  106. flock/frontend/src/utils/transforms.ts +323 -0
  107. flock/frontend/src/vite-env.d.ts +17 -0
  108. flock/frontend/tsconfig.json +27 -0
  109. flock/frontend/tsconfig.node.json +11 -0
  110. flock/frontend/vite.config.ts +25 -0
  111. flock/frontend/vitest.config.ts +11 -0
  112. flock/helper/cli_helper.py +1 -1
  113. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b52.dist-info}/METADATA +1 -1
  114. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b52.dist-info}/RECORD +117 -7
  115. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b52.dist-info}/WHEEL +0 -0
  116. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b52.dist-info}/entry_points.txt +0 -0
  117. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b52.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,406 @@
1
+ import { useCallback, useMemo, useEffect, useState } from 'react';
2
+ import {
3
+ ReactFlow,
4
+ Background,
5
+ Controls,
6
+ NodeChange,
7
+ EdgeChange,
8
+ applyNodeChanges,
9
+ applyEdgeChanges,
10
+ useReactFlow,
11
+ type Node,
12
+ } from '@xyflow/react';
13
+ import '@xyflow/react/dist/style.css';
14
+ import AgentNode from './AgentNode';
15
+ import MessageNode from './MessageNode';
16
+ import MessageFlowEdge from './MessageFlowEdge';
17
+ import TransformEdge from './TransformEdge';
18
+ import MiniMap from './MiniMap';
19
+ import { useGraphStore } from '../../store/graphStore';
20
+ import { useUIStore } from '../../store/uiStore';
21
+ import { useModuleStore } from '../../store/moduleStore';
22
+ import { useSettingsStore } from '../../store/settingsStore';
23
+ import { moduleRegistry } from '../modules/ModuleRegistry';
24
+ import { applyDagreLayout } from '../../services/layout';
25
+ import { usePersistence } from '../../hooks/usePersistence';
26
+ import { v4 as uuidv4 } from 'uuid';
27
+
28
+ const GraphCanvas: React.FC = () => {
29
+ const { fitView, getIntersectingNodes } = useReactFlow();
30
+
31
+ const mode = useUIStore((state) => state.mode);
32
+ const openDetailWindow = useUIStore((state) => state.openDetailWindow);
33
+ const layoutDirection = useSettingsStore((state) => state.advanced.layoutDirection);
34
+ const nodes = useGraphStore((state) => state.nodes);
35
+ const edges = useGraphStore((state) => state.edges);
36
+ const agents = useGraphStore((state) => state.agents);
37
+ const messages = useGraphStore((state) => state.messages);
38
+ const runs = useGraphStore((state) => state.runs);
39
+ const generateAgentViewGraph = useGraphStore((state) => state.generateAgentViewGraph);
40
+ const generateBlackboardViewGraph = useGraphStore((state) => state.generateBlackboardViewGraph);
41
+ const updateNodePosition = useGraphStore((state) => state.updateNodePosition);
42
+ const addModule = useModuleStore((state) => state.addModule);
43
+
44
+ // Graph settings from settings store
45
+ const edgeType = useSettingsStore((state) => state.graph.edgeType);
46
+ const edgeStrokeWidth = useSettingsStore((state) => state.graph.edgeStrokeWidth);
47
+ const edgeAnimation = useSettingsStore((state) => state.graph.edgeAnimation);
48
+
49
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
50
+ const [showModuleSubmenu, setShowModuleSubmenu] = useState(false);
51
+
52
+ // Persistence hook - loads positions on mount and handles saves
53
+ const { saveNodePosition } = usePersistence();
54
+
55
+ // Memoize node types to prevent re-creation
56
+ const nodeTypes = useMemo(
57
+ () => ({
58
+ agent: AgentNode,
59
+ message: MessageNode,
60
+ }),
61
+ []
62
+ );
63
+
64
+ // Memoize edge types to prevent re-creation
65
+ const edgeTypes = useMemo(
66
+ () => ({
67
+ message_flow: MessageFlowEdge,
68
+ transformation: TransformEdge,
69
+ }),
70
+ []
71
+ );
72
+
73
+ // Generate graph when mode changes OR when agents/messages/runs change
74
+ useEffect(() => {
75
+ if (mode === 'agent') {
76
+ generateAgentViewGraph();
77
+ } else {
78
+ generateBlackboardViewGraph();
79
+ }
80
+ }, [mode, agents, messages, runs, generateAgentViewGraph, generateBlackboardViewGraph]);
81
+
82
+ // Regenerate graph when edge settings change to apply new edge styles
83
+ useEffect(() => {
84
+ if (mode === 'agent') {
85
+ generateAgentViewGraph();
86
+ } else {
87
+ generateBlackboardViewGraph();
88
+ }
89
+ }, [edgeType, edgeStrokeWidth, edgeAnimation, mode, generateAgentViewGraph, generateBlackboardViewGraph]);
90
+
91
+ const onNodesChange = useCallback(
92
+ (changes: NodeChange[]) => {
93
+ const updatedNodes = applyNodeChanges(changes, nodes);
94
+ useGraphStore.setState({ nodes: updatedNodes });
95
+
96
+ // Update position in store for persistence
97
+ changes.forEach((change) => {
98
+ if (change.type === 'position' && change.position) {
99
+ updateNodePosition(change.id, change.position);
100
+ }
101
+ });
102
+ },
103
+ [nodes, updateNodePosition]
104
+ );
105
+
106
+ const onEdgesChange = useCallback(
107
+ (changes: EdgeChange[]) => {
108
+ const updatedEdges = applyEdgeChanges(changes, edges);
109
+ useGraphStore.setState({ edges: updatedEdges });
110
+ },
111
+ [edges]
112
+ );
113
+
114
+ // Auto-layout handler
115
+ const handleAutoLayout = useCallback(() => {
116
+ const nodeSpacing = useSettingsStore.getState().advanced.nodeSpacing;
117
+ const rankSpacing = useSettingsStore.getState().advanced.rankSpacing;
118
+ const layoutedNodes = applyDagreLayout(nodes, edges, layoutDirection || 'TB', nodeSpacing, rankSpacing);
119
+
120
+ // Update nodes with new positions
121
+ layoutedNodes.forEach((node) => {
122
+ updateNodePosition(node.id, node.position);
123
+ });
124
+
125
+ useGraphStore.setState({ nodes: layoutedNodes });
126
+ setContextMenu(null);
127
+ setShowModuleSubmenu(false);
128
+ }, [nodes, edges, layoutDirection, updateNodePosition]);
129
+
130
+ // Auto-zoom handler
131
+ const handleAutoZoom = useCallback(() => {
132
+ fitView({ padding: 0.1, duration: 300 });
133
+ setContextMenu(null);
134
+ setShowModuleSubmenu(false);
135
+ }, [fitView]);
136
+
137
+ // Add module handler
138
+ const handleAddModule = useCallback((moduleType: string, clickX: number, clickY: number) => {
139
+ const moduleInstance = {
140
+ id: uuidv4(),
141
+ type: moduleType,
142
+ position: { x: clickX, y: clickY },
143
+ size: { width: 600, height: 400 },
144
+ visible: true,
145
+ };
146
+
147
+ addModule(moduleInstance);
148
+ setContextMenu(null);
149
+ setShowModuleSubmenu(false);
150
+ }, [addModule]);
151
+
152
+ // Context menu handler
153
+ const onPaneContextMenu = useCallback((event: React.MouseEvent | MouseEvent) => {
154
+ event.preventDefault();
155
+ setContextMenu({
156
+ x: event.clientX,
157
+ y: event.clientY,
158
+ });
159
+ setShowModuleSubmenu(false);
160
+ }, []);
161
+
162
+ // Close context menu on click outside
163
+ const onPaneClick = useCallback(() => {
164
+ setContextMenu(null);
165
+ setShowModuleSubmenu(false);
166
+ }, []);
167
+
168
+ // Node drag handler - prevent overlaps with collision detection
169
+ const onNodeDrag = useCallback(
170
+ (_event: React.MouseEvent | MouseEvent, node: Node) => {
171
+ const intersections = getIntersectingNodes(node);
172
+
173
+ // If there are intersecting nodes, snap back to prevent overlap
174
+ if (intersections.length > 0) {
175
+ // Revert to previous position by updating the nodes
176
+ useGraphStore.setState((state) => ({
177
+ nodes: state.nodes.map((n) =>
178
+ n.id === node.id
179
+ ? { ...n, position: n.position } // Keep previous position
180
+ : n
181
+ ),
182
+ }));
183
+ }
184
+ },
185
+ [getIntersectingNodes]
186
+ );
187
+
188
+ // Node drag stop handler - persist position with 300ms debounce
189
+ const onNodeDragStop = useCallback(
190
+ (_event: React.MouseEvent | MouseEvent, node: Node) => {
191
+ saveNodePosition(node.id, node.position);
192
+ },
193
+ [saveNodePosition]
194
+ );
195
+
196
+ // Node double-click handler - open detail window
197
+ const onNodeDoubleClick = useCallback(
198
+ (_event: React.MouseEvent, node: Node) => {
199
+ openDetailWindow(node.id);
200
+ },
201
+ [openDetailWindow]
202
+ );
203
+
204
+ const defaultEdgeOptions = useMemo(
205
+ () => ({
206
+ type: edgeType,
207
+ animated: edgeAnimation,
208
+ style: {
209
+ stroke: 'var(--color-edge-default)',
210
+ strokeWidth: edgeStrokeWidth,
211
+ },
212
+ }),
213
+ [edgeType, edgeAnimation, edgeStrokeWidth]
214
+ );
215
+
216
+ return (
217
+ <div style={{ width: '100%', height: '100%', position: 'relative' }}>
218
+ <ReactFlow
219
+ nodes={nodes}
220
+ edges={edges}
221
+ onNodesChange={onNodesChange}
222
+ onEdgesChange={onEdgesChange}
223
+ onNodeDrag={onNodeDrag}
224
+ onNodeDragStop={onNodeDragStop}
225
+ onNodeDoubleClick={onNodeDoubleClick}
226
+ nodeTypes={nodeTypes}
227
+ edgeTypes={edgeTypes}
228
+ defaultEdgeOptions={defaultEdgeOptions}
229
+ onPaneContextMenu={onPaneContextMenu}
230
+ onPaneClick={onPaneClick}
231
+ style={{
232
+ backgroundColor: 'var(--color-bg-elevated)',
233
+ }}
234
+ >
235
+ <Background
236
+ color="var(--color-border-subtle)"
237
+ gap={16}
238
+ size={1}
239
+ style={{
240
+ backgroundColor: 'var(--color-bg-elevated)',
241
+ }}
242
+ />
243
+ <Controls
244
+ style={{
245
+ backgroundColor: 'var(--color-bg-surface)',
246
+ border: '1px solid var(--color-border-default)',
247
+ borderRadius: 'var(--radius-lg)',
248
+ overflow: 'hidden',
249
+ boxShadow: 'var(--shadow-lg)',
250
+ }}
251
+ showZoom={true}
252
+ showFitView={true}
253
+ showInteractive={true}
254
+ />
255
+ <MiniMap />
256
+ </ReactFlow>
257
+
258
+ {/* Context Menu */}
259
+ {contextMenu && (
260
+ <div
261
+ style={{
262
+ position: 'fixed',
263
+ top: contextMenu.y,
264
+ left: contextMenu.x,
265
+ background: 'var(--color-bg-surface)',
266
+ border: 'var(--border-default)',
267
+ borderRadius: 'var(--radius-md)',
268
+ boxShadow: 'var(--shadow-lg)',
269
+ zIndex: 1000,
270
+ minWidth: 180,
271
+ }}
272
+ >
273
+ <button
274
+ onClick={handleAutoLayout}
275
+ style={{
276
+ display: 'block',
277
+ width: '100%',
278
+ padding: 'var(--spacing-2) var(--spacing-4)',
279
+ border: 'none',
280
+ background: 'transparent',
281
+ cursor: 'pointer',
282
+ textAlign: 'left',
283
+ fontSize: 'var(--font-size-body-sm)',
284
+ color: 'var(--color-text-primary)',
285
+ transition: 'var(--transition-colors)',
286
+ }}
287
+ onMouseEnter={(e) => {
288
+ e.currentTarget.style.background = 'var(--color-bg-overlay)';
289
+ }}
290
+ onMouseLeave={(e) => {
291
+ e.currentTarget.style.background = 'transparent';
292
+ }}
293
+ >
294
+ Auto Layout
295
+ </button>
296
+
297
+ <button
298
+ onClick={handleAutoZoom}
299
+ style={{
300
+ display: 'block',
301
+ width: '100%',
302
+ padding: 'var(--spacing-2) var(--spacing-4)',
303
+ border: 'none',
304
+ background: 'transparent',
305
+ cursor: 'pointer',
306
+ textAlign: 'left',
307
+ fontSize: 'var(--font-size-body-sm)',
308
+ color: 'var(--color-text-primary)',
309
+ transition: 'var(--transition-colors)',
310
+ }}
311
+ onMouseEnter={(e) => {
312
+ e.currentTarget.style.background = 'var(--color-bg-overlay)';
313
+ }}
314
+ onMouseLeave={(e) => {
315
+ e.currentTarget.style.background = 'transparent';
316
+ }}
317
+ >
318
+ Auto Zoom
319
+ </button>
320
+
321
+ <div style={{ position: 'relative' }}>
322
+ <button
323
+ onMouseEnter={() => setShowModuleSubmenu(true)}
324
+ onMouseLeave={(e) => {
325
+ // Only close submenu if not moving to submenu itself
326
+ const relatedTarget = e.relatedTarget as HTMLElement;
327
+ if (!relatedTarget || !relatedTarget.closest('.module-submenu')) {
328
+ setShowModuleSubmenu(false);
329
+ }
330
+ }}
331
+ style={{
332
+ display: 'flex',
333
+ alignItems: 'center',
334
+ justifyContent: 'space-between',
335
+ width: '100%',
336
+ padding: 'var(--spacing-2) var(--spacing-4)',
337
+ border: 'none',
338
+ background: showModuleSubmenu ? 'var(--color-bg-overlay)' : 'transparent',
339
+ cursor: 'pointer',
340
+ textAlign: 'left',
341
+ fontSize: 'var(--font-size-body-sm)',
342
+ color: 'var(--color-text-primary)',
343
+ transition: 'var(--transition-colors)',
344
+ }}
345
+ >
346
+ <span>Add Module</span>
347
+ <span style={{ marginLeft: 'var(--spacing-2)' }}>▶</span>
348
+ </button>
349
+
350
+ {/* Module Submenu */}
351
+ {showModuleSubmenu && (
352
+ <div
353
+ className="module-submenu"
354
+ onMouseEnter={() => setShowModuleSubmenu(true)}
355
+ onMouseLeave={() => setShowModuleSubmenu(false)}
356
+ style={{
357
+ position: 'absolute',
358
+ top: 0,
359
+ left: '100%',
360
+ background: 'var(--color-bg-surface)',
361
+ border: 'var(--border-default)',
362
+ borderRadius: 'var(--radius-md)',
363
+ boxShadow: 'var(--shadow-lg)',
364
+ zIndex: 1001,
365
+ minWidth: 160,
366
+ }}
367
+ >
368
+ {moduleRegistry.getAll().map((module) => (
369
+ <button
370
+ key={module.id}
371
+ onClick={() => handleAddModule(module.id, contextMenu.x, contextMenu.y)}
372
+ style={{
373
+ display: 'flex',
374
+ alignItems: 'center',
375
+ gap: 'var(--gap-sm)',
376
+ width: '100%',
377
+ padding: 'var(--spacing-2) var(--spacing-4)',
378
+ border: 'none',
379
+ background: 'transparent',
380
+ cursor: 'pointer',
381
+ textAlign: 'left',
382
+ fontSize: 'var(--font-size-body-sm)',
383
+ color: 'var(--color-text-primary)',
384
+ transition: 'var(--transition-colors)',
385
+ }}
386
+ onMouseEnter={(e) => {
387
+ e.currentTarget.style.background = 'var(--color-bg-overlay)';
388
+ }}
389
+ onMouseLeave={(e) => {
390
+ e.currentTarget.style.background = 'transparent';
391
+ }}
392
+ >
393
+ {module.icon && <span>{module.icon}</span>}
394
+ <span>{module.name}</span>
395
+ </button>
396
+ ))}
397
+ </div>
398
+ )}
399
+ </div>
400
+ </div>
401
+ )}
402
+ </div>
403
+ );
404
+ };
405
+
406
+ export default GraphCanvas;
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import {
3
+ BaseEdge,
4
+ EdgeProps,
5
+ getBezierPath,
6
+ getStraightPath,
7
+ getSmoothStepPath,
8
+ getSimpleBezierPath,
9
+ EdgeLabelRenderer
10
+ } from '@xyflow/react';
11
+ import { useSettingsStore } from '../../store/settingsStore';
12
+
13
+ /**
14
+ * Phase 4: Graph Visualization & Dual Views - MessageFlowEdge Component
15
+ *
16
+ * Custom edge for Agent View showing message flow between agents.
17
+ * Features:
18
+ * - Animated edge to show active message flow
19
+ * - Label with message type and count (e.g., "Movie (3)")
20
+ * - Arrow marker at target
21
+ *
22
+ * SPECIFICATION: docs/specs/003-real-time-dashboard/PLAN.md Phase 4
23
+ */
24
+
25
+ export interface MessageFlowEdgeData {
26
+ messageType: string;
27
+ messageCount: number;
28
+ artifactIds: string[];
29
+ latestTimestamp: string;
30
+ labelOffset?: number; // Phase 11: Vertical offset to prevent label overlap
31
+ }
32
+
33
+ const MessageFlowEdge: React.FC<EdgeProps> = ({
34
+ id,
35
+ sourceX,
36
+ sourceY,
37
+ targetX,
38
+ targetY,
39
+ sourcePosition,
40
+ targetPosition,
41
+ label,
42
+ style = {},
43
+ markerEnd,
44
+ data,
45
+ }) => {
46
+ // Get edge settings from settings store
47
+ const edgeType = useSettingsStore((state) => state.graph.edgeType);
48
+ const edgeStrokeWidth = useSettingsStore((state) => state.graph.edgeStrokeWidth);
49
+ const edgeAnimation = useSettingsStore((state) => state.graph.edgeAnimation);
50
+ const showEdgeLabels = useSettingsStore((state) => state.graph.showEdgeLabels);
51
+
52
+ // Use appropriate path function based on settings
53
+ const getPath = () => {
54
+ const params = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition };
55
+ switch (edgeType) {
56
+ case 'straight':
57
+ return getStraightPath(params);
58
+ case 'smoothstep':
59
+ return getSmoothStepPath(params);
60
+ case 'simplebezier':
61
+ return getSimpleBezierPath(params);
62
+ case 'bezier':
63
+ default:
64
+ return getBezierPath(params);
65
+ }
66
+ };
67
+
68
+ const [edgePath, labelX, labelY] = getPath();
69
+
70
+ // Phase 11 Bug Fix: Apply label offset to prevent overlap when multiple edges exist
71
+ const edgeData = data as MessageFlowEdgeData | undefined;
72
+ const labelOffset = edgeData?.labelOffset || 0;
73
+
74
+ const [isHovered, setIsHovered] = React.useState(false);
75
+
76
+ return (
77
+ <>
78
+ <BaseEdge
79
+ id={id}
80
+ path={edgePath}
81
+ style={{
82
+ ...style,
83
+ stroke: 'var(--color-edge-message)',
84
+ strokeWidth: isHovered ? edgeStrokeWidth + 1 : edgeStrokeWidth,
85
+ animation: edgeAnimation ? 'dash 20s linear infinite' : 'none',
86
+ transition: 'var(--transition-all)',
87
+ filter: isHovered ? 'drop-shadow(0 0 4px var(--color-edge-message))' : 'none',
88
+ }}
89
+ markerEnd={markerEnd}
90
+ />
91
+ {label && showEdgeLabels && (
92
+ <EdgeLabelRenderer>
93
+ <div
94
+ style={{
95
+ position: 'absolute',
96
+ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY + labelOffset}px)`,
97
+ fontSize: 'var(--font-size-caption)',
98
+ fontWeight: 'var(--font-weight-medium)',
99
+ background: 'var(--color-edge-label-bg)',
100
+ color: 'var(--color-edge-label-text)',
101
+ padding: 'var(--spacing-1) var(--spacing-2)',
102
+ borderRadius: 'var(--radius-sm)',
103
+ border: 'var(--border-width-1) solid var(--color-edge-message)',
104
+ pointerEvents: 'all',
105
+ backdropFilter: 'blur(var(--blur-sm))',
106
+ boxShadow: 'var(--shadow-sm)',
107
+ transition: 'var(--transition-all)',
108
+ }}
109
+ className="nodrag nopan"
110
+ onMouseEnter={() => setIsHovered(true)}
111
+ onMouseLeave={() => setIsHovered(false)}
112
+ >
113
+ {label}
114
+ </div>
115
+ </EdgeLabelRenderer>
116
+ )}
117
+ <style>{`
118
+ @keyframes dash {
119
+ to {
120
+ stroke-dashoffset: -100;
121
+ }
122
+ }
123
+ `}</style>
124
+ </>
125
+ );
126
+ };
127
+
128
+ export default MessageFlowEdge;
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { ReactFlowProvider } from '@xyflow/react';
4
+ import MessageNode from './MessageNode';
5
+ import { MessageNodeData } from '../../types/graph';
6
+ import { NodeProps } from '@xyflow/react';
7
+
8
+ describe('MessageNode', () => {
9
+ const createNodeProps = (data: MessageNodeData, selected = false): NodeProps =>
10
+ ({
11
+ id: 'msg-1',
12
+ data,
13
+ selected,
14
+ type: 'message',
15
+ isConnectable: true,
16
+ dragging: false,
17
+ zIndex: 0,
18
+ selectable: true,
19
+ deletable: true,
20
+ draggable: true,
21
+ }) as unknown as NodeProps;
22
+
23
+ it('should render artifact type', () => {
24
+ const data: MessageNodeData = {
25
+ artifactType: 'Movie',
26
+ payloadPreview: '{"title": "Test Movie"}',
27
+ payload: { title: 'Test Movie' },
28
+ producedBy: 'movie',
29
+ consumedBy: ['tagline'],
30
+ timestamp: Date.now(),
31
+ };
32
+
33
+ render(
34
+ <ReactFlowProvider>
35
+ <MessageNode {...createNodeProps(data)} />
36
+ </ReactFlowProvider>
37
+ );
38
+ expect(screen.getByText('Movie')).toBeInTheDocument();
39
+ });
40
+
41
+ it('should render produced by', () => {
42
+ const data: MessageNodeData = {
43
+ artifactType: 'Movie',
44
+ payloadPreview: '{"title": "Test Movie"}',
45
+ payload: { title: 'Test Movie' },
46
+ producedBy: 'movie',
47
+ consumedBy: [],
48
+ timestamp: Date.now(),
49
+ };
50
+
51
+ render(
52
+ <ReactFlowProvider>
53
+ <MessageNode {...createNodeProps(data)} />
54
+ </ReactFlowProvider>
55
+ );
56
+ // Text is split across elements: <div>by: <span>movie</span></div>
57
+ // Use getByText with function matcher to find text across elements
58
+ expect(screen.getByText((_content, element) => {
59
+ return element?.textContent === 'by: movie';
60
+ })).toBeInTheDocument();
61
+ });
62
+ });