@grackle-ai/web-components 0.107.2

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 (279) hide show
  1. package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
  2. package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
  3. package/.rush/temp/operation/_phase_build/all.log +18 -0
  4. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
  5. package/.rush/temp/operation/_phase_build/state.json +3 -0
  6. package/.rush/temp/operation/_phase_test/all.log +121 -0
  7. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
  8. package/.rush/temp/operation/_phase_test/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +938 -0
  10. package/.storybook/main.ts +22 -0
  11. package/.storybook/preview.tsx +30 -0
  12. package/config/rig.json +4 -0
  13. package/config/rush-project.json +12 -0
  14. package/dist/index.css +1 -0
  15. package/dist/index.js +39221 -0
  16. package/eslint.config.cjs +5 -0
  17. package/package.json +83 -0
  18. package/rush-logs/web-components._phase_build.cache.log +4 -0
  19. package/rush-logs/web-components._phase_test.cache.log +4 -0
  20. package/src/components/chat/ChatInput.module.scss +81 -0
  21. package/src/components/chat/ChatInput.stories.tsx +91 -0
  22. package/src/components/chat/ChatInput.tsx +168 -0
  23. package/src/components/chat/index.ts +6 -0
  24. package/src/components/dag/DagView.module.scss +149 -0
  25. package/src/components/dag/DagView.stories.tsx +125 -0
  26. package/src/components/dag/DagView.tsx +109 -0
  27. package/src/components/dag/TaskNode.stories.tsx +133 -0
  28. package/src/components/dag/TaskNode.tsx +40 -0
  29. package/src/components/dag/useDagLayout.ts +139 -0
  30. package/src/components/display/Breadcrumbs.module.scss +71 -0
  31. package/src/components/display/Breadcrumbs.stories.tsx +80 -0
  32. package/src/components/display/Breadcrumbs.tsx +46 -0
  33. package/src/components/display/Button.module.scss +110 -0
  34. package/src/components/display/Button.stories.tsx +88 -0
  35. package/src/components/display/Button.tsx +40 -0
  36. package/src/components/display/ConfirmDialog.module.scss +67 -0
  37. package/src/components/display/ConfirmDialog.stories.tsx +81 -0
  38. package/src/components/display/ConfirmDialog.tsx +88 -0
  39. package/src/components/display/CopyButton.module.scss +41 -0
  40. package/src/components/display/CopyButton.stories.tsx +78 -0
  41. package/src/components/display/CopyButton.tsx +64 -0
  42. package/src/components/display/DemoBanner.module.scss +37 -0
  43. package/src/components/display/DemoBanner.stories.tsx +40 -0
  44. package/src/components/display/DemoBanner.tsx +23 -0
  45. package/src/components/display/EventHoverRow.module.scss +102 -0
  46. package/src/components/display/EventHoverRow.stories.tsx +99 -0
  47. package/src/components/display/EventHoverRow.tsx +154 -0
  48. package/src/components/display/EventRenderer.module.scss +272 -0
  49. package/src/components/display/EventRenderer.stories.tsx +186 -0
  50. package/src/components/display/EventRenderer.tsx +271 -0
  51. package/src/components/display/EventStream.module.scss +93 -0
  52. package/src/components/display/EventStream.stories.tsx +249 -0
  53. package/src/components/display/EventStream.tsx +369 -0
  54. package/src/components/display/FloatingActionBar.module.scss +107 -0
  55. package/src/components/display/FloatingActionBar.stories.tsx +122 -0
  56. package/src/components/display/FloatingActionBar.tsx +119 -0
  57. package/src/components/display/SessionAttemptSelector.module.scss +50 -0
  58. package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
  59. package/src/components/display/SessionAttemptSelector.tsx +49 -0
  60. package/src/components/display/SessionPicker.module.scss +200 -0
  61. package/src/components/display/SessionPicker.stories.tsx +169 -0
  62. package/src/components/display/SessionPicker.tsx +214 -0
  63. package/src/components/display/Skeleton.module.scss +58 -0
  64. package/src/components/display/Skeleton.stories.tsx +94 -0
  65. package/src/components/display/Skeleton.tsx +127 -0
  66. package/src/components/display/Spinner.module.scss +41 -0
  67. package/src/components/display/Spinner.stories.tsx +66 -0
  68. package/src/components/display/Spinner.tsx +32 -0
  69. package/src/components/display/SplashScreen.module.scss +20 -0
  70. package/src/components/display/SplashScreen.stories.tsx +26 -0
  71. package/src/components/display/SplashScreen.tsx +16 -0
  72. package/src/components/display/SplitButton.module.scss +166 -0
  73. package/src/components/display/SplitButton.stories.tsx +95 -0
  74. package/src/components/display/SplitButton.tsx +128 -0
  75. package/src/components/display/Tooltip.module.scss +84 -0
  76. package/src/components/display/Tooltip.stories.tsx +240 -0
  77. package/src/components/display/Tooltip.tsx +184 -0
  78. package/src/components/display/extractText.test.tsx +48 -0
  79. package/src/components/display/index.ts +20 -0
  80. package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
  81. package/src/components/editable/EditableCheckbox.tsx +39 -0
  82. package/src/components/editable/EditableField.module.scss +135 -0
  83. package/src/components/editable/EditableSelect.tsx +164 -0
  84. package/src/components/editable/EditableTextArea.stories.tsx +50 -0
  85. package/src/components/editable/EditableTextArea.tsx +148 -0
  86. package/src/components/editable/EditableTextField.stories.tsx +62 -0
  87. package/src/components/editable/EditableTextField.tsx +153 -0
  88. package/src/components/editable/EnvironmentSelect.module.scss +17 -0
  89. package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
  90. package/src/components/editable/EnvironmentSelect.tsx +87 -0
  91. package/src/components/editable/index.ts +13 -0
  92. package/src/components/editable/useEditableField.test.tsx +233 -0
  93. package/src/components/editable/useEditableField.ts +173 -0
  94. package/src/components/index.ts +20 -0
  95. package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
  96. package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
  97. package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
  98. package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
  99. package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
  100. package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
  101. package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
  102. package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
  103. package/src/components/knowledge/KnowledgeNav.tsx +138 -0
  104. package/src/components/knowledge/index.ts +3 -0
  105. package/src/components/layout/AppNav.module.scss +82 -0
  106. package/src/components/layout/AppNav.stories.tsx +115 -0
  107. package/src/components/layout/AppNav.tsx +133 -0
  108. package/src/components/layout/BottomStatusBar.module.scss +58 -0
  109. package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
  110. package/src/components/layout/BottomStatusBar.tsx +206 -0
  111. package/src/components/layout/Sidebar.module.scss +60 -0
  112. package/src/components/layout/Sidebar.stories.tsx +46 -0
  113. package/src/components/layout/Sidebar.tsx +84 -0
  114. package/src/components/layout/StatusBar.module.scss +108 -0
  115. package/src/components/layout/StatusBar.stories.tsx +119 -0
  116. package/src/components/layout/StatusBar.tsx +70 -0
  117. package/src/components/layout/index.ts +9 -0
  118. package/src/components/lists/EnvironmentNav.module.scss +118 -0
  119. package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
  120. package/src/components/lists/EnvironmentNav.tsx +133 -0
  121. package/src/components/lists/FindingsNav.module.scss +126 -0
  122. package/src/components/lists/FindingsNav.tsx +146 -0
  123. package/src/components/lists/TaskList.module.scss +206 -0
  124. package/src/components/lists/TaskList.stories.tsx +401 -0
  125. package/src/components/lists/TaskList.tsx +509 -0
  126. package/src/components/lists/index.ts +6 -0
  127. package/src/components/lists/listHelpers.tsx +130 -0
  128. package/src/components/notifications/Callout.module.scss +83 -0
  129. package/src/components/notifications/Callout.stories.tsx +81 -0
  130. package/src/components/notifications/Callout.tsx +64 -0
  131. package/src/components/notifications/Toast.module.scss +86 -0
  132. package/src/components/notifications/Toast.stories.tsx +71 -0
  133. package/src/components/notifications/Toast.tsx +51 -0
  134. package/src/components/notifications/ToastContainer.module.scss +23 -0
  135. package/src/components/notifications/ToastContainer.stories.tsx +66 -0
  136. package/src/components/notifications/ToastContainer.tsx +29 -0
  137. package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
  138. package/src/components/notifications/UpdateBanner.test.tsx +64 -0
  139. package/src/components/notifications/UpdateBanner.tsx +44 -0
  140. package/src/components/notifications/index.ts +8 -0
  141. package/src/components/panels/AboutPanel.stories.tsx +70 -0
  142. package/src/components/panels/AboutPanel.tsx +66 -0
  143. package/src/components/panels/AppearancePanel.stories.tsx +45 -0
  144. package/src/components/panels/AppearancePanel.tsx +97 -0
  145. package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
  146. package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
  147. package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
  148. package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
  149. package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
  150. package/src/components/panels/FindingsPanel.module.scss +94 -0
  151. package/src/components/panels/FindingsPanel.stories.tsx +109 -0
  152. package/src/components/panels/FindingsPanel.tsx +76 -0
  153. package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
  154. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
  155. package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
  156. package/src/components/panels/PluginsPanel.tsx +77 -0
  157. package/src/components/panels/SettingsPanel.module.scss +336 -0
  158. package/src/components/panels/TaskActionButtons.module.scss +22 -0
  159. package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
  160. package/src/components/panels/TaskActionButtons.tsx +87 -0
  161. package/src/components/panels/TaskEditPanel.module.scss +202 -0
  162. package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
  163. package/src/components/panels/TaskEditPanel.tsx +328 -0
  164. package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
  165. package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
  166. package/src/components/panels/TaskOverviewPanel.tsx +270 -0
  167. package/src/components/panels/TokensPanel.stories.tsx +131 -0
  168. package/src/components/panels/TokensPanel.tsx +143 -0
  169. package/src/components/panels/WorkpadPanel.module.scss +39 -0
  170. package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
  171. package/src/components/panels/WorkpadPanel.tsx +63 -0
  172. package/src/components/panels/index.ts +13 -0
  173. package/src/components/personas/McpToolSelector.module.scss +109 -0
  174. package/src/components/personas/McpToolSelector.stories.tsx +129 -0
  175. package/src/components/personas/McpToolSelector.tsx +180 -0
  176. package/src/components/personas/PersonaManager.module.scss +233 -0
  177. package/src/components/personas/PersonaManager.stories.tsx +139 -0
  178. package/src/components/personas/PersonaManager.tsx +122 -0
  179. package/src/components/schedules/ScheduleManager.module.scss +98 -0
  180. package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
  181. package/src/components/schedules/ScheduleManager.tsx +160 -0
  182. package/src/components/settings/SettingsNav.module.scss +82 -0
  183. package/src/components/settings/SettingsNav.stories.tsx +83 -0
  184. package/src/components/settings/SettingsNav.tsx +104 -0
  185. package/src/components/streams/StreamDetailPanel.module.scss +206 -0
  186. package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
  187. package/src/components/streams/StreamDetailPanel.tsx +119 -0
  188. package/src/components/streams/StreamList.module.scss +92 -0
  189. package/src/components/streams/StreamList.stories.tsx +99 -0
  190. package/src/components/streams/StreamList.tsx +114 -0
  191. package/src/components/streams/index.ts +10 -0
  192. package/src/components/tools/AgentToolCard.module.scss +118 -0
  193. package/src/components/tools/AgentToolCard.stories.tsx +304 -0
  194. package/src/components/tools/AgentToolCard.tsx +247 -0
  195. package/src/components/tools/FileEditCard.stories.tsx +138 -0
  196. package/src/components/tools/FileEditCard.tsx +160 -0
  197. package/src/components/tools/FileReadCard.stories.tsx +120 -0
  198. package/src/components/tools/FileReadCard.tsx +106 -0
  199. package/src/components/tools/FindingCard.stories.tsx +124 -0
  200. package/src/components/tools/FindingCard.tsx +178 -0
  201. package/src/components/tools/GenericToolCard.stories.tsx +80 -0
  202. package/src/components/tools/GenericToolCard.tsx +111 -0
  203. package/src/components/tools/IpcCard.stories.tsx +129 -0
  204. package/src/components/tools/IpcCard.tsx +178 -0
  205. package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
  206. package/src/components/tools/KnowledgeCard.tsx +165 -0
  207. package/src/components/tools/MetadataCard.stories.tsx +32 -0
  208. package/src/components/tools/MetadataCard.tsx +39 -0
  209. package/src/components/tools/SearchCard.stories.tsx +74 -0
  210. package/src/components/tools/SearchCard.tsx +86 -0
  211. package/src/components/tools/ShellCard.stories.tsx +112 -0
  212. package/src/components/tools/ShellCard.tsx +106 -0
  213. package/src/components/tools/TaskCard.stories.tsx +123 -0
  214. package/src/components/tools/TaskCard.tsx +203 -0
  215. package/src/components/tools/TodoCard.module.scss +131 -0
  216. package/src/components/tools/TodoCard.stories.tsx +202 -0
  217. package/src/components/tools/TodoCard.tsx +200 -0
  218. package/src/components/tools/ToolCard.stories.tsx +177 -0
  219. package/src/components/tools/ToolCard.tsx +60 -0
  220. package/src/components/tools/ToolCardProps.ts +20 -0
  221. package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
  222. package/src/components/tools/ToolSearchCard.tsx +86 -0
  223. package/src/components/tools/WorkpadCard.stories.tsx +106 -0
  224. package/src/components/tools/WorkpadCard.tsx +125 -0
  225. package/src/components/tools/classifyTool.test.ts +44 -0
  226. package/src/components/tools/classifyTool.ts +134 -0
  227. package/src/components/tools/parseDiff.ts +95 -0
  228. package/src/components/tools/parseShellOutput.ts +28 -0
  229. package/src/components/tools/toolCardHelpers.test.ts +53 -0
  230. package/src/components/tools/toolCards.module.scss +234 -0
  231. package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
  232. package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
  233. package/src/components/workspace/WorkspaceBoard.tsx +232 -0
  234. package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
  235. package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
  236. package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
  237. package/src/context/GrackleContext.ts +28 -0
  238. package/src/context/GrackleContextTypes.ts +64 -0
  239. package/src/context/SidebarContext.tsx +53 -0
  240. package/src/context/ThemeContext.tsx +21 -0
  241. package/src/context/ToastContext.tsx +56 -0
  242. package/src/hooks/types.ts +864 -0
  243. package/src/hooks/useEventSelection.test.ts +204 -0
  244. package/src/hooks/useEventSelection.ts +158 -0
  245. package/src/hooks/useSmartScroll.ts +151 -0
  246. package/src/hooks/useTheme.ts +228 -0
  247. package/src/index.ts +210 -0
  248. package/src/mocks/MockGrackleProvider.tsx +1397 -0
  249. package/src/mocks/mockData.ts +1966 -0
  250. package/src/mocks/mockKnowledgeData.ts +294 -0
  251. package/src/scss.d.ts +12 -0
  252. package/src/styles/global.scss +244 -0
  253. package/src/styles/mixins.scss +278 -0
  254. package/src/styles/prism-theme.scss +148 -0
  255. package/src/styles/theme.scss +1102 -0
  256. package/src/test-utils/storybook-decorators.tsx +50 -0
  257. package/src/test-utils/storybook-helpers.ts +262 -0
  258. package/src/themes.ts +142 -0
  259. package/src/utils/boardColumns.ts +141 -0
  260. package/src/utils/breadcrumbs.test.ts +285 -0
  261. package/src/utils/breadcrumbs.ts +222 -0
  262. package/src/utils/dashboard.test.ts +156 -0
  263. package/src/utils/dashboard.ts +195 -0
  264. package/src/utils/eventContent.test.ts +353 -0
  265. package/src/utils/eventContent.ts +209 -0
  266. package/src/utils/findingCategory.ts +33 -0
  267. package/src/utils/format.ts +27 -0
  268. package/src/utils/iconSize.ts +18 -0
  269. package/src/utils/navigation.ts +205 -0
  270. package/src/utils/route-config.test.ts +128 -0
  271. package/src/utils/scrollUtils.test.ts +65 -0
  272. package/src/utils/scrollUtils.ts +49 -0
  273. package/src/utils/sessionEvents.test.ts +302 -0
  274. package/src/utils/sessionEvents.ts +233 -0
  275. package/src/utils/taskStatus.tsx +137 -0
  276. package/src/utils/time.ts +92 -0
  277. package/tsconfig.json +8 -0
  278. package/vite.config.ts +20 -0
  279. package/vitest.config.ts +10 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Force-directed knowledge graph visualization using d3-force + SVG.
