@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,206 @@
1
+ import type { JSX } from "react";
2
+ import { useLocation, useMatch } from "react-router";
3
+ import type { Session, TaskData, Environment } from "../../hooks/types.js";
4
+ import { newTaskUrl, newChatUrl, useAppNavigate } from "../../utils/navigation.js";
5
+ import styles from "./BottomStatusBar.module.scss";
6
+
7
+ /**
8
+ * Thin, read-only status bar that shows contextual hints based on the current
9
+ * route and application state. Does NOT contain any form inputs or send/spawn
10
+ * actions — those live in {@link ChatInput} on each page.
11
+ *
12
+ * Returns an empty fragment when the current page is showing a ChatInput or
13
+ * when the route has no meaningful hint to display.
14
+ */
15
+ /** Props for the BottomStatusBar component. */
16
+ interface BottomStatusBarProps {
17
+ /** All sessions. */
18
+ sessions: Session[];
19
+ /** All tasks. */
20
+ tasks: TaskData[];
21
+ /** All environments. */
22
+ environments: Environment[];
23
+ }
24
+
25
+ export function BottomStatusBar({ sessions, tasks, environments }: BottomStatusBarProps): JSX.Element {
26
+ const navigate = useAppNavigate();
27
+ const location = useLocation();
28
+
29
+ // Match current route (both global and workspace-scoped task URLs)
30
+ const sessionMatch = useMatch("/sessions/:sessionId");
31
+ const taskMatch = useMatch("/tasks/:taskId");
32
+ const taskStreamMatch = useMatch("/tasks/:taskId/stream");
33
+ const taskFindingsMatch = useMatch("/tasks/:taskId/findings");
34
+ const taskEditMatch = useMatch("/tasks/:taskId/edit");
35
+ const wsTaskMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/tasks/:taskId");
36
+ const wsTaskStreamMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/tasks/:taskId/stream");
37
+ const wsTaskFindingsMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/tasks/:taskId/findings");
38
+ const wsTaskEditMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId/tasks/:taskId/edit");
39
+ const newChatMatch = useMatch("/sessions/new");
40
+ const workspaceMatch = useMatch("/environments/:environmentId/workspaces/:workspaceId");
41
+ const newTaskMatch = useMatch("/tasks/new");
42
+ const chatMatch = useMatch("/chat");
43
+ const emptyMatch = useMatch("/");
44
+ const settingsMatch = useMatch("/settings/*");
45
+
46
+ // Derive current page context
47
+ const sessionId = sessionMatch?.params.sessionId;
48
+ const taskId = taskMatch?.params.taskId ?? taskStreamMatch?.params.taskId ?? taskFindingsMatch?.params.taskId
49
+ ?? wsTaskMatch?.params.taskId ?? wsTaskStreamMatch?.params.taskId ?? wsTaskFindingsMatch?.params.taskId ?? wsTaskEditMatch?.params.taskId;
50
+ const wsMatch = wsTaskMatch ?? wsTaskStreamMatch ?? wsTaskFindingsMatch ?? wsTaskEditMatch;
51
+ const routeEnvironmentId = wsMatch?.params.environmentId ?? workspaceMatch?.params.environmentId;
52
+ const isEnvironments = location.pathname.startsWith("/environments") && !workspaceMatch && !wsMatch;
53
+ const isChat = !!chatMatch;
54
+ const isNewChat = !!newChatMatch;
55
+ const isWorkspace = !!workspaceMatch && !wsTaskMatch && !wsTaskStreamMatch && !wsTaskFindingsMatch && !wsTaskEditMatch;
56
+ const isNewTask = !!newTaskMatch;
57
+ const isTaskEdit = !!taskEditMatch || !!wsTaskEditMatch;
58
+ const isEmpty = !!emptyMatch && !isNewChat && !isWorkspace && !isNewTask;
59
+ const isSettings = !!settingsMatch;
60
+
61
+ // --- dashboard / settings / edit / new / environments / new_chat — empty ---
62
+ if (isEmpty || isSettings || isTaskEdit || isNewTask || isEnvironments || isNewChat) {
63
+ return <></>;
64
+ }
65
+
66
+ // --- /chat route — ChatInput handles input on the page; only show hint if no local env ---
67
+ if (isChat) {
68
+ const localEnv = environments.find((e) => e.adapterType === "local" && e.status === "connected");
69
+ if (!localEnv) {
70
+ return (
71
+ <div className={styles.bar}>
72
+ <span className={styles.hintText}>Add a local environment to start chatting</span>
73
+ </div>
74
+ );
75
+ }
76
+ return <></>;
77
+ }
78
+
79
+ // --- workspace mode (no specific task) ---
80
+ if (isWorkspace) {
81
+ return (
82
+ <div className={styles.bar}>
83
+ <span className={styles.hintText}>
84
+ Select a task or click + to create one
85
+ </span>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // --- task modes ---
91
+ if (taskId) {
92
+ const task = tasks.find((t) => t.id === taskId);
93
+ if (!task) {
94
+ return (
95
+ <div className={styles.bar}>
96
+ <span className={styles.hintText}>Loading...</span>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ const tasksById = new Map(tasks.map((t) => [t.id, t]));
102
+ const isTaskBlocked = task.dependsOn.some((depId) => {
103
+ const dep = tasksById.get(depId);
104
+ return dep !== undefined && dep.status !== "complete";
105
+ });
106
+
107
+ // Not started (blocked or unblocked)
108
+ if (task.status === "not_started") {
109
+ const blockerNames = isTaskBlocked
110
+ ? task.dependsOn
111
+ .map((depId) => tasksById.get(depId))
112
+ .filter((t) => t && t.status !== "complete")
113
+ .map((t) => t!.title)
114
+ : [];
115
+ return (
116
+ <div className={styles.bar}>
117
+ {isTaskBlocked ? (
118
+ <span className={styles.statusBlocked}>
119
+ Blocked by: {blockerNames.join(", ")}
120
+ </span>
121
+ ) : (
122
+ <span className={styles.hintText}>Use the buttons above to start or manage this task</span>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ // Working / paused — check if session is active
129
+ if (task.status === "working" || task.status === "paused") {
130
+ const taskSessionId = task.latestSessionId || undefined;
131
+ const taskSession = taskSessionId
132
+ ? sessions.find((s) => s.id === taskSessionId)
133
+ : undefined;
134
+ const isActive = taskSession && taskSession.status !== "stopped";
135
+
136
+ // Active session — ChatInput on the page handles this; return empty
137
+ if (isActive) {
138
+ return <></>;
139
+ }
140
+
141
+ return (
142
+ <div className={styles.bar}>
143
+ <span className={styles.hintText}>Waiting for agent...</span>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ // Complete
149
+ if (task.status === "complete") {
150
+ return (
151
+ <div className={styles.bar}>
152
+ <span className={`${styles.statusText} ${styles.statusCompleted}`}>
153
+ Task completed
154
+ </span>
155
+ <button
156
+ onClick={() => navigate(newTaskUrl(task.workspaceId, undefined, routeEnvironmentId))}
157
+ className={styles.btnPrimary}
158
+ >
159
+ + New Task
160
+ </button>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ // Failed
166
+ if (task.status === "failed") {
167
+ return (
168
+ <div className={styles.bar}>
169
+ <span className={`${styles.statusText} ${styles.statusFailed}`}>
170
+ Task failed
171
+ </span>
172
+ </div>
173
+ );
174
+ }
175
+ }
176
+
177
+ // --- session mode ---
178
+ if (sessionId) {
179
+ const session = sessions.find((s) => s.id === sessionId);
180
+ const isEnded = session?.status === "stopped";
181
+ const isActive = session !== undefined && !isEnded;
182
+
183
+ // Active session — ChatInput on the page handles this; return empty
184
+ if (isActive) {
185
+ return <></>;
186
+ }
187
+
188
+ if (isEnded) {
189
+ return (
190
+ <div className={styles.bar}>
191
+ <span className={`${styles.statusText} ${styles.hintText}`}>Session {session.endReason || session.status}</span>
192
+ <button onClick={() => navigate(newChatUrl(session.environmentId))} className={styles.btnPrimary}>
193
+ + New Chat
194
+ </button>
195
+ </div>
196
+ );
197
+ }
198
+ }
199
+
200
+ // fallback
201
+ return (
202
+ <div className={styles.bar}>
203
+ <span className={styles.hintText}>Loading...</span>
204
+ </div>
205
+ );
206
+ }
@@ -0,0 +1,60 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ // =============================================================================
4
+ // Sidebar — left panel with permanent workspace/task tree
5
+ // =============================================================================
6
+
7
+ .container {
8
+ @include surface-panel;
9
+ background: var(--bg-surface);
10
+ width: 320px;
11
+ min-width: 220px;
12
+ max-width: 600px;
13
+ border-right: 1px solid var(--border-subtle);
14
+ display: flex;
15
+ flex-direction: column;
16
+ resize: horizontal;
17
+ overflow: hidden;
18
+
19
+ @include mobile {
20
+ width: 100% !important;
21
+ min-width: unset;
22
+ max-width: unset;
23
+ resize: none;
24
+ border-right: none;
25
+ height: 100%;
26
+ }
27
+ }
28
+
29
+ .tabBar {
30
+ display: flex;
31
+ border-bottom: 1px solid var(--border-subtle);
32
+ flex-shrink: 0;
33
+ }
34
+
35
+ .tab {
36
+ flex: 1;
37
+ padding: var(--space-sm) var(--space-md);
38
+ font-size: var(--font-size-sm);
39
+ font-weight: var(--font-weight-medium);
40
+ color: var(--text-tertiary);
41
+ background: transparent;
42
+ border: none;
43
+ border-bottom: 2px solid transparent;
44
+ cursor: pointer;
45
+ transition: color 150ms, border-color 150ms;
46
+
47
+ &:hover {
48
+ color: var(--text-secondary);
49
+ }
50
+
51
+ &[data-active='true'] {
52
+ color: var(--text-primary);
53
+ border-bottom-color: var(--accent-blue);
54
+ }
55
+ }
56
+
57
+ .content {
58
+ flex: 1;
59
+ overflow-y: auto;
60
+ }
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect } from "@storybook/test";
3
+ import { Sidebar } from "./Sidebar.js";
4
+
5
+ const meta: Meta<typeof Sidebar> = {
6
+ component: Sidebar,
7
+ title: "Primitives/Layout/Sidebar",
8
+ tags: ["autodocs"],
9
+ };
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ /** Sidebar renders its content slot. */
14
+ export const WithContent: Story = {
15
+ args: {
16
+ content: (
17
+ <div data-testid="sidebar-child">
18
+ <h3>Navigation</h3>
19
+ <ul>
20
+ <li>Home</li>
21
+ <li>Tasks</li>
22
+ <li>Settings</li>
23
+ </ul>
24
+ </div>
25
+ ),
26
+ },
27
+ play: async ({ canvas }) => {
28
+ const sidebar = canvas.getByTestId("sidebar");
29
+ await expect(sidebar).toBeInTheDocument();
30
+ const child = canvas.getByTestId("sidebar-child");
31
+ await expect(child).toBeInTheDocument();
32
+ await expect(canvas.getByText("Navigation")).toBeInTheDocument();
33
+ await expect(canvas.getByText("Tasks")).toBeInTheDocument();
34
+ },
35
+ };
36
+
37
+ /** Sidebar is hidden when content is undefined. */
38
+ export const Empty: Story = {
39
+ args: {
40
+ content: undefined,
41
+ },
42
+ play: async ({ canvas }) => {
43
+ const sidebar = canvas.queryByTestId("sidebar");
44
+ await expect(sidebar).not.toBeInTheDocument();
45
+ },
46
+ };
@@ -0,0 +1,84 @@
1
+ import { useState, useRef, useEffect, type JSX, type ReactNode } from "react";
2
+ import styles from "./Sidebar.module.scss";
3
+
4
+ /** Default sidebar width in pixels. */
5
+ const DEFAULT_SIDEBAR_WIDTH: number = 320;
6
+ /** Minimum sidebar width in pixels. */
7
+ const MIN_SIDEBAR_WIDTH: number = 220;
8
+ /** Maximum sidebar width in pixels. */
9
+ const MAX_SIDEBAR_WIDTH: number = 600;
10
+ /** localStorage key for persisted sidebar width. */
11
+ const STORAGE_KEY: string = "grackle-sidebar-width";
12
+
13
+ /** Read persisted sidebar width from localStorage, falling back to the default. */
14
+ function loadWidth(): number {
15
+ try {
16
+ const stored = localStorage.getItem(STORAGE_KEY);
17
+ if (stored !== null) {
18
+ const parsed = Number(stored);
19
+ if (Number.isFinite(parsed) && parsed >= MIN_SIDEBAR_WIDTH && parsed <= MAX_SIDEBAR_WIDTH) {
20
+ return parsed;
21
+ }
22
+ }
23
+ } catch {
24
+ // localStorage unavailable
25
+ }
26
+ return DEFAULT_SIDEBAR_WIDTH;
27
+ }
28
+
29
+ /** Persist sidebar width to localStorage. */
30
+ function saveWidth(width: number): void {
31
+ try {
32
+ localStorage.setItem(STORAGE_KEY, String(width));
33
+ } catch {
34
+ // localStorage unavailable
35
+ }
36
+ }
37
+
38
+ /** Props for the Sidebar component. */
39
+ export interface SidebarProps {
40
+ /** Content to render inside the sidebar slot. When undefined, the sidebar is hidden. */
41
+ content: ReactNode | undefined;
42
+ }
43
+
44
+ /** Left sidebar rendering slot content passed via props. */
45
+ export function Sidebar({ content }: SidebarProps): JSX.Element | undefined {
46
+ const [width] = useState<number>(loadWidth);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+
49
+ /** Observe container resizes and persist width to localStorage. */
50
+ useEffect(() => {
51
+ const element = containerRef.current;
52
+ if (!element) {
53
+ return;
54
+ }
55
+
56
+ const observer = new ResizeObserver((entries) => {
57
+ for (const entry of entries) {
58
+ const borderBox = entry.borderBoxSize[0];
59
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- borderBoxSize[0] may be undefined in some browsers
60
+ if (borderBox) {
61
+ const boxWidth = Math.round(borderBox.inlineSize);
62
+ if (boxWidth >= MIN_SIDEBAR_WIDTH && boxWidth <= MAX_SIDEBAR_WIDTH) {
63
+ saveWidth(boxWidth);
64
+ }
65
+ }
66
+ }
67
+ });
68
+
69
+ observer.observe(element);
70
+ return () => { observer.disconnect(); };
71
+ }, []);
72
+
73
+ if (content === undefined) {
74
+ return undefined;
75
+ }
76
+
77
+ return (
78
+ <div className={styles.container} ref={containerRef} data-testid="sidebar" style={{ width }}>
79
+ <div className={styles.content}>
80
+ {content}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,108 @@
1
+ @use '../../styles/mixins' as *;
2
+
3
+ // =============================================================================
4
+ // Status Bar — top bar with brand name, connection indicator, and env count
5
+ // =============================================================================
6
+
7
+ .container {
8
+ @include surface-panel;
9
+ display: flex;
10
+ justify-content: space-between;
11
+ align-items: center;
12
+ padding: var(--space-sm) var(--space-lg);
13
+ border-bottom: 1px solid var(--border-subtle);
14
+ font-size: var(--font-size-lg);
15
+
16
+ @include mobile {
17
+ padding: var(--space-xs) var(--space-sm);
18
+ z-index: 101;
19
+ }
20
+ }
21
+
22
+ .hamburger {
23
+ display: none;
24
+ background: none;
25
+ border: none;
26
+ color: var(--text-primary);
27
+ font-size: var(--font-size-xl);
28
+ cursor: pointer;
29
+ padding: var(--space-xs);
30
+ line-height: 1;
31
+
32
+ @include mobile {
33
+ display: block;
34
+ }
35
+ }
36
+
37
+ .brand {
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: var(--space-xs);
41
+ font-weight: var(--font-weight-heavy);
42
+ font-size: var(--font-size-xl);
43
+ letter-spacing: -0.5px;
44
+ color: var(--text-primary);
45
+ background: none;
46
+ border: none;
47
+ cursor: pointer;
48
+ padding: 0;
49
+ font-family: inherit;
50
+ transition: color var(--transition-fast);
51
+
52
+ &:hover {
53
+ color: var(--accent-green);
54
+ }
55
+ }
56
+
57
+ .brandLogo {
58
+ width: 24px;
59
+ height: 24px;
60
+ object-fit: contain;
61
+ border-radius: var(--radius-sm);
62
+ }
63
+
64
+ .info {
65
+ display: flex;
66
+ gap: var(--space-lg);
67
+ font-size: var(--font-size-sm);
68
+ color: var(--text-secondary);
69
+ align-items: center;
70
+
71
+ @include mobile {
72
+ gap: var(--space-sm);
73
+ font-size: var(--font-size-xs);
74
+ }
75
+ }
76
+
77
+ .connectionLabel {
78
+ @include mobile {
79
+ position: absolute;
80
+ width: 1px;
81
+ height: 1px;
82
+ padding: 0;
83
+ margin: -1px;
84
+ overflow: hidden;
85
+ clip: rect(0, 0, 0, 0);
86
+ white-space: nowrap;
87
+ border: 0;
88
+ }
89
+ }
90
+
91
+ .connectionDot {
92
+ font-size: var(--font-size-sm);
93
+
94
+ &.connected {
95
+ color: var(--accent-green);
96
+ animation: pulse 2s ease-in-out infinite;
97
+ }
98
+
99
+ &.disconnected {
100
+ color: var(--accent-red);
101
+ }
102
+
103
+ &.connecting {
104
+ color: var(--accent-yellow);
105
+ animation: pulse 1.5s ease-in-out infinite;
106
+ }
107
+ }
108
+
@@ -0,0 +1,119 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn, userEvent } from "@storybook/test";
3
+ import { StatusBar } from "./StatusBar.js";
4
+ import { makeEnvironment, makeSession } from "../../test-utils/storybook-helpers.js";
5
+
6
+ const meta: Meta<typeof StatusBar> = {
7
+ component: StatusBar,
8
+ title: "Grackle/Layout/StatusBar",
9
+ tags: ["autodocs"],
10
+ args: {
11
+ connectionStatus: "connected",
12
+ environments: [makeEnvironment({ id: "local", displayName: "Local", status: "connected" })],
13
+ sessions: [makeSession({ status: "running" })],
14
+ },
15
+ };
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ /** Connected state with 1 environment and 1 active session. */
21
+ export const Connected: Story = {
22
+ play: async ({ canvas }) => {
23
+ await expect(canvas.getByLabelText("Connected")).toBeInTheDocument();
24
+ await expect(canvas.getByText("Connected")).toBeInTheDocument();
25
+ await expect(canvas.getByText("1/1 env")).toBeInTheDocument();
26
+ await expect(canvas.getByText("1 active")).toBeInTheDocument();
27
+ },
28
+ };
29
+
30
+ /** Disconnected state. */
31
+ export const Disconnected: Story = {
32
+ args: {
33
+ connectionStatus: "disconnected",
34
+ environments: [],
35
+ sessions: [],
36
+ },
37
+ play: async ({ canvas }) => {
38
+ await expect(canvas.getByText("Disconnected")).toBeInTheDocument();
39
+ await expect(canvas.getByText("0/0 envs")).toBeInTheDocument();
40
+ await expect(canvas.getByText("0 active")).toBeInTheDocument();
41
+ },
42
+ };
43
+
44
+ /** Connecting state shown during reconnection attempts. */
45
+ export const Connecting: Story = {
46
+ args: {
47
+ connectionStatus: "connecting",
48
+ environments: [],
49
+ sessions: [],
50
+ },
51
+ play: async ({ canvas }) => {
52
+ await expect(canvas.getByLabelText("Connecting...")).toBeInTheDocument();
53
+ await expect(canvas.getByText("Connecting...")).toBeInTheDocument();
54
+ await expect(canvas.getByText("0/0 envs")).toBeInTheDocument();
55
+ await expect(canvas.getByText("0 active")).toBeInTheDocument();
56
+ },
57
+ };
58
+
59
+ /** Hamburger button is visible when onToggleSidebar is provided (mobile drawer). */
60
+ export const HamburgerVisible: Story = {
61
+ args: {
62
+ onToggleSidebar: fn(),
63
+ sidebarOpen: false,
64
+ },
65
+ play: async ({ canvas }) => {
66
+ await expect(canvas.getByLabelText("Toggle sidebar")).toBeInTheDocument();
67
+ },
68
+ };
69
+
70
+ /** Hamburger button is hidden when onToggleSidebar is not provided (no sidebar page). */
71
+ export const HamburgerHidden: Story = {
72
+ args: {
73
+ onToggleSidebar: undefined,
74
+ },
75
+ play: async ({ canvas }) => {
76
+ await expect(canvas.queryByLabelText("Toggle sidebar")).not.toBeInTheDocument();
77
+ },
78
+ };
79
+
80
+ /** Hamburger fires callback and shows aria-expanded=true when sidebar is open. */
81
+ export const HamburgerToggle: Story = {
82
+ args: {
83
+ onToggleSidebar: fn(),
84
+ sidebarOpen: true,
85
+ },
86
+ play: async ({ canvas, args }) => {
87
+ const hamburger = canvas.getByLabelText("Toggle sidebar");
88
+ await expect(hamburger).toHaveAttribute("aria-expanded", "true");
89
+ await userEvent.click(hamburger);
90
+ await expect(args.onToggleSidebar).toHaveBeenCalled();
91
+ },
92
+ };
93
+
94
+ /** Persona button is NOT rendered in the StatusBar (removed in favor of Settings tab). */
95
+ export const NoPersonaButton: Story = {
96
+ play: async ({ canvas }) => {
97
+ await expect(canvas.queryByLabelText("Personas")).not.toBeInTheDocument();
98
+ },
99
+ };
100
+
101
+ /** Multiple environments -- some connected, some not. */
102
+ export const MultipleEnvironments: Story = {
103
+ args: {
104
+ environments: [
105
+ makeEnvironment({ id: "e1", status: "connected" }),
106
+ makeEnvironment({ id: "e2", status: "disconnected" }),
107
+ makeEnvironment({ id: "e3", status: "connected" }),
108
+ ],
109
+ sessions: [
110
+ makeSession({ id: "s1", status: "running" }),
111
+ makeSession({ id: "s2", status: "idle" }),
112
+ makeSession({ id: "s3", status: "stopped" }),
113
+ ],
114
+ },
115
+ play: async ({ canvas }) => {
116
+ await expect(canvas.getByText("2/3 envs")).toBeInTheDocument();
117
+ await expect(canvas.getByText("2 active")).toBeInTheDocument();
118
+ },
119
+ };
@@ -0,0 +1,70 @@
1
+ import type { JSX } from "react";
2
+ import { Circle, Menu } from "lucide-react";
3
+ import type { ConnectionStatus, Environment, Session } from "../../hooks/types.js";
4
+ import { ICON_LG, ICON_XS } from "../../utils/iconSize.js";
5
+ import { HOME_URL, useAppNavigate } from "../../utils/navigation.js";
6
+ import { Tooltip } from "../display/Tooltip.js";
7
+ import styles from "./StatusBar.module.scss";
8
+
9
+ /** Human-readable label for each connection state. */
10
+ const CONNECTION_LABEL: Record<ConnectionStatus, string> = {
11
+ connected: "Connected",
12
+ connecting: "Connecting...",
13
+ disconnected: "Disconnected",
14
+ };
15
+
16
+ /** CSS class for the connection dot in each state. */
17
+ const CONNECTION_DOT_CLASS: Record<ConnectionStatus, string> = {
18
+ connected: styles.connected,
19
+ connecting: styles.connecting,
20
+ disconnected: styles.disconnected,
21
+ };
22
+
23
+ /** Props for the StatusBar component. */
24
+ interface StatusBarProps {
25
+ /** Current connection state of the event stream. */
26
+ connectionStatus: ConnectionStatus;
27
+ /** List of all environments. */
28
+ environments: Environment[];
29
+ /** List of all sessions. */
30
+ sessions: Session[];
31
+ /** Callback to toggle the mobile sidebar drawer. */
32
+ onToggleSidebar?: () => void;
33
+ /** Whether the sidebar drawer is currently open (for aria-expanded). */
34
+ sidebarOpen?: boolean;
35
+ }
36
+
37
+ /** Top status bar showing connection state, environment counts, and active session count. */
38
+ export function StatusBar({ connectionStatus, environments, sessions, onToggleSidebar, sidebarOpen }: StatusBarProps): JSX.Element {
39
+ const navigate = useAppNavigate();
40
+ const totalEnvs = environments.length;
41
+ const connectedEnvs = environments.filter((e) => e.status === "connected").length;
42
+ const activeCount = sessions.filter((s) => ["running", "idle"].includes(s.status)).length;
43
+ const label = CONNECTION_LABEL[connectionStatus];
44
+
45
+ return (
46
+ <div className={styles.container}>
47
+ {onToggleSidebar && (
48
+ <button type="button" className={styles.hamburger} onClick={onToggleSidebar} aria-label="Toggle sidebar" aria-expanded={sidebarOpen}>
49
+ <Menu size={ICON_LG} aria-hidden="true" />
50
+ </button>
51
+ )}
52
+ <Tooltip text="Home" placement="bottom">
53
+ <button type="button" className={styles.brand} onClick={() => navigate(HOME_URL)} data-testid="statusbar-brand">
54
+ <img src="/icon-192x192.png" alt="" className={styles.brandLogo} aria-hidden="true" data-testid="statusbar-logo" />
55
+ Grackle
56
+ </button>
57
+ </Tooltip>
58
+ <div className={styles.info}>
59
+ <span aria-label={label}>
60
+ <span className={`${styles.connectionDot} ${CONNECTION_DOT_CLASS[connectionStatus]}`} aria-hidden="true">
61
+ <Circle size={ICON_XS} fill="currentColor" />
62
+ </span>
63
+ {" "}<span className={styles.connectionLabel} aria-hidden="true">{label}</span>
64
+ </span>
65
+ <span>{connectedEnvs}/{totalEnvs} env{totalEnvs !== 1 ? "s" : ""}</span>
66
+ <span>{activeCount} active</span>
67
+ </div>
68
+ </div>
69
+ );
70
+ }