@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,9 @@
1
+ /**
2
+ * Layout components for the application shell structure.
3
+ * @module layout
4
+ */
5
+ export { StatusBar } from "./StatusBar.js";
6
+ export { AppNav, TABS } from "./AppNav.js";
7
+ export type { AppTab } from "./AppNav.js";
8
+ export { Sidebar } from "./Sidebar.js";
9
+ export { BottomStatusBar } from "./BottomStatusBar.js";
@@ -0,0 +1,118 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ .nav {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: var(--space-xs);
7
+ width: 100%;
8
+ padding: var(--space-md);
9
+ overflow-y: auto;
10
+
11
+ @include mobile {
12
+ flex-direction: row;
13
+ width: 100%;
14
+ min-width: unset;
15
+ border-right: none;
16
+ border-bottom: 1px solid var(--border-subtle);
17
+ overflow-x: auto;
18
+ overflow-y: hidden;
19
+ padding: var(--space-xs) var(--space-sm);
20
+ gap: 0;
21
+ flex-wrap: nowrap;
22
+ }
23
+ }
24
+
25
+ .tab {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: var(--space-sm);
29
+ padding: var(--space-sm) var(--space-md);
30
+ border: none;
31
+ border-left: 3px solid transparent;
32
+ border-radius: var(--radius-md);
33
+ background: transparent;
34
+ color: var(--text-secondary);
35
+ font-size: var(--font-size-sm);
36
+ font-family: var(--font-ui);
37
+ cursor: pointer;
38
+ transition: background var(--transition-fast),
39
+ color var(--transition-fast),
40
+ border-color var(--transition-fast);
41
+ text-align: left;
42
+ width: 100%;
43
+
44
+ &:hover {
45
+ background: var(--bg-overlay);
46
+ color: var(--text-primary);
47
+ }
48
+
49
+ &:focus-visible {
50
+ outline: 2px solid var(--accent-green);
51
+ outline-offset: -2px;
52
+ }
53
+
54
+ @include mobile {
55
+ border-left: none;
56
+ border-bottom: 2px solid transparent;
57
+ white-space: nowrap;
58
+ flex-shrink: 0;
59
+ padding: var(--space-xs) var(--space-sm);
60
+ font-size: var(--font-size-xs);
61
+ width: auto;
62
+ border-radius: 0;
63
+ }
64
+ }
65
+
66
+ .tabActive {
67
+ border-left-color: var(--accent-green);
68
+ background: var(--bg-overlay);
69
+ color: var(--text-primary);
70
+ font-weight: var(--font-weight-medium);
71
+
72
+ @include mobile {
73
+ border-left-color: transparent;
74
+ border-bottom-color: var(--accent-green);
75
+ }
76
+ }
77
+
78
+ .tabLabel {
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ }
83
+
84
+ .statusDot {
85
+ flex-shrink: 0;
86
+ font-size: var(--font-size-xs);
87
+ line-height: 1;
88
+ }
89
+
90
+ .pulse {
91
+ animation: pulse-glow 2s ease-in-out infinite;
92
+ }
93
+
94
+ @keyframes pulse-glow {
95
+ 0%, 100% { opacity: 1; }
96
+ 50% { opacity: 0.5; }
97
+ }
98
+
99
+ .addButton {
100
+ @include btn-ghost;
101
+ font-size: var(--font-size-sm);
102
+ padding: var(--space-sm) var(--space-md);
103
+ margin-top: var(--space-sm);
104
+ text-align: left;
105
+ width: 100%;
106
+ color: var(--text-tertiary);
107
+
108
+ &:hover {
109
+ color: var(--accent-green);
110
+ }
111
+ }
112
+
113
+ .empty {
114
+ padding: var(--space-md);
115
+ font-size: var(--font-size-sm);
116
+ color: var(--text-tertiary);
117
+ text-align: center;
118
+ }
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, userEvent } from "@storybook/test";
3
+ import { EnvironmentNav } from "./EnvironmentNav.js";
4
+ import { buildEnvironment } from "../../test-utils/storybook-helpers.js";
5
+
6
+ const meta: Meta<typeof EnvironmentNav> = {
7
+ title: "Grackle/Lists/EnvironmentNav",
8
+ tags: ["autodocs"],
9
+ component: EnvironmentNav,
10
+ args: {
11
+ environments: [
12
+ buildEnvironment({ id: "env-1", displayName: "test-local", status: "connected" }),
13
+ buildEnvironment({ id: "env-2", displayName: "test-ssh", status: "disconnected", adapterType: "ssh" }),
14
+ buildEnvironment({ id: "env-3", displayName: "test-docker", status: "error", adapterType: "docker" }),
15
+ ],
16
+ },
17
+ };
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof EnvironmentNav>;
21
+
22
+ /** Environment card renders with its display name. */
23
+ export const CardRendersWithName: Story = {
24
+ play: async ({ canvas }) => {
25
+ await expect(canvas.getByText("test-local")).toBeInTheDocument();
26
+ await expect(canvas.getByText("test-ssh")).toBeInTheDocument();
27
+ await expect(canvas.getByText("test-docker")).toBeInTheDocument();
28
+ },
29
+ };
30
+
31
+ /** Status dot is colored based on the environment status. */
32
+ export const StatusDotColored: Story = {
33
+ args: {
34
+ environments: [
35
+ buildEnvironment({ id: "env-connected", displayName: "Connected Env", status: "connected" }),
36
+ buildEnvironment({ id: "env-disconnected", displayName: "Disconnected Env", status: "disconnected" }),
37
+ buildEnvironment({ id: "env-error", displayName: "Error Env", status: "error" }),
38
+ ],
39
+ },
40
+ play: async ({ canvas }) => {
41
+ const items: HTMLElement[] = canvas.getAllByTestId("env-nav-item");
42
+ await expect(items.length).toBe(3);
43
+
44
+ // Connected and disconnected dots should have different colors
45
+ const connectedDot: HTMLElement | null = items[0].querySelector("span");
46
+ const disconnectedDot: HTMLElement | null = items[1].querySelector("span");
47
+ await expect(connectedDot).not.toBeNull();
48
+ await expect(disconnectedDot).not.toBeNull();
49
+ if (connectedDot && disconnectedDot) {
50
+ const connectedColor: string = window.getComputedStyle(connectedDot).color;
51
+ const disconnectedColor: string = window.getComputedStyle(disconnectedDot).color;
52
+ await expect(connectedColor).not.toBe(disconnectedColor);
53
+ }
54
+ },
55
+ };
56
+
57
+ /** The "+ Add Environment" button is visible and accessible. */
58
+ export const AddButtonVisible: Story = {
59
+ play: async ({ canvas }) => {
60
+ const addButton = canvas.getByTestId("env-nav-add");
61
+ await expect(addButton).toBeInTheDocument();
62
+ await expect(addButton).toHaveTextContent("+ Add Environment");
63
+ },
64
+ };
65
+
66
+ /** Environment cards appear in the nav list. */
67
+ export const CardsInList: Story = {
68
+ play: async ({ canvas }) => {
69
+ const navItems = canvas.getAllByTestId("env-nav-item");
70
+ await expect(navItems.length).toBe(3);
71
+ },
72
+ };
73
+
74
+ /** Arrow keys navigate between environment items. */
75
+ export const KeyboardNavigation: Story = {
76
+ play: async ({ canvas }) => {
77
+ const tabs = canvas.getAllByRole("tab");
78
+ tabs[0].focus();
79
+ await expect(tabs[0]).toHaveFocus();
80
+
81
+ // ArrowDown moves to next
82
+ await userEvent.keyboard("{ArrowDown}");
83
+ await expect(tabs[1]).toHaveFocus();
84
+
85
+ // ArrowUp moves back
86
+ await userEvent.keyboard("{ArrowUp}");
87
+ await expect(tabs[0]).toHaveFocus();
88
+
89
+ // End jumps to last, Home jumps to first
90
+ await userEvent.keyboard("{End}");
91
+ await expect(tabs[tabs.length - 1]).toHaveFocus();
92
+ await userEvent.keyboard("{Home}");
93
+ await expect(tabs[0]).toHaveFocus();
94
+ },
95
+ };
96
+
97
+ /** J/K keys navigate between environment items (vim-style aliases). */
98
+ export const JKNavigation: Story = {
99
+ play: async ({ canvas }) => {
100
+ const tabs = canvas.getAllByRole("tab");
101
+ tabs[0].focus();
102
+
103
+ await userEvent.keyboard("j");
104
+ await expect(tabs[1]).toHaveFocus();
105
+
106
+ await userEvent.keyboard("k");
107
+ await expect(tabs[0]).toHaveFocus();
108
+ },
109
+ };
110
+
111
+ /** Empty state shows a message when there are no environments. */
112
+ export const EmptyState: Story = {
113
+ args: {
114
+ environments: [],
115
+ },
116
+ play: async ({ canvas }) => {
117
+ await expect(canvas.getByText("No environments yet.")).toBeInTheDocument();
118
+ // Add button should still be visible
119
+ await expect(canvas.getByTestId("env-nav-add")).toBeInTheDocument();
120
+ },
121
+ };
@@ -0,0 +1,133 @@
1
+ import { useCallback, useRef, type JSX, type KeyboardEvent } from "react";
2
+ import { Circle } from "lucide-react";
3
+ import { ICON_XS } from "../../utils/iconSize.js";
4
+ import { useMatch } from "react-router";
5
+ import type { Environment } from "../../hooks/types.js";
6
+ import { environmentUrl, NEW_ENVIRONMENT_URL, useAppNavigate } from "../../utils/navigation.js";
7
+ import styles from "./EnvironmentNav.module.scss";
8
+
9
+ /** Status-dot color mapping using CSS custom properties. */
10
+ const STATUS_COLORS: Record<string, string> = {
11
+ connected: "var(--accent-green)",
12
+ sleeping: "var(--accent-yellow)",
13
+ error: "var(--accent-red)",
14
+ disconnected: "var(--text-tertiary)",
15
+ connecting: "var(--accent-blue)",
16
+ };
17
+
18
+ /** Props for the EnvironmentNav component. */
19
+ interface EnvironmentNavProps {
20
+ /** List of all environments to display in the nav. */
21
+ environments: Environment[];
22
+ }
23
+
24
+ /** Vertical nav rail listing environments with status dots. */
25
+ export function EnvironmentNav({ environments }: EnvironmentNavProps): JSX.Element {
26
+ const navigate = useAppNavigate();
27
+ const tabListRef = useRef<HTMLElement>(null);
28
+
29
+ const envMatch = useMatch("/environments/:environmentId");
30
+ const editMatch = useMatch("/environments/:environmentId/edit");
31
+ const workspaceMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId");
32
+ const workspaceSubMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/*");
33
+ const rawId = envMatch?.params.environmentId ?? editMatch?.params.environmentId
34
+ ?? workspaceMatch?.params.environmentId ?? workspaceSubMatch?.params.environmentId;
35
+ /** Filter out the "new" pseudo-ID so /environments/new doesn't highlight a real tab. */
36
+ const activeId = rawId === "new" ? undefined : rawId;
37
+
38
+ const handleClick = useCallback((envId: string) => {
39
+ navigate(environmentUrl(envId));
40
+ }, [navigate]);
41
+
42
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLElement>) => {
43
+ const buttons = tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
44
+ if (!buttons || buttons.length === 0) {
45
+ return;
46
+ }
47
+ const focusedIndex = Array.from(buttons).findIndex((b) => b === document.activeElement);
48
+ const currentIndex = focusedIndex >= 0 ? focusedIndex : environments.findIndex((env) => env.id === activeId);
49
+ let nextIndex = currentIndex;
50
+
51
+ if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
52
+ e.preventDefault();
53
+ nextIndex = (currentIndex + 1) % buttons.length;
54
+ } else if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
55
+ e.preventDefault();
56
+ nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
57
+ } else if (e.key === "Home") {
58
+ e.preventDefault();
59
+ nextIndex = 0;
60
+ } else if (e.key === "End") {
61
+ e.preventDefault();
62
+ nextIndex = buttons.length - 1;
63
+ } else {
64
+ return;
65
+ }
66
+
67
+ if (nextIndex < environments.length) {
68
+ navigate(environmentUrl(environments[nextIndex].id));
69
+ }
70
+ buttons[nextIndex].focus();
71
+ }, [activeId, environments, navigate]);
72
+
73
+ /** When no environment is selected, the first tab should be focusable. */
74
+ const focusableId = activeId ?? (environments.length > 0 ? environments[0].id : undefined);
75
+
76
+ return (
77
+ <div className={styles.nav} data-testid="environment-nav">
78
+ <nav
79
+ ref={tabListRef}
80
+ role="tablist"
81
+ aria-orientation="vertical"
82
+ aria-label="Environments"
83
+ onKeyDown={handleKeyDown}
84
+ >
85
+ {environments.map((env) => {
86
+ const isActive = env.id === activeId;
87
+ const isFocusable = env.id === focusableId;
88
+ const statusColor = STATUS_COLORS[env.status] || "var(--text-tertiary)";
89
+ const isConnected = env.status === "connected";
90
+ return (
91
+ <button
92
+ key={env.id}
93
+ role="tab"
94
+ type="button"
95
+ aria-selected={isActive}
96
+ tabIndex={isFocusable ? 0 : -1}
97
+ className={`${styles.tab} ${isActive ? styles.tabActive : ""}`}
98
+ onClick={() => handleClick(env.id)}
99
+ data-testid="env-nav-item"
100
+ >
101
+ <span
102
+ className={`${styles.statusDot} ${isConnected ? styles.pulse : ""}`}
103
+ style={{ color: statusColor }}
104
+ aria-hidden="true"
105
+ >
106
+ <Circle size={ICON_XS} fill="currentColor" />
107
+ </span>
108
+ <span className={styles.tabLabel} title={env.displayName || env.id}>
109
+ {env.displayName || env.id}
110
+ </span>
111
+ </button>
112
+ );
113
+ })}
114
+ </nav>
115
+
116
+ <button
117
+ type="button"
118
+ className={styles.addButton}
119
+ onClick={() => navigate(NEW_ENVIRONMENT_URL)}
120
+ title="Add environment"
121
+ data-testid="env-nav-add"
122
+ >
123
+ + Add Environment
124
+ </button>
125
+
126
+ {environments.length === 0 && (
127
+ <div className={styles.empty}>
128
+ No environments yet.
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,126 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ .nav {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: var(--space-xs);
7
+ width: 100%;
8
+ padding: var(--space-md);
9
+ overflow-y: auto;
10
+
11
+ @include mobile {
12
+ flex-direction: row;
13
+ width: 100%;
14
+ min-width: unset;
15
+ border-right: none;
16
+ border-bottom: 1px solid var(--border-subtle);
17
+ overflow-x: auto;
18
+ overflow-y: hidden;
19
+ padding: var(--space-xs) var(--space-sm);
20
+ gap: 0;
21
+ flex-wrap: nowrap;
22
+ }
23
+ }
24
+
25
+ .categoryPills {
26
+ display: flex;
27
+ gap: var(--space-xs);
28
+ flex-wrap: wrap;
29
+ padding-bottom: var(--space-sm);
30
+ border-bottom: 1px solid var(--border-subtle);
31
+ margin-bottom: var(--space-xs);
32
+ }
33
+
34
+ .categoryPill {
35
+ font-size: 10px;
36
+ font-weight: var(--font-weight-bold);
37
+ text-transform: uppercase;
38
+ padding: 1px var(--space-xs);
39
+ border-radius: var(--radius-full);
40
+ background: var(--bg-elevated);
41
+ }
42
+
43
+ .tab {
44
+ display: flex;
45
+ align-items: flex-start;
46
+ gap: var(--space-sm);
47
+ padding: var(--space-sm) var(--space-md);
48
+ border: none;
49
+ border-left: 3px solid transparent;
50
+ border-radius: var(--radius-md);
51
+ background: transparent;
52
+ color: var(--text-secondary);
53
+ font-size: var(--font-size-sm);
54
+ font-family: var(--font-ui);
55
+ cursor: pointer;
56
+ transition: background var(--transition-fast),
57
+ color var(--transition-fast),
58
+ border-color var(--transition-fast);
59
+ text-align: left;
60
+ width: 100%;
61
+
62
+ &:hover {
63
+ background: var(--bg-overlay);
64
+ color: var(--text-primary);
65
+ }
66
+
67
+ &:focus-visible {
68
+ outline: 2px solid var(--accent-blue);
69
+ outline-offset: -2px;
70
+ }
71
+
72
+ @include mobile {
73
+ border-left: none;
74
+ border-bottom: 2px solid transparent;
75
+ white-space: nowrap;
76
+ flex-shrink: 0;
77
+ padding: var(--space-xs) var(--space-sm);
78
+ font-size: var(--font-size-xs);
79
+ width: auto;
80
+ border-radius: 0;
81
+ }
82
+ }
83
+
84
+ .tabActive {
85
+ border-left-color: var(--accent-blue);
86
+ background: var(--bg-overlay);
87
+ color: var(--text-primary);
88
+ font-weight: var(--font-weight-medium);
89
+
90
+ @include mobile {
91
+ border-left-color: transparent;
92
+ border-bottom-color: var(--accent-blue);
93
+ }
94
+ }
95
+
96
+ .tabContent {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 2px;
100
+ min-width: 0;
101
+ }
102
+
103
+ .tabLabel {
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
108
+
109
+ .tabMeta {
110
+ font-size: var(--font-size-xs);
111
+ color: var(--text-tertiary);
112
+ }
113
+
114
+ .categoryDot {
115
+ flex-shrink: 0;
116
+ font-size: var(--font-size-xs);
117
+ line-height: 1;
118
+ margin-top: 3px;
119
+ }
120
+
121
+ .empty {
122
+ padding: var(--space-md);
123
+ font-size: var(--font-size-sm);
124
+ color: var(--text-tertiary);
125
+ text-align: center;
126
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Sidebar navigation for the Findings pages.
3
+ *
4
+ * Displays a list of findings with category pills and relative timestamps.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import { useCallback, useMemo, useRef, type JSX, type KeyboardEvent } from "react";
10
+ import { Circle } from "lucide-react";
11
+ import { ICON_XS } from "../../utils/iconSize.js";
12
+ import { useMatch } from "react-router";
13
+ import type { FindingData } from "../../hooks/types.js";
14
+ import { findingUrl, useAppNavigate } from "../../utils/navigation.js";
15
+ import { formatRelativeTime } from "../../utils/time.js";
16
+ import { getCategoryColor } from "../../utils/findingCategory.js";
17
+ import styles from "./FindingsNav.module.scss";
18
+
19
+ /** Props for the FindingsNav component. */
20
+ interface FindingsNavProps {
21
+ /** All loaded findings to display. */
22
+ findings: FindingData[];
23
+ /** Optional workspace ID for scoped navigation. */
24
+ workspaceId?: string;
25
+ /** Optional environment ID for scoped navigation. */
26
+ environmentId?: string;
27
+ }
28
+
29
+ /** Sidebar nav listing findings with category badges and relative timestamps. */
30
+ export function FindingsNav({ findings, workspaceId, environmentId }: FindingsNavProps): JSX.Element {
31
+ const navigate = useAppNavigate();
32
+ const tabListRef = useRef<HTMLElement>(null);
33
+
34
+ // Match both global and workspace-scoped finding detail routes.
35
+ const globalMatch = useMatch("/findings/:findingId");
36
+ const scopedMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/findings/:findingId");
37
+ const activeFindingId = globalMatch?.params.findingId ?? scopedMatch?.params.findingId;
38
+
39
+ /** Unique categories derived from the current findings list. */
40
+ const categories = useMemo(() => {
41
+ const cats = new Set(findings.map((f) => f.category).filter(Boolean));
42
+ return Array.from(cats).sort();
43
+ }, [findings]);
44
+
45
+ const handleClick = useCallback((findingId: string) => {
46
+ navigate(findingUrl(findingId, workspaceId, environmentId));
47
+ }, [navigate, workspaceId, environmentId]);
48
+
49
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLElement>) => {
50
+ const buttons = tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
51
+ if (!buttons || buttons.length === 0) {
52
+ return;
53
+ }
54
+ const focusedIndex = Array.from(buttons).findIndex((b) => b === document.activeElement);
55
+ const currentIndex = focusedIndex >= 0 ? focusedIndex : findings.findIndex((f) => f.id === activeFindingId);
56
+ let nextIndex = currentIndex;
57
+
58
+ if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
59
+ e.preventDefault();
60
+ nextIndex = (currentIndex + 1) % buttons.length;
61
+ } else if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
62
+ e.preventDefault();
63
+ nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
64
+ } else if (e.key === "Home") {
65
+ e.preventDefault();
66
+ nextIndex = 0;
67
+ } else if (e.key === "End") {
68
+ e.preventDefault();
69
+ nextIndex = buttons.length - 1;
70
+ } else {
71
+ return;
72
+ }
73
+
74
+ if (nextIndex < findings.length) {
75
+ navigate(findingUrl(findings[nextIndex].id, workspaceId, environmentId));
76
+ }
77
+ buttons[nextIndex].focus();
78
+ }, [activeFindingId, findings, navigate, workspaceId, environmentId]);
79
+
80
+ const focusableId = activeFindingId ?? (findings.length > 0 ? findings[0].id : undefined);
81
+
82
+ return (
83
+ <div className={styles.nav} data-testid="findings-nav">
84
+ {categories.length > 1 && (
85
+ <div className={styles.categoryPills} data-testid="findings-nav-categories">
86
+ {categories.map((cat) => (
87
+ <span
88
+ key={cat}
89
+ className={styles.categoryPill}
90
+ style={{ color: getCategoryColor(cat).text }}
91
+ >
92
+ {cat}
93
+ </span>
94
+ ))}
95
+ </div>
96
+ )}
97
+
98
+ <nav
99
+ ref={tabListRef}
100
+ role="tablist"
101
+ aria-orientation="vertical"
102
+ aria-label="Findings"
103
+ onKeyDown={handleKeyDown}
104
+ >
105
+ {findings.map((f) => {
106
+ const isActive = f.id === activeFindingId;
107
+ const isFocusable = f.id === focusableId;
108
+ return (
109
+ <button
110
+ key={f.id}
111
+ role="tab"
112
+ type="button"
113
+ aria-selected={isActive}
114
+ tabIndex={isFocusable ? 0 : -1}
115
+ className={`${styles.tab} ${isActive ? styles.tabActive : ""}`}
116
+ onClick={() => handleClick(f.id)}
117
+ data-testid="finding-nav-item"
118
+ >
119
+ <span
120
+ className={styles.categoryDot}
121
+ style={{ color: getCategoryColor(f.category).text }}
122
+ aria-hidden="true"
123
+ >
124
+ <Circle size={ICON_XS} fill="currentColor" />
125
+ </span>
126
+ <span className={styles.tabContent}>
127
+ <span className={styles.tabLabel} title={f.title}>
128
+ {f.title}
129
+ </span>
130
+ <span className={styles.tabMeta} title={f.createdAt}>
131
+ {formatRelativeTime(f.createdAt)}
132
+ </span>
133
+ </span>
134
+ </button>
135
+ );
136
+ })}
137
+ </nav>
138
+
139
+ {findings.length === 0 && (
140
+ <div className={styles.empty}>
141
+ No findings yet. Agents will post discoveries here.
142
+ </div>
143
+ )}
144
+ </div>
145
+ );
146
+ }