3
+ *
4
+ * Renders nodes as styled SVG elements with CSS theming support,
5
+ * glassmorphic cards, glow effects, and smooth transitions.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { useCallback, useRef, useEffect, useState, type JSX } from "react";
11
+ import {
12
+ forceSimulation,
13
+ forceLink,
14
+ forceManyBody,
15
+ forceCenter,
16
+ forceCollide,
17
+ type Simulation,
18
+ type SimulationNodeDatum,
19
+ type SimulationLinkDatum,
20
+ } from "d3-force";
21
+ import { drag, type D3DragEvent } from "d3-drag";
22
+ import { select, type Selection } from "d3-selection";
23
+ import { zoom, zoomIdentity, type ZoomBehavior } from "d3-zoom";
24
+ import "d3-transition";
25
+ import type { GraphNode, GraphLink } from "../../hooks/types.js";
26
+ import styles from "./KnowledgeGraph.module.scss";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface SimNode extends SimulationNodeDatum, GraphNode {}
33
+
34
+ interface SimLink extends SimulationLinkDatum<SimNode> {
35
+ type: string;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Constants
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const NODE_COLORS: Record<string, string> = {
43
+ reference: "#4A9EFF",
44
+ decision: "#22C55E",
45
+ insight: "#EAB308",
46
+ concept: "#A855F7",
47
+ snippet: "#6B7280",
48
+ };
49
+
50
+ function getNodeColor(node: GraphNode): string {
51
+ if (node.kind === "reference") {
52
+ return NODE_COLORS.reference;
53
+ }
54
+ return NODE_COLORS[node.category ?? "insight"] ?? NODE_COLORS.insight;
55
+ }
56
+
57
+ const NODE_WIDTH: number = 200;
58
+ const NODE_HEIGHT: number = 52;
59
+ const NODE_RADIUS: number = 12;
60
+
61
+ /** Padding around the bounding box when computing zoom-to-fit. */
62
+ const FIT_PADDING: number = 40;
63
+
64
+ /** Minimum drag distance (px) before a mouseup is treated as a drag-end rather than a click. */
65
+ const DRAG_CLICK_THRESHOLD: number = 3;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ interface ZoomToFitResult {
72
+ translateX: number;
73
+ translateY: number;
74
+ scale: number;
75
+ }
76
+
77
+ /**
78
+ * Compute the transform needed to fit all nodes within the viewport.
79
+ *
80
+ * Returns translate + scale that centers the node bounding box with padding.
81
+ * Scale is capped at 1.0 so small graphs are never zoomed in past 100%.
82
+ */
83
+ function computeZoomToFit(
84
+ nodes: readonly { x?: number; y?: number }[],
85
+ viewport: { width: number; height: number },
86
+ ): ZoomToFitResult | undefined {
87
+ if (viewport.width <= 0 || viewport.height <= 0) {
88
+ return undefined;
89
+ }
90
+
91
+ // Single-pass min/max to avoid stack overflow with large node counts
92
+ let minX: number = Infinity;
93
+ let maxX: number = -Infinity;
94
+ let minY: number = Infinity;
95
+ let maxY: number = -Infinity;
96
+ for (const n of nodes) {
97
+ const nx: number = n.x ?? 0;
98
+ const ny: number = n.y ?? 0;
99
+ if (nx < minX) { minX = nx; }
100
+ if (nx > maxX) { maxX = nx; }
101
+ if (ny < minY) { minY = ny; }
102
+ if (ny > maxY) { maxY = ny; }
103
+ }
104
+
105
+ const x0: number = minX - NODE_WIDTH / 2 - FIT_PADDING;
106
+ const x1: number = maxX + NODE_WIDTH / 2 + FIT_PADDING;
107
+ const y0: number = minY - NODE_HEIGHT / 2 - FIT_PADDING;
108
+ const y1: number = maxY + NODE_HEIGHT / 2 + FIT_PADDING;
109
+
110
+ const bboxWidth: number = x1 - x0;
111
+ const bboxHeight: number = y1 - y0;
112
+
113
+ const scale: number = Math.min(
114
+ viewport.width / bboxWidth,
115
+ viewport.height / bboxHeight,
116
+ 1.0,
117
+ );
118
+
119
+ const bboxCenterX: number = (x0 + x1) / 2;
120
+ const bboxCenterY: number = (y0 + y1) / 2;
121
+
122
+ return {
123
+ translateX: viewport.width / 2 - bboxCenterX * scale,
124
+ translateY: viewport.height / 2 - bboxCenterY * scale,
125
+ scale,
126
+ };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Component
131
+ // ---------------------------------------------------------------------------
132
+
133
+ interface KnowledgeGraphProps {
134
+ graphData: { nodes: GraphNode[]; links: GraphLink[] };
135
+ selectedNodeId?: string;
136
+ onNodeClick: (nodeId: string) => void;
137
+ onNodeDoubleClick: (nodeId: string) => void;
138
+ }
139
+
140
+ export function KnowledgeGraph({
141
+ graphData,
142
+ selectedNodeId,
143
+ onNodeClick,
144
+ onNodeDoubleClick,
145
+ }: KnowledgeGraphProps): JSX.Element {
146
+ const svgRef = useRef<SVGSVGElement>(null);
147
+ const gRef = useRef<SVGGElement>(null);
148
+ const simRef = useRef<Simulation<SimNode, SimLink> | undefined>(undefined);
149
+ const zoomRef = useRef<ZoomBehavior<SVGSVGElement, unknown> | undefined>(undefined);
150
+ const linkElsRef = useRef<Selection<SVGLineElement, SimLink, SVGGElement, unknown> | undefined>(undefined);
151
+ const nodeElsRef = useRef<Selection<SVGGElement, SimNode, SVGGElement, unknown> | undefined>(undefined);
152
+ const selectedNodeIdRef = useRef(selectedNodeId);
153
+ selectedNodeIdRef.current = selectedNodeId;
154
+ const didAutoFitRef = useRef(false);
155
+ const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
156
+ const dragDistanceRef = useRef(0);
157
+
158
+ // Track container size
159
+ useEffect(() => {
160
+ const container: HTMLElement | null = svgRef.current?.parentElement ?? null;
161
+ if (!container) {
162
+ return;
163
+ }
164
+ const observer: ResizeObserver = new ResizeObserver((entries) => {
165
+ for (const entry of entries) {
166
+ setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height });
167
+ }
168
+ });
169
+ observer.observe(container);
170
+ setDimensions({ width: container.clientWidth, height: container.clientHeight });
171
+ return () => { observer.disconnect(); };
172
+ }, []);
173
+
174
+ // Setup zoom
175
+ useEffect(() => {
176
+ if (!svgRef.current || !gRef.current) {
177
+ return;
178
+ }
179
+ const svgEl: SVGSVGElement = svgRef.current;
180
+ const gEl: SVGGElement = gRef.current;
181
+
182
+ const zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> = zoom<SVGSVGElement, unknown>()
183
+ .scaleExtent([0.1, 4])
184
+ .filter((event: Event) => {
185
+ // Prevent zoom on double-click (we use it for expand)
186
+ if (event.type === "dblclick") {
187
+ return false;
188
+ }
189
+ return true;
190
+ })
191
+ .on("zoom", (event) => {
192
+ select(gEl).attr("transform", String(event.transform));
193
+ });
194
+
195
+ select(svgEl).call(zoomBehavior);
196
+ zoomRef.current = zoomBehavior;
197
+ return () => { select(svgEl).on(".zoom", null); };
198
+ }, []);
199
+
200
+ // Stable callback refs so d3 event handlers don't go stale
201
+ const onClickRef = useRef(onNodeClick);
202
+ onClickRef.current = onNodeClick;
203
+ const onDblClickRef = useRef(onNodeDoubleClick);
204
+ onDblClickRef.current = onNodeDoubleClick;
205
+
206
+ // Run simulation
207
+ useEffect(() => {
208
+ if (!gRef.current) {
209
+ return;
210
+ }
211
+ const g: SVGGElement = gRef.current;
212
+
213
+ // Stop previous
214
+ if (simRef.current) {
215
+ simRef.current.stop();
216
+ simRef.current = undefined;
217
+ }
218
+
219
+ // Reset auto-fit flag when graph data changes so the next simulation run fits the view
220
+ didAutoFitRef.current = false;
221
+
222
+ if (graphData.nodes.length === 0) {
223
+ select(g).selectAll("*").remove();
224
+ return;
225
+ }
226
+
227
+ // Clone data for d3 mutation
228
+ const simNodes: SimNode[] = graphData.nodes.map((n) => ({ ...n }));
229
+ const nodeMap: Map<string, SimNode> = new Map(simNodes.map((n) => [n.id, n]));
230
+ const simLinks: SimLink[] = graphData.links
231
+ .filter((l) => nodeMap.has(l.source) && nodeMap.has(l.target))
232
+ .map((l) => ({ source: l.source, target: l.target, type: l.type }));
233
+
234
+ // Clear previous elements
235
+ select(g).selectAll("*").remove();
236
+
237
+ // Create link elements
238
+ const linkEls: Selection<SVGLineElement, SimLink, SVGGElement, unknown> = select(g)
239
+ .selectAll<SVGLineElement, SimLink>("line")
240
+ .data(simLinks)
241
+ .enter()
242
+ .append("line")
243
+ .attr("class", styles.link);
244
+
245
+ // Edge type tooltip on hover
246
+ linkEls.append("title")
247
+ .text((d: SimLink) => d.type);
248
+
249
+ linkElsRef.current = linkEls;
250
+
251
+ // Create node groups
252
+ const nodeEls: Selection<SVGGElement, SimNode, SVGGElement, unknown> = select(g)
253
+ .selectAll<SVGGElement, SimNode>("g.kg-node")
254
+ .data(simNodes)
255
+ .enter()
256
+ .append("g")
257
+ .attr("class", `kg-node ${styles.node}`)
258
+ .on("click", (_event: MouseEvent, d: SimNode) => {
259
+ // Suppress click if the user just finished dragging
260
+ if (dragDistanceRef.current > DRAG_CLICK_THRESHOLD) {
261
+ return;
262
+ }
263
+ onClickRef.current(d.id);
264
+ })
265
+ .on("dblclick", (_event: MouseEvent, d: SimNode) => {
266
+ onDblClickRef.current(d.id);
267
+ });
268
+
269
+ nodeElsRef.current = nodeEls;
270
+
271
+ // Node card background
272
+ nodeEls.append("rect")
273
+ .attr("class", styles.nodeCard)
274
+ .attr("width", NODE_WIDTH)
275
+ .attr("height", NODE_HEIGHT)
276
+ .attr("rx", NODE_RADIUS)
277
+ .attr("ry", NODE_RADIUS)
278
+ .style("--node-color", (d: SimNode) => getNodeColor(d));
279
+
280
+ // Category indicator bar
281
+ nodeEls.append("rect")
282
+ .attr("class", styles.nodeIndicator)
283
+ .attr("width", 4)
284
+ .attr("height", NODE_HEIGHT)
285
+ .attr("rx", 2)
286
+ .attr("fill", (d: SimNode) => getNodeColor(d));
287
+
288
+ // Node label
289
+ nodeEls.append("text")
290
+ .attr("class", styles.nodeLabel)
291
+ .attr("x", NODE_WIDTH / 2)
292
+ .attr("y", NODE_HEIGHT / 2 - 4)
293
+ .attr("text-anchor", "middle")
294
+ .attr("dominant-baseline", "central")
295
+ .text((d: SimNode) => d.label.length > 26 ? d.label.substring(0, 24) + "..." : d.label);
296
+
297
+ // Category badge
298
+ nodeEls.append("text")
299
+ .attr("class", styles.nodeBadge)
300
+ .attr("x", NODE_WIDTH / 2)
301
+ .attr("y", NODE_HEIGHT - 8)
302
+ .attr("text-anchor", "middle")
303
+ .text((d: SimNode) => (d.kind === "reference" ? d.sourceType ?? "ref" : d.category ?? "").toUpperCase());
304
+
305
+ // Simulation
306
+ const sim: Simulation<SimNode, SimLink> = forceSimulation(simNodes)
307
+ .force("link", forceLink<SimNode, SimLink>(simLinks).id((d) => d.id).distance(140))
308
+ .force("charge", forceManyBody().strength(-400))
309
+ .force("center", forceCenter(dimensions.width / 2, dimensions.height / 2))
310
+ .force("collide", forceCollide<SimNode>(NODE_WIDTH / 2 + 16))
311
+ .on("tick", () => {
312
+ linkEls
313
+ .attr("x1", (d: SimLink) => (d.source as SimNode).x ?? 0)
314
+ .attr("y1", (d: SimLink) => (d.source as SimNode).y ?? 0)
315
+ .attr("x2", (d: SimLink) => (d.target as SimNode).x ?? 0)
316
+ .attr("y2", (d: SimLink) => (d.target as SimNode).y ?? 0);
317
+
318
+ nodeEls
319
+ .attr("transform", (d: SimNode) =>
320
+ `translate(${(d.x ?? 0) - NODE_WIDTH / 2},${(d.y ?? 0) - NODE_HEIGHT / 2})`
321
+ );
322
+ });
323
+
324
+ simRef.current = sim;
325
+
326
+ // Drag behavior — lets users grab and reposition nodes
327
+ const dragBehavior = drag<SVGGElement, SimNode>()
328
+ .on("start", (_event: D3DragEvent<SVGGElement, SimNode, SimNode>, d: SimNode) => {
329
+ d.fx = d.x;
330
+ d.fy = d.y;
331
+ dragDistanceRef.current = 0;
332
+ })
333
+ .on("drag", (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d: SimNode) => {
334
+ d.fx = event.x;
335
+ d.fy = event.y;
336
+ dragDistanceRef.current += Math.abs(event.dx) + Math.abs(event.dy);
337
+ // Only reheat simulation once we confirm an actual drag gesture
338
+ if (dragDistanceRef.current > DRAG_CLICK_THRESHOLD && sim.alphaTarget() === 0) {
339
+ sim.alphaTarget(0.3).restart();
340
+ }
341
+ })
342
+ .on("end", (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d: SimNode) => {
343
+ if (!event.active) {
344
+ sim.alphaTarget(0);
345
+ }
346
+ // Release node so it re-settles in the force layout
347
+ d.fx = undefined;
348
+ d.fy = undefined;
349
+ });
350
+ nodeEls.call(dragBehavior);
351
+
352
+ // Zoom to fit all nodes once the force simulation has fully converged.
353
+ // One-shot: skip if already fitted (drag reheat would re-trigger), or if a node is selected.
354
+ sim.on("end", () => {
355
+ if (svgRef.current && zoomRef.current && simNodes.length > 0
356
+ && !didAutoFitRef.current && !selectedNodeIdRef.current) {
357
+ didAutoFitRef.current = true;
358
+ const fit: ZoomToFitResult | undefined = computeZoomToFit(simNodes, dimensions);
359
+ if (!fit) {
360
+ return;
361
+ }
362
+ const { translateX, translateY, scale }: ZoomToFitResult = fit;
363
+ const zb: ZoomBehavior<SVGSVGElement, unknown> = zoomRef.current;
364
+ const t = zoomIdentity.translate(translateX, translateY).scale(scale);
365
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- d3 zoom API pattern
366
+ select(svgRef.current).transition().duration(500).call(zb.transform, t);
367
+ }
368
+ });
369
+
370
+ return () => {
371
+ sim.stop();
372
+ };
373
+ }, [graphData, dimensions]);
374
+
375
+ // Update selection styling without rebuilding simulation
376
+ useEffect(() => {
377
+ if (!gRef.current || !nodeElsRef.current || !linkElsRef.current) {
378
+ return;
379
+ }
380
+
381
+ if (!selectedNodeId) {
382
+ // No selection — full opacity on everything
383
+ nodeElsRef.current.classed(styles.dimmed, false).classed(styles.selected, false);
384
+ linkElsRef.current.classed(styles.dimmedLink, false);
385
+ return;
386
+ }
387
+
388
+ // Build set of connected node IDs
389
+ const connectedIds: Set<string> = new Set([selectedNodeId]);
390
+ linkElsRef.current.each((d: SimLink) => {
391
+ const srcId: string = (d.source as SimNode).id;
392
+ const tgtId: string = (d.target as SimNode).id;
393
+ if (srcId === selectedNodeId || tgtId === selectedNodeId) {
394
+ connectedIds.add(srcId);
395
+ connectedIds.add(tgtId);
396
+ }
397
+ });
398
+
399
+ // Update node classes
400
+ nodeElsRef.current
401
+ .classed(styles.selected, (d: SimNode) => d.id === selectedNodeId)
402
+ .classed(styles.dimmed, (d: SimNode) => !connectedIds.has(d.id));
403
+
404
+ // Dim unconnected links
405
+ linkElsRef.current
406
+ .classed(styles.dimmedLink, (d: SimLink) => {
407
+ const srcId: string = (d.source as SimNode).id;
408
+ const tgtId: string = (d.target as SimNode).id;
409
+ return !connectedIds.has(srcId) || !connectedIds.has(tgtId);
410
+ });
411
+ }, [selectedNodeId, graphData]);
412
+
413
+ // Center on selected node
414
+ const handleCenterOnNode = useCallback(() => {
415
+ if (!selectedNodeId || !simRef.current || !svgRef.current || !zoomRef.current) {
416
+ return;
417
+ }
418
+ const node: SimNode | undefined = simRef.current.nodes().find((n: SimNode) => n.id === selectedNodeId);
419
+ if (node && Number.isFinite(node.x) && Number.isFinite(node.y)) {
420
+ const zb: ZoomBehavior<SVGSVGElement, unknown> = zoomRef.current;
421
+ const t = zoomIdentity
422
+ .translate(dimensions.width / 2, dimensions.height / 2)
423
+ .scale(1.2)
424
+ .translate(-(node.x ?? 0), -(node.y ?? 0));
425
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- d3 zoom API pattern
426
+ select(svgRef.current).transition().duration(500).call(zb.transform, t);
427
+ }
428
+ }, [selectedNodeId, dimensions]);
429
+
430
+ useEffect(() => {
431
+ handleCenterOnNode();
432
+ }, [handleCenterOnNode]);
433
+
434
+ return (
435
+ <div className={styles.graphContainer} data-testid="knowledge-graph">
436
+ <svg
437
+ ref={svgRef}
438
+ width={dimensions.width}
439
+ height={dimensions.height}
440
+ className={styles.svg}
441
+ >
442
+ <defs>
443
+ <filter id="glow">
444
+ <feGaussianBlur stdDeviation="3" result="coloredBlur" />
445
+ <feMerge>
446
+ <feMergeNode in="coloredBlur" />
447
+ <feMergeNode in="SourceGraphic" />
448
+ </feMerge>
449
+ </filter>
450
+ </defs>
451
+ <g ref={gRef} />
452
+ </svg>
453
+ </div>
454
+ );
455
+ }
@@ -0,0 +1,130 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ .nav {
4
+ display: flex;
5
+ flex-direction: column;
6
+ height: 100%;
7
+ overflow: hidden;
8
+ }
9
+
10
+ .searchForm {
11
+ display: flex;
12
+ gap: 4px;
13
+ padding: 8px 12px;
14
+ border-bottom: 1px solid var(--border-default);
15
+ }
16
+
17
+ .searchInput {
18
+ flex: 1;
19
+ min-width: 0;
20
+ padding: 5px 8px;
21
+ border: 1px solid var(--border-default);
22
+ border-radius: var(--radius-sm);
23
+ background: var(--bg-inset);
24
+ color: var(--text-primary);
25
+ font-size: 13px;
26
+
27
+ &::placeholder {
28
+ color: var(--text-disabled);
29
+ }
30
+
31
+ &:focus {
32
+ outline: none;
33
+ border-color: var(--accent-blue);
34
+ }
35
+ }
36
+
37
+ .searchButton {
38
+ @include btn-primary;
39
+ padding: 5px 10px;
40
+ font-size: 12px;
41
+ }
42
+
43
+ .clearButton {
44
+ display: block;
45
+ width: 100%;
46
+ padding: 4px 12px;
47
+ background: none;
48
+ border: none;
49
+ border-bottom: 1px solid var(--border-default);
50
+ color: var(--text-secondary);
51
+ font-size: 12px;
52
+ cursor: pointer;
53
+ text-align: left;
54
+
55
+ &:hover {
56
+ color: var(--text-primary);
57
+ background: var(--bg-hover, rgba(255, 255, 255, 0.04));
58
+ }
59
+ }
60
+
61
+ .workspaceSelect {
62
+ margin: 8px 12px;
63
+ padding: 5px 8px;
64
+ border: 1px solid var(--border-default);
65
+ border-radius: var(--radius-sm);
66
+ background: var(--bg-inset);
67
+ color: var(--text-primary);
68
+ font-size: 12px;
69
+
70
+ &:focus {
71
+ outline: none;
72
+ border-color: var(--accent-blue);
73
+ }
74
+ }
75
+
76
+ .listHeader {
77
+ padding: 8px 12px 4px;
78
+ font-size: 10px;
79
+ font-weight: 600;
80
+ text-transform: uppercase;
81
+ letter-spacing: 0.5px;
82
+ color: var(--text-disabled);
83
+ }
84
+
85
+ .nodeList {
86
+ list-style: none;
87
+ margin: 0;
88
+ padding: 0;
89
+ overflow-y: auto;
90
+ flex: 1;
91
+ }
92
+
93
+ .nodeItem {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 8px;
97
+ padding: 7px 12px;
98
+ cursor: pointer;
99
+ transition: background 0.15s ease;
100
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
101
+
102
+ &:hover {
103
+ background: var(--bg-hover, rgba(255, 255, 255, 0.04));
104
+ }
105
+ }
106
+
107
+ .indicator {
108
+ width: 3px;
109
+ height: 18px;
110
+ border-radius: 2px;
111
+ flex-shrink: 0;
112
+ }
113
+
114
+ .label {
115
+ flex: 1;
116
+ font-size: 13px;
117
+ color: var(--text-secondary);
118
+ overflow: hidden;
119
+ text-overflow: ellipsis;
120
+ white-space: nowrap;
121
+ }
122
+
123
+ .badge {
124
+ font-family: var(--font-mono, monospace);
125
+ font-size: 9px;
126
+ text-transform: uppercase;
127
+ letter-spacing: 0.3px;
128
+ color: var(--text-disabled);
129
+ flex-shrink: 0;
130
+ }
@@ -0,0 +1,108 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn, userEvent } from "@storybook/test";
3
+ import { KnowledgeNav } from "./KnowledgeNav.js";
4
+ import { makeGraphNode, makeWorkspace } from "../../test-utils/storybook-helpers.js";
5
+
6
+ const meta: Meta<typeof KnowledgeNav> = {
7
+ title: "Grackle/Knowledge/KnowledgeNav",
8
+ tags: ["autodocs"],
9
+ component: KnowledgeNav,
10
+ decorators: [
11
+ (Story) => (
12
+ <div style={{ width: "280px", height: "500px", overflow: "auto" }}>
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ args: {
18
+ nodes: [],
19
+ workspaces: [],
20
+ loading: false,
21
+ searchQuery: "",
22
+ onSearch: fn(),
23
+ onClearSearch: fn(),
24
+ onSelectNode: fn(),
25
+ onWorkspaceChange: fn(),
26
+ },
27
+ };
28
+
29
+ export default meta;
30
+ type Story = StoryObj<typeof KnowledgeNav>;
31
+
32
+ /** Empty state shows "Nodes (0)" and the search input. */
33
+ export const EmptyState: Story = {
34
+ play: async ({ canvas }) => {
35
+ await expect(canvas.getByTestId("knowledge-search-input")).toBeInTheDocument();
36
+ await expect(canvas.getByTestId("knowledge-nav")).toHaveTextContent("Nodes (0)");
37
+ },
38
+ };
39
+
40
+ /** Node list renders labels with category badges for each kind. */
41
+ export const WithNodes: Story = {
42
+ args: {
43
+ nodes: [
44
+ makeGraphNode({ id: "n-1", label: "Auth Flow", kind: "knowledge", category: "concept" }),
45
+ makeGraphNode({ id: "n-2", label: "DB Schema Choice", kind: "knowledge", category: "decision" }),
46
+ makeGraphNode({ id: "n-3", label: "Perf Insight", kind: "knowledge", category: "insight" }),
47
+ makeGraphNode({ id: "n-4", label: "Login Bug", kind: "reference", sourceType: "task" }),
48
+ ],
49
+ },
50
+ play: async ({ canvas }) => {
51
+ await expect(canvas.getByTestId("knowledge-nav")).toHaveTextContent("Nodes (4)");
52
+ await expect(canvas.getByText("Auth Flow")).toBeInTheDocument();
53
+ await expect(canvas.getByText("DB Schema Choice")).toBeInTheDocument();
54
+ await expect(canvas.getByText("Perf Insight")).toBeInTheDocument();
55
+ await expect(canvas.getByText("Login Bug")).toBeInTheDocument();
56
+ },
57
+ };
58
+
59
+ /** Submitting the search form calls onSearch with the trimmed query. */
60
+ export const SearchSubmit: Story = {
61
+ play: async ({ canvas, args }) => {
62
+ const input = canvas.getByTestId("knowledge-search-input");
63
+ await userEvent.type(input, " OAuth flow ");
64
+ await userEvent.keyboard("{Enter}");
65
+ await expect(args.onSearch).toHaveBeenCalledWith("OAuth flow");
66
+ },
67
+ };
68
+
69
+ /** Clear search button appears when searchQuery is non-empty and calls onClearSearch. */
70
+ export const ClearSearchButton: Story = {
71
+ args: {
72
+ searchQuery: "active query",
73
+ },
74
+ play: async ({ canvas, args }) => {
75
+ const clearButton = canvas.getByRole("button", { name: "Clear search" });
76
+ await expect(clearButton).toBeInTheDocument();
77
+ await userEvent.click(clearButton);
78
+ await expect(args.onClearSearch).toHaveBeenCalled();
79
+ },
80
+ };
81
+
82
+ /** Changing the workspace filter calls onWorkspaceChange with the selected ID. */
83
+ export const WorkspaceFilterChange: Story = {
84
+ args: {
85
+ workspaces: [
86
+ makeWorkspace({ id: "ws-alpha", name: "Alpha Workspace" }),
87
+ makeWorkspace({ id: "ws-beta", name: "Beta Workspace" }),
88
+ ],
89
+ },
90
+ play: async ({ canvas, args }) => {
91
+ const select = canvas.getByTestId("knowledge-workspace-filter");
92
+ await userEvent.selectOptions(select, "ws-alpha");
93
+ await expect(args.onWorkspaceChange).toHaveBeenCalledWith("ws-alpha");
94
+ },
95
+ };
96
+
97
+ /** Clicking a node in the list calls onSelectNode with the correct ID. */
98
+ export const NodeClickCallsOnSelectNode: Story = {
99
+ args: {
100
+ nodes: [
101
+ makeGraphNode({ id: "node-xyz", label: "Click Me" }),
102
+ ],
103
+ },
104
+ play: async ({ canvas, args }) => {
105
+ await userEvent.click(canvas.getByText("Click Me"));
106
+ await expect(args.onSelectNode).toHaveBeenCalledWith("node-xyz");
107
+ },
108
+ };