@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,285 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildTaskAncestorChain,
4
+ buildHomeBreadcrumbs,
5
+ buildSettingsBreadcrumbs,
6
+ buildWorkspaceBreadcrumbs,
7
+ buildTaskBreadcrumbs,
8
+ buildNewTaskBreadcrumbs,
9
+ buildNewChatBreadcrumbs,
10
+ buildSessionBreadcrumbs,
11
+ buildFindingsBreadcrumbs,
12
+ buildFindingBreadcrumbs,
13
+ type BreadcrumbSegment,
14
+ } from "./breadcrumbs.js";
15
+ import type { Environment, TaskData, Workspace } from "../hooks/types.js";
16
+ import { getStatusBadgeClassKey, getStatusStyle } from "./taskStatus.js";
17
+
18
+ /** Creates a minimal Workspace for testing. */
19
+ function makeWorkspace(id: string, name: string, environmentId: string = "env-1"): Workspace {
20
+ return {
21
+ id,
22
+ name,
23
+ description: "",
24
+ repoUrl: "",
25
+ linkedEnvironmentIds: environmentId ? [environmentId] : [],
26
+ status: "active",
27
+ workingDirectory: "",
28
+ useWorktrees: true,
29
+ defaultPersonaId: "",
30
+ tokenBudget: 0,
31
+ costBudgetMillicents: 0,
32
+ createdAt: "",
33
+ updatedAt: "",
34
+ };
35
+ }
36
+
37
+ /** Creates a minimal Environment for testing. */
38
+ function makeEnvironment(id: string, displayName: string): Environment {
39
+ return {
40
+ id,
41
+ displayName,
42
+ adapterType: "local",
43
+ adapterConfig: "",
44
+ status: "connected",
45
+ bootstrapped: true,
46
+ };
47
+ }
48
+
49
+ /** Creates a minimal TaskData for testing. */
50
+ function makeTask(overrides: Partial<TaskData> & { id: string; workspaceId: string }): TaskData {
51
+ return {
52
+ title: overrides.id,
53
+ description: "",
54
+ status: "not_started",
55
+ branch: "",
56
+ latestSessionId: "",
57
+ dependsOn: [],
58
+ sortOrder: 0,
59
+ createdAt: "",
60
+ parentTaskId: "",
61
+ depth: 0,
62
+ childTaskIds: [],
63
+ canDecompose: false,
64
+ defaultPersonaId: "",
65
+ workpad: "",
66
+ tokenBudget: 0,
67
+ costBudgetMillicents: 0,
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ describe("buildTaskAncestorChain", () => {
73
+ it("returns single task when no parent", () => {
74
+ const task: TaskData = makeTask({ id: "t1", workspaceId: "p1", title: "Root" });
75
+ const byId: Map<string, TaskData> = new Map([["t1", task]]);
76
+
77
+ const chain: TaskData[] = buildTaskAncestorChain("t1", byId);
78
+ expect(chain).toHaveLength(1);
79
+ expect(chain[0].id).toBe("t1");
80
+ });
81
+
82
+ it("returns ancestor chain root-first", () => {
83
+ const root: TaskData = makeTask({ id: "t1", workspaceId: "p1", title: "Root" });
84
+ const child: TaskData = makeTask({ id: "t2", workspaceId: "p1", title: "Child", parentTaskId: "t1", depth: 1 });
85
+ const grandchild: TaskData = makeTask({ id: "t3", workspaceId: "p1", title: "Grandchild", parentTaskId: "t2", depth: 2 });
86
+ const byId: Map<string, TaskData> = new Map([["t1", root], ["t2", child], ["t3", grandchild]]);
87
+
88
+ const chain: TaskData[] = buildTaskAncestorChain("t3", byId);
89
+ expect(chain).toHaveLength(3);
90
+ expect(chain[0].title).toBe("Root");
91
+ expect(chain[1].title).toBe("Child");
92
+ expect(chain[2].title).toBe("Grandchild");
93
+ });
94
+
95
+ it("handles missing parent gracefully", () => {
96
+ const task: TaskData = makeTask({ id: "t1", workspaceId: "p1", title: "Orphan", parentTaskId: "missing" });
97
+ const byId: Map<string, TaskData> = new Map([["t1", task]]);
98
+
99
+ const chain: TaskData[] = buildTaskAncestorChain("t1", byId);
100
+ expect(chain).toHaveLength(1);
101
+ expect(chain[0].title).toBe("Orphan");
102
+ });
103
+
104
+ it("guards against cycles", () => {
105
+ const t1: TaskData = makeTask({ id: "t1", workspaceId: "p1", parentTaskId: "t2" });
106
+ const t2: TaskData = makeTask({ id: "t2", workspaceId: "p1", parentTaskId: "t1" });
107
+ const byId: Map<string, TaskData> = new Map([["t1", t1], ["t2", t2]]);
108
+
109
+ const chain: TaskData[] = buildTaskAncestorChain("t1", byId);
110
+ // Should not infinite-loop; returns at most the 2 tasks
111
+ expect(chain.length).toBeLessThanOrEqual(2);
112
+ });
113
+ });
114
+
115
+ describe("breadcrumb builders", () => {
116
+ it("home returns Home as non-clickable", () => {
117
+ const segments: BreadcrumbSegment[] = buildHomeBreadcrumbs();
118
+ expect(segments).toHaveLength(1);
119
+ expect(segments[0].label).toBe("Home");
120
+ expect(segments[0].url).toBeUndefined();
121
+ });
122
+
123
+ it("settings returns Home > Settings when no tab specified", () => {
124
+ const segments: BreadcrumbSegment[] = buildSettingsBreadcrumbs();
125
+ expect(segments).toHaveLength(2);
126
+ expect(segments[0].label).toBe("Home");
127
+ expect(segments[0].url).toBe("/");
128
+ expect(segments[1].label).toBe("Settings");
129
+ expect(segments[1].url).toBeUndefined();
130
+ });
131
+
132
+ it("settings with tab returns Home > Settings > TabLabel", () => {
133
+ const segments: BreadcrumbSegment[] = buildSettingsBreadcrumbs("Environments");
134
+ expect(segments).toHaveLength(3);
135
+ expect(segments[0].label).toBe("Home");
136
+ expect(segments[0].url).toBe("/");
137
+ expect(segments[1].label).toBe("Settings");
138
+ expect(segments[1].url).toBe("/settings");
139
+ expect(segments[2].label).toBe("Environments");
140
+ expect(segments[2].url).toBeUndefined();
141
+ });
142
+
143
+ it("workspace returns Home > Environments > Env > WorkspaceName", () => {
144
+ const workspaces: Workspace[] = [makeWorkspace("p1", "My Workspace", "env-1")];
145
+ const environments: Environment[] = [makeEnvironment("env-1", "Local Dev")];
146
+ const segments: BreadcrumbSegment[] = buildWorkspaceBreadcrumbs("p1", "env-1", workspaces, environments);
147
+ expect(segments).toHaveLength(4);
148
+ expect(segments[0].label).toBe("Home");
149
+ expect(segments[0].url).toBe("/");
150
+ expect(segments[1].label).toBe("Environments");
151
+ expect(segments[1].url).toBe("/environments");
152
+ expect(segments[2].label).toBe("Local Dev");
153
+ expect(segments[2].url).toBe("/environments/env-1");
154
+ expect(segments[3].label).toBe("My Workspace");
155
+ expect(segments[3].url).toBeUndefined();
156
+ });
157
+
158
+ it("task returns Home > Environments > Env > Workspace > Task", () => {
159
+ const workspaces: Workspace[] = [makeWorkspace("p1", "My Workspace", "env-1")];
160
+ const environments: Environment[] = [makeEnvironment("env-1", "Local Dev")];
161
+ const task: TaskData = makeTask({ id: "t1", workspaceId: "p1", title: "My Task" });
162
+ const byId: Map<string, TaskData> = new Map([["t1", task]]);
163
+
164
+ const segments: BreadcrumbSegment[] = buildTaskBreadcrumbs("t1", "env-1", workspaces, environments, byId);
165
+ expect(segments).toHaveLength(5);
166
+ expect(segments[0].label).toBe("Home");
167
+ expect(segments[0].url).toBe("/");
168
+ expect(segments[1].label).toBe("Environments");
169
+ expect(segments[1].url).toBe("/environments");
170
+ expect(segments[2].label).toBe("Local Dev");
171
+ expect(segments[2].url).toBe("/environments/env-1");
172
+ expect(segments[3].label).toBe("My Workspace");
173
+ expect(segments[3].url).toBe("/environments/env-1/workspaces/p1");
174
+ expect(segments[4].label).toBe("My Task");
175
+ expect(segments[4].url).toBeUndefined();
176
+ });
177
+
178
+ it("nested task includes ancestor chain", () => {
179
+ const workspaces: Workspace[] = [makeWorkspace("p1", "WS", "env-1")];
180
+ const environments: Environment[] = [makeEnvironment("env-1", "Local Dev")];
181
+ const root: TaskData = makeTask({ id: "t1", workspaceId: "p1", title: "Root" });
182
+ const child: TaskData = makeTask({ id: "t2", workspaceId: "p1", title: "Child", parentTaskId: "t1", depth: 1 });
183
+ const grandchild: TaskData = makeTask({ id: "t3", workspaceId: "p1", title: "Grandchild", parentTaskId: "t2", depth: 2 });
184
+ const byId: Map<string, TaskData> = new Map([["t1", root], ["t2", child], ["t3", grandchild]]);
185
+
186
+ const segments: BreadcrumbSegment[] = buildTaskBreadcrumbs("t3", "env-1", workspaces, environments, byId);
187
+ expect(segments).toHaveLength(7); // Home > Environments > Env > WS > Root > Child > Grandchild
188
+ expect(segments[0].label).toBe("Home");
189
+ expect(segments[1].label).toBe("Environments");
190
+ expect(segments[2].label).toBe("Local Dev");
191
+ expect(segments[3].label).toBe("WS");
192
+ expect(segments[3].url).toBe("/environments/env-1/workspaces/p1");
193
+ expect(segments[4].label).toBe("Root");
194
+ expect(segments[4].url).toBe("/environments/env-1/workspaces/p1/tasks/t1");
195
+ expect(segments[5].label).toBe("Child");
196
+ expect(segments[5].url).toBe("/environments/env-1/workspaces/p1/tasks/t2");
197
+ expect(segments[6].label).toBe("Grandchild");
198
+ expect(segments[6].url).toBeUndefined();
199
+ });
200
+
201
+ it("new task with parentTaskId shows ancestor chain", () => {
202
+ const workspaces: Workspace[] = [makeWorkspace("p1", "WS", "env-1")];
203
+ const environments: Environment[] = [makeEnvironment("env-1", "Local Dev")];
204
+ const parent: TaskData = makeTask({ id: "t1", workspaceId: "p1", title: "Parent" });
205
+ const byId: Map<string, TaskData> = new Map([["t1", parent]]);
206
+
207
+ const segments: BreadcrumbSegment[] = buildNewTaskBreadcrumbs("p1", "env-1", "t1", workspaces, environments, byId);
208
+ expect(segments).toHaveLength(6); // Home > Environments > Env > WS > Parent > New Task
209
+ expect(segments[1].label).toBe("Environments");
210
+ expect(segments[2].label).toBe("Local Dev");
211
+ expect(segments[3].label).toBe("WS");
212
+ expect(segments[4].label).toBe("Parent");
213
+ expect(segments[5].label).toBe("New Task");
214
+ expect(segments[5].url).toBeUndefined();
215
+ });
216
+
217
+ it("new chat returns Home > New Chat", () => {
218
+ const segments: BreadcrumbSegment[] = buildNewChatBreadcrumbs();
219
+ expect(segments).toHaveLength(2);
220
+ expect(segments[1].label).toBe("New Chat");
221
+ });
222
+
223
+ it("session returns Home > Session prefix", () => {
224
+ const segments: BreadcrumbSegment[] = buildSessionBreadcrumbs("abcdef1234567890");
225
+ expect(segments).toHaveLength(2);
226
+ expect(segments[1].label).toBe("Session abcdef12");
227
+ });
228
+ });
229
+
230
+ describe("findings breadcrumb builders", () => {
231
+ it("buildFindingsBreadcrumbs returns Home > Findings (non-clickable)", () => {
232
+ const segments: BreadcrumbSegment[] = buildFindingsBreadcrumbs();
233
+ expect(segments).toHaveLength(2);
234
+ expect(segments[0].label).toBe("Home");
235
+ expect(segments[0].url).toBe("/");
236
+ expect(segments[1].label).toBe("Findings");
237
+ expect(segments[1].url).toBeUndefined();
238
+ });
239
+
240
+ it("buildFindingBreadcrumbs without scope returns Home > Findings > Title", () => {
241
+ const segments: BreadcrumbSegment[] = buildFindingBreadcrumbs("My Finding", undefined, undefined, [], []);
242
+ expect(segments).toHaveLength(3);
243
+ expect(segments[0].label).toBe("Home");
244
+ expect(segments[0].url).toBe("/");
245
+ expect(segments[1].label).toBe("Findings");
246
+ expect(segments[1].url).toBe("/findings");
247
+ expect(segments[2].label).toBe("My Finding");
248
+ expect(segments[2].url).toBeUndefined();
249
+ });
250
+
251
+ it("buildFindingBreadcrumbs with workspace scope returns full chain", () => {
252
+ const workspaces: Workspace[] = [makeWorkspace("ws1", "My Workspace", "env1")];
253
+ const environments: Environment[] = [makeEnvironment("env1", "Local Dev")];
254
+ const segments: BreadcrumbSegment[] = buildFindingBreadcrumbs("My Finding", "ws1", "env1", workspaces, environments);
255
+ expect(segments).toHaveLength(6);
256
+ expect(segments[0].label).toBe("Home");
257
+ expect(segments[0].url).toBe("/");
258
+ expect(segments[1].label).toBe("Environments");
259
+ expect(segments[1].url).toBe("/environments");
260
+ expect(segments[2].label).toBe("Local Dev");
261
+ expect(segments[2].url).toBe("/environments/env1");
262
+ expect(segments[3].label).toBe("My Workspace");
263
+ expect(segments[3].url).toBe("/environments/env1/workspaces/ws1");
264
+ expect(segments[4].label).toBe("Findings");
265
+ expect(segments[4].url).toBe("/environments/env1/workspaces/ws1/findings");
266
+ expect(segments[5].label).toBe("My Finding");
267
+ expect(segments[5].url).toBeUndefined();
268
+ });
269
+ });
270
+
271
+ describe("task status helpers", () => {
272
+ it("maps legacy task statuses to canonical styles", () => {
273
+ expect(getStatusStyle("pending").label).toBe("Not Started");
274
+ expect(getStatusStyle("in_progress").label).toBe("Working");
275
+ expect(getStatusStyle("review").label).toBe("Paused");
276
+ expect(getStatusStyle("done").label).toBe("Complete");
277
+ });
278
+
279
+ it("maps legacy task statuses to canonical badge classes", () => {
280
+ expect(getStatusBadgeClassKey("pending")).toBe("statusPending");
281
+ expect(getStatusBadgeClassKey("in_progress")).toBe("statusInProgress");
282
+ expect(getStatusBadgeClassKey("waiting_input")).toBe("statusWaitingInput");
283
+ expect(getStatusBadgeClassKey("done")).toBe("statusDone");
284
+ });
285
+ });
@@ -0,0 +1,222 @@
1
+ import type { Environment, TaskData, Workspace } from "../hooks/types.js";
2
+ import { ENVIRONMENTS_URL, environmentUrl, FINDINGS_URL, findingsUrl, HOME_URL, SETTINGS_URL, taskUrl, workspaceUrl } from "./navigation.js";
3
+
4
+ /** A single segment in the breadcrumb trail. */
5
+ export interface BreadcrumbSegment {
6
+ /** Display label for this segment. */
7
+ label: string;
8
+ /** URL to navigate to when clicked, or undefined for the current (non-clickable) segment. */
9
+ url: string | undefined;
10
+ }
11
+
12
+ /**
13
+ * Walks up the `parentTaskId` chain from a task to build an ordered list
14
+ * of ancestor tasks (root-first). Includes the task itself as the last element.
15
+ */
16
+ export function buildTaskAncestorChain(
17
+ taskId: string,
18
+ tasksById: Map<string, TaskData>,
19
+ ): TaskData[] {
20
+ const ancestors: TaskData[] = [];
21
+ let currentId: string | undefined = taskId;
22
+ const visited = new Set<string>();
23
+
24
+ while (currentId && tasksById.has(currentId)) {
25
+ if (visited.has(currentId)) {
26
+ break; // guard against cycles
27
+ }
28
+ visited.add(currentId);
29
+ const ancestor: TaskData = tasksById.get(currentId)!;
30
+ ancestors.unshift(ancestor);
31
+ currentId = ancestor.parentTaskId || undefined;
32
+ }
33
+ return ancestors;
34
+ }
35
+
36
+ /** Home breadcrumb segment. */
37
+ const HOME_SEGMENT: BreadcrumbSegment = { label: "Home", url: HOME_URL };
38
+
39
+ /** Build breadcrumbs for the home page. */
40
+ export function buildHomeBreadcrumbs(): BreadcrumbSegment[] {
41
+ return [{ label: "Home", url: undefined }];
42
+ }
43
+
44
+ /** Build breadcrumbs for the settings page, optionally showing the active tab. */
45
+ export function buildSettingsBreadcrumbs(tabLabel?: string): BreadcrumbSegment[] {
46
+ if (tabLabel) {
47
+ return [HOME_SEGMENT, { label: "Settings", url: SETTINGS_URL }, { label: tabLabel, url: undefined }];
48
+ }
49
+ return [HOME_SEGMENT, { label: "Settings", url: undefined }];
50
+ }
51
+
52
+ /** Environments breadcrumb segment. */
53
+ const ENVIRONMENTS_SEGMENT: BreadcrumbSegment = { label: "Environments", url: ENVIRONMENTS_URL };
54
+
55
+ /** Build breadcrumbs for the environments landing page. */
56
+ export function buildEnvironmentsBreadcrumbs(): BreadcrumbSegment[] {
57
+ return [HOME_SEGMENT, { label: "Environments", url: undefined }];
58
+ }
59
+
60
+ /** Build breadcrumbs for the new environment page. */
61
+ export function buildNewEnvironmentBreadcrumbs(): BreadcrumbSegment[] {
62
+ return [HOME_SEGMENT, ENVIRONMENTS_SEGMENT, { label: "New Environment", url: undefined }];
63
+ }
64
+
65
+ /** Build breadcrumbs for the new chat page. */
66
+ export function buildNewChatBreadcrumbs(): BreadcrumbSegment[] {
67
+ return [HOME_SEGMENT, { label: "New Chat", url: undefined }];
68
+ }
69
+
70
+ /** Build breadcrumbs for a session page. */
71
+ export function buildSessionBreadcrumbs(sessionId: string): BreadcrumbSegment[] {
72
+ return [HOME_SEGMENT, { label: `Session ${sessionId.slice(0, 8)}`, url: undefined }];
73
+ }
74
+
75
+ /** Build breadcrumbs for a workspace page: Home > Environments > [Env] > [Workspace]. */
76
+ export function buildWorkspaceBreadcrumbs(
77
+ workspaceId: string,
78
+ environmentId: string,
79
+ workspaces: Workspace[],
80
+ environments: Environment[],
81
+ ): BreadcrumbSegment[] {
82
+ const workspace = workspaces.find((p) => p.id === workspaceId);
83
+ const environment = environments.find((e) => e.id === environmentId);
84
+ return [
85
+ HOME_SEGMENT,
86
+ ENVIRONMENTS_SEGMENT,
87
+ { label: environment?.displayName ?? "Environment", url: environmentUrl(environmentId) },
88
+ { label: workspace?.name ?? "Workspace", url: undefined },
89
+ ];
90
+ }
91
+
92
+ /** Build breadcrumbs for a task page: Home > Environments > [Env] > [Workspace] > [ancestors...] > [Task]. */
93
+ export function buildTaskBreadcrumbs(
94
+ taskId: string,
95
+ routeEnvironmentId: string | undefined,
96
+ workspaces: Workspace[],
97
+ environments: Environment[],
98
+ tasksById: Map<string, TaskData>,
99
+ ): BreadcrumbSegment[] {
100
+ const ancestors = buildTaskAncestorChain(taskId, tasksById);
101
+ const task = tasksById.get(taskId);
102
+ const taskWorkspaceId = task?.workspaceId;
103
+ const workspace = taskWorkspaceId ? workspaces.find((p) => p.id === taskWorkspaceId) : undefined;
104
+ const environmentId = routeEnvironmentId ?? workspace?.linkedEnvironmentIds[0];
105
+ const environment = environmentId ? environments.find((e) => e.id === environmentId) : undefined;
106
+
107
+ const segments: BreadcrumbSegment[] = [HOME_SEGMENT];
108
+
109
+ if (environment && environmentId) {
110
+ segments.push(ENVIRONMENTS_SEGMENT);
111
+ segments.push({ label: environment.displayName, url: environmentUrl(environmentId) });
112
+ }
113
+
114
+ if (workspace && environmentId) {
115
+ segments.push({
116
+ label: workspace.name,
117
+ url: workspaceUrl(workspace.id, environmentId),
118
+ });
119
+ }
120
+
121
+ // Add ancestor tasks (all except the last, which is the current task)
122
+ for (let i = 0; i < ancestors.length - 1; i++) {
123
+ segments.push({
124
+ label: ancestors[i].title,
125
+ url: taskUrl(ancestors[i].id, undefined, taskWorkspaceId, environmentId),
126
+ });
127
+ }
128
+
129
+ // Current task (non-clickable)
130
+ const currentTask = ancestors[ancestors.length - 1];
131
+ segments.push({
132
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- currentTask may be undefined if ancestors is empty
133
+ label: currentTask?.title ?? taskId,
134
+ url: undefined,
135
+ });
136
+
137
+ return segments;
138
+ }
139
+
140
+ /** Build breadcrumbs for the new task page. */
141
+ export function buildNewTaskBreadcrumbs(
142
+ workspaceIdParam: string,
143
+ environmentId: string | undefined,
144
+ parentTaskId: string | undefined,
145
+ workspaces: Workspace[],
146
+ environments: Environment[],
147
+ tasksById: Map<string, TaskData>,
148
+ ): BreadcrumbSegment[] {
149
+ const workspace = workspaces.find((p) => p.id === workspaceIdParam);
150
+ const envId = environmentId ?? workspace?.linkedEnvironmentIds[0];
151
+ const environment = envId ? environments.find((e) => e.id === envId) : undefined;
152
+
153
+ const segments: BreadcrumbSegment[] = [HOME_SEGMENT];
154
+
155
+ if (environment && envId) {
156
+ segments.push(ENVIRONMENTS_SEGMENT);
157
+ segments.push({ label: environment.displayName, url: environmentUrl(envId) });
158
+ }
159
+
160
+ if (envId) {
161
+ segments.push({
162
+ label: workspace?.name ?? "Workspace",
163
+ url: workspaceUrl(workspaceIdParam, envId),
164
+ });
165
+ } else {
166
+ segments.push({
167
+ label: workspace?.name ?? "Workspace",
168
+ url: undefined,
169
+ });
170
+ }
171
+
172
+ // If creating a child task, show parent ancestors
173
+ if (parentTaskId) {
174
+ const ancestors = buildTaskAncestorChain(parentTaskId, tasksById);
175
+ for (const ancestor of ancestors) {
176
+ segments.push({
177
+ label: ancestor.title,
178
+ url: taskUrl(ancestor.id, undefined, workspaceIdParam, envId),
179
+ });
180
+ }
181
+ }
182
+
183
+ segments.push({ label: "New Task", url: undefined });
184
+ return segments;
185
+ }
186
+
187
+ /** Findings breadcrumb segment. */
188
+ const FINDINGS_SEGMENT: BreadcrumbSegment = { label: "Findings", url: FINDINGS_URL };
189
+
190
+ /** Build breadcrumbs for the findings landing page. */
191
+ export function buildFindingsBreadcrumbs(): BreadcrumbSegment[] {
192
+ return [HOME_SEGMENT, { label: "Findings", url: undefined }];
193
+ }
194
+
195
+ /** Build breadcrumbs for a finding detail page, optionally scoped to a workspace. */
196
+ export function buildFindingBreadcrumbs(
197
+ findingTitle: string,
198
+ workspaceId: string | undefined,
199
+ environmentId: string | undefined,
200
+ workspaces: Workspace[],
201
+ environments: Environment[],
202
+ ): BreadcrumbSegment[] {
203
+ const segments: BreadcrumbSegment[] = [HOME_SEGMENT];
204
+
205
+ if (workspaceId && environmentId) {
206
+ const workspace = workspaces.find((p) => p.id === workspaceId);
207
+ const environment = environments.find((e) => e.id === environmentId);
208
+ segments.push(ENVIRONMENTS_SEGMENT);
209
+ if (environment) {
210
+ segments.push({ label: environment.displayName, url: environmentUrl(environmentId) });
211
+ }
212
+ if (workspace) {
213
+ segments.push({ label: workspace.name, url: workspaceUrl(workspaceId, environmentId) });
214
+ }
215
+ segments.push({ label: "Findings", url: findingsUrl(workspaceId, environmentId) });
216
+ } else {
217
+ segments.push(FINDINGS_SEGMENT);
218
+ }
219
+
220
+ segments.push({ label: findingTitle, url: undefined });
221
+ return segments;
222
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Environment, Session, TaskData, Workspace } from "../hooks/types.js";
3
+ import { computeKpis, getAttentionTasks, getWorkspaceSnapshots } from "./dashboard.js";
4
+
5
+ function makeWorkspace(id: string, name: string, environmentId: string = "env-1"): Workspace {
6
+ return {
7
+ id,
8
+ name,
9
+ description: "",
10
+ repoUrl: "",
11
+ linkedEnvironmentIds: environmentId ? [environmentId] : [],
12
+ status: "active",
13
+ workingDirectory: "",
14
+ useWorktrees: true,
15
+ defaultPersonaId: "",
16
+ tokenBudget: 0,
17
+ costBudgetMillicents: 0,
18
+ createdAt: "",
19
+ updatedAt: "",
20
+ };
21
+ }
22
+
23
+ function makeTask(overrides: Partial<TaskData> & { id: string }): TaskData {
24
+ const { id, ...rest } = overrides;
25
+ return {
26
+ id,
27
+ workspaceId: "ws-1",
28
+ title: id,
29
+ description: "",
30
+ status: "not_started",
31
+ branch: "",
32
+ latestSessionId: "",
33
+ dependsOn: [],
34
+ sortOrder: 0,
35
+ createdAt: "",
36
+ parentTaskId: "",
37
+ depth: 0,
38
+ childTaskIds: [],
39
+ canDecompose: false,
40
+ defaultPersonaId: "",
41
+ workpad: "",
42
+ tokenBudget: 0,
43
+ costBudgetMillicents: 0,
44
+ ...rest,
45
+ };
46
+ }
47
+
48
+ function makeSession(overrides: Partial<Session> & { id: string }): Session {
49
+ const { id, ...rest } = overrides;
50
+ return {
51
+ id,
52
+ environmentId: "env-1",
53
+ runtime: "stub",
54
+ status: "idle",
55
+ prompt: "prompt",
56
+ startedAt: "",
57
+ ...rest,
58
+ };
59
+ }
60
+
61
+ function makeEnvironment(id: string, displayName: string, status: string): Environment {
62
+ return {
63
+ id,
64
+ displayName,
65
+ adapterType: "local",
66
+ adapterConfig: "{}",
67
+ status,
68
+ bootstrapped: true,
69
+ };
70
+ }
71
+
72
+ describe("dashboard selectors", () => {
73
+ it("treats missing dependency tasks as blocked in KPI counts", () => {
74
+ const tasks: TaskData[] = [makeTask({ id: "task-1", dependsOn: ["missing-task"] })];
75
+
76
+ const kpis = computeKpis([], tasks, []);
77
+
78
+ expect(kpis.blockedTasks).toBe(1);
79
+ expect(kpis.attentionTasks).toBe(1);
80
+ });
81
+
82
+ it("orders attention tasks as failed, blocked, then paused", () => {
83
+ const workspaces: Workspace[] = [makeWorkspace("ws-1", "Alpha")];
84
+ const tasks: TaskData[] = [
85
+ makeTask({ id: "paused-task", title: "Paused task", status: "paused" }),
86
+ makeTask({ id: "dependency-source", title: "Dependency source", status: "working" }),
87
+ makeTask({ id: "blocked-task", title: "Blocked task", dependsOn: ["dependency-source"] }),
88
+ makeTask({ id: "failed-task", title: "Failed task", status: "failed" }),
89
+ ];
90
+
91
+ const attentionTasks = getAttentionTasks(tasks, workspaces);
92
+
93
+ expect(attentionTasks.map((entry) => entry.reason)).toEqual(["failed", "blocked", "paused"]);
94
+ expect(attentionTasks.map((entry) => entry.task.title)).toEqual([
95
+ "Failed task",
96
+ "Blocked task",
97
+ "Paused task",
98
+ ]);
99
+ });
100
+
101
+ it("builds workspace snapshots from grouped task stats", () => {
102
+ const workspaces: Workspace[] = [
103
+ makeWorkspace("ws-1", "Alpha", "env-1"),
104
+ makeWorkspace("ws-2", "Beta", "env-2"),
105
+ ];
106
+ const tasks: TaskData[] = [
107
+ makeTask({ id: "a-1", workspaceId: "ws-1", status: "complete" }),
108
+ makeTask({ id: "a-2", workspaceId: "ws-1", status: "working" }),
109
+ makeTask({ id: "a-3", workspaceId: "ws-1", status: "failed" }),
110
+ makeTask({ id: "b-1", workspaceId: "ws-2", status: "not_started" }),
111
+ makeTask({ id: "root-task", workspaceId: undefined, status: "working" }),
112
+ ];
113
+ const environments: Environment[] = [
114
+ makeEnvironment("env-1", "test-local", "connected"),
115
+ makeEnvironment("env-2", "backup", "connected"),
116
+ ];
117
+
118
+ const snapshots = getWorkspaceSnapshots(workspaces, tasks, environments);
119
+
120
+ expect(snapshots).toEqual([
121
+ {
122
+ workspace: workspaces[0],
123
+ totalTasks: 3,
124
+ completedTasks: 1,
125
+ workingTasks: 1,
126
+ failedTasks: 1,
127
+ },
128
+ {
129
+ workspace: workspaces[1],
130
+ totalTasks: 1,
131
+ completedTasks: 0,
132
+ workingTasks: 0,
133
+ failedTasks: 0,
134
+ },
135
+ ]);
136
+ });
137
+
138
+ it("counts active sessions and unhealthy environments", () => {
139
+ const sessions: Session[] = [
140
+ makeSession({ id: "session-1", status: "running" }),
141
+ makeSession({ id: "session-2", status: "idle" }),
142
+ makeSession({ id: "session-3", status: "waiting" }),
143
+ makeSession({ id: "session-4", status: "stopped" }),
144
+ ];
145
+ const environments: Environment[] = [
146
+ makeEnvironment("env-1", "primary", "connected"),
147
+ makeEnvironment("env-2", "secondary", "disconnected"),
148
+ makeEnvironment("env-3", "tertiary", "error"),
149
+ ];
150
+
151
+ const kpis = computeKpis(sessions, [], environments);
152
+
153
+ expect(kpis.activeSessions).toBe(3);
154
+ expect(kpis.unhealthyEnvironments).toBe(2);
155
+ });
156
+ });