@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,509 @@
1
+ import { useEffect, useMemo, useState, type CSSProperties, type JSX } from "react";
2
+ import { ChevronRight, List } from "lucide-react";
3
+ import { useMatch } from "react-router";
4
+ import { AnimatePresence, motion } from "motion/react";
5
+ import type { Workspace, TaskData } from "../../hooks/types.js";
6
+ import { MAX_TASK_DEPTH, fuzzySearch, type FuzzyKey, type MatchIndex } from "@grackle-ai/common";
7
+ import { ICON_SM, ICON_MD } from "../../utils/iconSize.js";
8
+ import { taskUrl, newTaskUrl, useAppNavigate } from "../../utils/navigation.js";
9
+ import { getStatusStyle, resolveStatus } from "../../utils/taskStatus.js";
10
+ import { Tooltip } from "../display/Tooltip.js";
11
+ import { HighlightedText, buildTaskTree, groupTasksByStatus, type TaskNode, type StatusGroup } from "./listHelpers.js";
12
+ import styles from "./TaskList.module.scss";
13
+
14
+ /** Fuzzy search keys for task matching. */
15
+ const TASK_SEARCH_KEYS: FuzzyKey[] = [{ name: "title", weight: 2 }, { name: "description", weight: 1 }];
16
+
17
+ /** Base left-padding for task rows. */
18
+ const TASK_BASE_INDENT_PX: number = 16;
19
+ /** Additional left-padding per depth level. */
20
+ const TASK_DEPTH_INDENT_PX: number = 16;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Group-by-status toggle persistence
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** localStorage key for the group-by-status toggle (separate from WorkspaceList's
27
+ * "grackle-group-by-status" key — each view has its own grouping preference). */
28
+ const STORAGE_KEY_GROUP_BY_STATUS: string = "grackle-task-group-by-status";
29
+
30
+ /** Read the persisted group-by-status preference. */
31
+ function getGroupByStatus(): boolean {
32
+ try {
33
+ return localStorage.getItem(STORAGE_KEY_GROUP_BY_STATUS) === "true";
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /** Persist the group-by-status preference. */
40
+ function saveGroupByStatus(value: boolean): void {
41
+ try {
42
+ localStorage.setItem(STORAGE_KEY_GROUP_BY_STATUS, String(value));
43
+ } catch {
44
+ /* localStorage unavailable */
45
+ }
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // StatusGroupAccordion
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Props for the StatusGroupAccordion component. */
53
+ interface StatusGroupAccordionProps {
54
+ group: StatusGroup;
55
+ isExpanded: boolean;
56
+ onToggle: () => void;
57
+ selectedTaskId: string | undefined;
58
+ navigate: ReturnType<typeof useAppNavigate>;
59
+ titleHighlights: Map<string, readonly MatchIndex[]>;
60
+ workspaceNames: Map<string, string>;
61
+ }
62
+
63
+ /** Collapsible accordion for a status group. */
64
+ function StatusGroupAccordion({
65
+ group,
66
+ isExpanded,
67
+ onToggle,
68
+ selectedTaskId,
69
+ navigate,
70
+ titleHighlights,
71
+ workspaceNames,
72
+ }: StatusGroupAccordionProps): JSX.Element {
73
+ return (
74
+ <div data-testid={`status-group-${group.status}`}>
75
+ <div
76
+ className={styles.statusGroupHeader}
77
+ role="button"
78
+ tabIndex={0}
79
+ aria-expanded={isExpanded}
80
+ onClick={onToggle}
81
+ onKeyDown={(e) => {
82
+ if (e.key === "Enter" || e.key === " ") {
83
+ e.preventDefault();
84
+ onToggle();
85
+ }
86
+ }}
87
+ >
88
+ <span className={`${styles.expandArrow} ${isExpanded ? styles.expanded : ""}`} aria-hidden="true">
89
+ <ChevronRight size={ICON_SM} />
90
+ </span>
91
+ <span className={styles.statusGroupIcon} style={{ color: group.style.color }} aria-hidden="true">
92
+ {group.style.icon}
93
+ </span>
94
+ <span className={styles.statusGroupLabel}>{group.label}</span>
95
+ <span className={styles.statusGroupCount}>{group.tasks.length}</span>
96
+ </div>
97
+
98
+ <AnimatePresence>
99
+ {isExpanded && (
100
+ <motion.div
101
+ initial={{ height: 0, opacity: 0 }}
102
+ animate={{ height: "auto", opacity: 1 }}
103
+ exit={{ height: 0, opacity: 0 }}
104
+ transition={{ duration: 0.2 }}
105
+ style={{ overflow: "hidden" }}
106
+ >
107
+ {group.tasks.map((task) => {
108
+ const statusStyle = getStatusStyle(task.status);
109
+ const isSelected = selectedTaskId === task.id;
110
+ const wsName = task.parentTaskId || !task.workspaceId ? undefined : workspaceNames.get(task.workspaceId);
111
+ return (
112
+ <div
113
+ key={task.id}
114
+ onClick={() => navigate(taskUrl(task.id))}
115
+ role="button"
116
+ tabIndex={0}
117
+ aria-label={task.title}
118
+ onKeyDown={(e) => {
119
+ if (e.currentTarget === e.target && (e.key === "Enter" || e.key === " ")) {
120
+ e.preventDefault();
121
+ navigate(taskUrl(task.id));
122
+ }
123
+ }}
124
+ className={`${styles.taskRow} ${isSelected ? styles.selected : ""}`}
125
+ style={{ '--task-indent': `${TASK_BASE_INDENT_PX}px` } as CSSProperties}
126
+ data-task-id={task.id}
127
+ >
128
+ <span className={styles.leafSpacer} />
129
+ <span className={styles.taskStatusIcon} style={{ color: statusStyle.color }} aria-hidden="true" data-testid={`task-status-${resolveStatus(task.status)}`}>
130
+ {statusStyle.icon}
131
+ </span>
132
+ <span className={styles.taskTitle} title={task.title}>
133
+ <HighlightedText text={task.title} indices={titleHighlights.get(task.id)} highlightClass={styles.searchHighlight} />
134
+ </span>
135
+ {wsName && (
136
+ <span className={styles.workspaceBadge} title={wsName}>{wsName}</span>
137
+ )}
138
+ </div>
139
+ );
140
+ })}
141
+ </motion.div>
142
+ )}
143
+ </AnimatePresence>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /** Props for the recursive TaskTreeNode component. */
149
+ interface TaskTreeNodeProps {
150
+ node: TaskNode;
151
+ depth: number;
152
+ expandedTasks: Set<string>;
153
+ toggleTask: (taskId: string) => void;
154
+ selectedTaskId: string | undefined;
155
+ navigate: ReturnType<typeof useAppNavigate>;
156
+ taskStatusById: Map<string, string>;
157
+ titleHighlights: Map<string, readonly MatchIndex[]>;
158
+ workspaceNames: Map<string, string>;
159
+ }
160
+
161
+ /** Renders a single task tree node with optional children. */
162
+ function TaskTreeNode({
163
+ node,
164
+ depth,
165
+ expandedTasks,
166
+ toggleTask,
167
+ selectedTaskId,
168
+ navigate,
169
+ taskStatusById,
170
+ titleHighlights,
171
+ workspaceNames,
172
+ }: TaskTreeNodeProps): JSX.Element {
173
+ const statusStyle = getStatusStyle(node.status);
174
+ const isBlocked = node.dependsOn.length > 0 &&
175
+ node.dependsOn.some((depId) => taskStatusById.get(depId) !== "complete");
176
+ const isExpanded = expandedTasks.has(node.id);
177
+ const hasChildren = node.children.length > 0;
178
+ const isSelected = selectedTaskId === node.id;
179
+ const indent = TASK_BASE_INDENT_PX + depth * TASK_DEPTH_INDENT_PX;
180
+ const isRoot = depth === 0;
181
+ const wsName = isRoot && !node.parentTaskId && node.workspaceId ? workspaceNames.get(node.workspaceId) : undefined;
182
+ return (
183
+ <>
184
+ <div
185
+ onClick={() => navigate(taskUrl(node.id))}
186
+ role="button"
187
+ tabIndex={0}
188
+ aria-label={node.title}
189
+ onKeyDown={(e) => {
190
+ if (e.currentTarget === e.target && (e.key === "Enter" || e.key === " ")) {
191
+ e.preventDefault();
192
+ navigate(taskUrl(node.id));
193
+ }
194
+ }}
195
+ className={`${styles.taskRow} ${isSelected ? styles.selected : ""}`}
196
+ style={{ '--task-indent': `${indent}px` } as CSSProperties}
197
+ data-task-id={node.id}
198
+ >
199
+ {hasChildren && (
200
+ <span
201
+ className={`${styles.expandArrow} ${isExpanded ? styles.expanded : ""}`}
202
+ role="button"
203
+ tabIndex={0}
204
+ aria-label={isExpanded ? "Collapse task" : "Expand task"}
205
+ onClick={(e) => { e.stopPropagation(); toggleTask(node.id); }}
206
+ onKeyDown={(e) => {
207
+ if (e.key === "Enter" || e.key === " ") {
208
+ e.preventDefault();
209
+ e.stopPropagation();
210
+ toggleTask(node.id);
211
+ }
212
+ }}
213
+ >
214
+ <ChevronRight size={ICON_SM} aria-hidden="true" />
215
+ </span>
216
+ )}
217
+ {!hasChildren && <span className={styles.leafSpacer} />}
218
+ <span className={styles.taskStatusIcon} style={{ color: statusStyle.color }} aria-hidden="true" data-testid={`task-status-${resolveStatus(node.status)}`}>
219
+ {statusStyle.icon}
220
+ </span>
221
+ <span className={styles.taskTitle} title={node.title}>
222
+ <HighlightedText text={node.title} indices={titleHighlights.get(node.id)} highlightClass={styles.searchHighlight} />
223
+ </span>
224
+ {wsName && (
225
+ <span className={styles.workspaceBadge} title={wsName}>{wsName}</span>
226
+ )}
227
+ {hasChildren && (
228
+ <span className={styles.childCountBadge}>
229
+ {node.children.filter(c => c.status === "complete").length}/{node.children.length}
230
+ </span>
231
+ )}
232
+ {node.dependsOn.length > 0 && (
233
+ <span
234
+ className={`${styles.dependencyBadge} ${isBlocked ? styles.blockedBadge : ""}`}
235
+ title={`Depends on: ${node.dependsOn.join(", ")}`}
236
+ >
237
+ {isBlocked ? "blocked" : "dep"}
238
+ </span>
239
+ )}
240
+ {depth < MAX_TASK_DEPTH && (
241
+ <Tooltip text="Add child task">
242
+ <button
243
+ onClick={(e) => {
244
+ e.stopPropagation();
245
+ navigate(newTaskUrl(node.workspaceId, node.id));
246
+ }}
247
+ aria-label="Add child task"
248
+ className={styles.addChildButton}
249
+ >
250
+ +
251
+ </button>
252
+ </Tooltip>
253
+ )}
254
+ </div>
255
+
256
+ <AnimatePresence>
257
+ {hasChildren && isExpanded && (
258
+ <motion.div
259
+ initial={{ height: 0, opacity: 0 }}
260
+ animate={{ height: "auto", opacity: 1 }}
261
+ exit={{ height: 0, opacity: 0 }}
262
+ transition={{ duration: 0.15 }}
263
+ style={{ overflow: "hidden" }}
264
+ >
265
+ {node.children.map(child => (
266
+ <TaskTreeNode
267
+ key={child.id}
268
+ node={child}
269
+ depth={depth + 1}
270
+ expandedTasks={expandedTasks}
271
+ toggleTask={toggleTask}
272
+ selectedTaskId={selectedTaskId}
273
+ navigate={navigate}
274
+ taskStatusById={taskStatusById}
275
+ titleHighlights={titleHighlights}
276
+ workspaceNames={workspaceNames}
277
+ />
278
+ ))}
279
+ </motion.div>
280
+ )}
281
+ </AnimatePresence>
282
+ </>
283
+ );
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // TaskList (main export)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /** Props for the TaskList component. */
291
+ interface TaskListProps {
292
+ /** All workspaces (used for workspace name lookup). */
293
+ workspaces: Workspace[];
294
+ /** All tasks to display. */
295
+ tasks: TaskData[];
296
+ }
297
+
298
+ /** Global task tree sidebar view — shows all tasks across all workspaces. */
299
+ export function TaskList({ workspaces, tasks }: TaskListProps): JSX.Element {
300
+ const navigate = useAppNavigate();
301
+ const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
302
+ const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
303
+ const [groupByStatus, setGroupByStatusState] = useState(getGroupByStatus);
304
+ const [groupExpandDefault, setGroupExpandDefault] = useState(getGroupByStatus);
305
+ const [groupExpandOverrides, setGroupExpandOverrides] = useState<Map<string, boolean>>(new Map());
306
+
307
+ // Derive selected state from router
308
+ const taskMatch = useMatch("/tasks/:taskId/*");
309
+ const selectedTaskId = taskMatch?.params.taskId !== "new" ? taskMatch?.params.taskId : undefined;
310
+
311
+ const taskStatusById = useMemo(
312
+ () => new Map(tasks.map((t) => [t.id, t.status])),
313
+ [tasks],
314
+ );
315
+
316
+ const workspaceNames = useMemo(
317
+ () => new Map(workspaces.map((w) => [w.id, w.name])),
318
+ [workspaces],
319
+ );
320
+
321
+
322
+ /** Toggle group-by-status mode. */
323
+ const toggleGroupByStatus = (): void => {
324
+ const next = !groupByStatus;
325
+ saveGroupByStatus(next);
326
+ setGroupByStatusState(next);
327
+ if (next) {
328
+ setGroupExpandDefault(true);
329
+ setGroupExpandOverrides(new Map());
330
+ }
331
+ };
332
+
333
+ /** Toggle a single status group accordion. */
334
+ const toggleStatusGroup = (status: string): void => {
335
+ setGroupExpandOverrides((prev) => {
336
+ const next = new Map(prev);
337
+ const current = next.has(status) ? next.get(status)! : groupExpandDefault;
338
+ next.set(status, !current);
339
+ return next;
340
+ });
341
+ };
342
+
343
+ /** Check if a status group is expanded. */
344
+ const isGroupExpanded = (status: string): boolean => {
345
+ return groupExpandOverrides.has(status) ? groupExpandOverrides.get(status)! : groupExpandDefault;
346
+ };
347
+
348
+ const toggleTask = (tid: string): void => {
349
+ setExpandedTasks((prev) => {
350
+ const next = new Set(prev);
351
+ if (next.has(tid)) {
352
+ next.delete(tid);
353
+ setManuallyCollapsed((mc) => new Set(mc).add(tid));
354
+ } else {
355
+ next.add(tid);
356
+ setManuallyCollapsed((mc) => {
357
+ const updated = new Set(mc);
358
+ updated.delete(tid);
359
+ return updated;
360
+ });
361
+ }
362
+ return next;
363
+ });
364
+ };
365
+
366
+ // Auto-expand parent tasks that have children (skip manually collapsed ones)
367
+ useEffect(() => {
368
+ const parentIds = new Set(
369
+ tasks.filter(t => t.parentTaskId).map(t => t.parentTaskId),
370
+ );
371
+ if (parentIds.size > 0) {
372
+ setExpandedTasks((prev) => {
373
+ const next = new Set(prev);
374
+ for (const pid of parentIds) {
375
+ if (!manuallyCollapsed.has(pid)) {
376
+ next.add(pid);
377
+ }
378
+ }
379
+ return next;
380
+ });
381
+ }
382
+ }, [tasks, manuallyCollapsed]);
383
+
384
+ // ── Search / filter state ──────────────────────────────────────
385
+ const [searchQuery, setSearchQuery] = useState("");
386
+
387
+ const { directMatchTaskIds, treeMatchTaskIds, titleHighlights } = useMemo(() => {
388
+ if (!searchQuery.trim()) {
389
+ return { directMatchTaskIds: null, treeMatchTaskIds: null, titleHighlights: new Map<string, readonly MatchIndex[]>() };
390
+ }
391
+ const taskResults = fuzzySearch(tasks, searchQuery, TASK_SEARCH_KEYS);
392
+ const directIds = new Set(taskResults.map((r) => r.item.id));
393
+
394
+ const highlights = new Map<string, readonly MatchIndex[]>();
395
+ for (const r of taskResults) {
396
+ const titleMatch = r.matches.find((m) => m.key === "title");
397
+ if (titleMatch) {
398
+ highlights.set(r.item.id, titleMatch.indices);
399
+ }
400
+ }
401
+
402
+ // Include ancestor tasks for tree structure
403
+ const treeIds = new Set(directIds);
404
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
405
+ for (const taskId of [...directIds]) {
406
+ let current = taskById.get(taskId);
407
+ while (current?.parentTaskId) {
408
+ treeIds.add(current.parentTaskId);
409
+ current = taskById.get(current.parentTaskId);
410
+ }
411
+ }
412
+
413
+ return { directMatchTaskIds: directIds, treeMatchTaskIds: treeIds, titleHighlights: highlights };
414
+ }, [searchQuery, tasks]);
415
+
416
+ const isSearching = directMatchTaskIds !== null;
417
+ const activeMatchIds = isSearching
418
+ ? (groupByStatus ? directMatchTaskIds : treeMatchTaskIds)
419
+ : null;
420
+ const visibleTasks = activeMatchIds
421
+ ? tasks.filter((t) => activeMatchIds.has(t.id))
422
+ : tasks;
423
+
424
+ const tree = !groupByStatus ? buildTaskTree(visibleTasks) : [];
425
+
426
+ return (
427
+ <div className={styles.container}>
428
+ <div className={styles.header}>
429
+ <span>Tasks</span>
430
+ <div className={styles.headerActions}>
431
+ <Tooltip text={groupByStatus ? "Switch to tree view" : "Group tasks by status"}>
432
+ <button
433
+ className={`${styles.groupToggle} ${groupByStatus ? styles.groupToggleActive : ""}`}
434
+ onClick={toggleGroupByStatus}
435
+ aria-label={groupByStatus ? "Switch to tree view" : "Group tasks by status"}
436
+ aria-pressed={groupByStatus}
437
+ data-testid="task-group-by-status-toggle"
438
+ >
439
+ <List size={ICON_MD} />
440
+ </button>
441
+ </Tooltip>
442
+ <Tooltip text="New task">
443
+ <button
444
+ className={styles.addButton}
445
+ onClick={() => navigate(newTaskUrl())}
446
+ aria-label="New task"
447
+ data-testid="new-task-button"
448
+ >
449
+ +
450
+ </button>
451
+ </Tooltip>
452
+ </div>
453
+ </div>
454
+
455
+ {tasks.length > 0 && (
456
+ <input
457
+ type="text"
458
+ value={searchQuery}
459
+ onChange={(e) => setSearchQuery(e.target.value)}
460
+ placeholder="Filter..."
461
+ aria-label="Filter tasks"
462
+ className={styles.searchInput}
463
+ data-testid="sidebar-search"
464
+ />
465
+ )}
466
+
467
+ {groupByStatus ? (
468
+ groupTasksByStatus(visibleTasks, taskStatusById).map(group => (
469
+ <StatusGroupAccordion
470
+ key={group.status}
471
+ group={group}
472
+ isExpanded={isGroupExpanded(group.status)}
473
+ onToggle={() => toggleStatusGroup(group.status)}
474
+ selectedTaskId={selectedTaskId}
475
+ navigate={navigate}
476
+ titleHighlights={titleHighlights}
477
+ workspaceNames={workspaceNames}
478
+ />
479
+ ))
480
+ ) : (
481
+ tree.map(node => (
482
+ <TaskTreeNode
483
+ key={node.id}
484
+ node={node}
485
+ depth={0}
486
+ expandedTasks={expandedTasks}
487
+ toggleTask={toggleTask}
488
+ selectedTaskId={selectedTaskId}
489
+ navigate={navigate}
490
+ taskStatusById={taskStatusById}
491
+ titleHighlights={titleHighlights}
492
+ workspaceNames={workspaceNames}
493
+ />
494
+ ))
495
+ )}
496
+
497
+ {visibleTasks.length === 0 && !isSearching && (
498
+ <div className={styles.emptyState}>
499
+ No tasks yet. Click + to create one.
500
+ </div>
501
+ )}
502
+ {visibleTasks.length === 0 && isSearching && (
503
+ <div className={styles.emptyState}>
504
+ No matching tasks
505
+ </div>
506
+ )}
507
+ </div>
508
+ );
509
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * List and navigation components for the sidebar.
3
+ * @module lists
4
+ */
5
+ export { EnvironmentNav } from "./EnvironmentNav.js";
6
+ export { FindingsNav } from "./FindingsNav.js";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Shared helpers for sidebar list components (WorkspaceList, TaskList).
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type { JSX, ReactNode } from "react";
8
+ import type { TaskData } from "../../hooks/types.js";
9
+ import type { MatchIndex } from "@grackle-ai/common";
10
+ import { SIDEBAR_STATUS_ORDER, getStatusStyle } from "../../utils/taskStatus.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Highlight helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Merge overlapping or adjacent [start, end] ranges into non-overlapping ranges. */
17
+ export function mergeRanges(ranges: readonly MatchIndex[]): MatchIndex[] {
18
+ if (ranges.length === 0) {
19
+ return [];
20
+ }
21
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
22
+ const merged: [number, number][] = [[sorted[0][0], sorted[0][1]]];
23
+ for (let i = 1; i < sorted.length; i++) {
24
+ const prev = merged[merged.length - 1];
25
+ const [start, end] = sorted[i];
26
+ if (start <= prev[1] + 1) {
27
+ prev[1] = Math.max(prev[1], end);
28
+ } else {
29
+ merged.push([start, end]);
30
+ }
31
+ }
32
+ return merged;
33
+ }
34
+
35
+ /** Render text with highlighted match ranges. Unmatched portions are plain, matched portions are bold. */
36
+ export function HighlightedText({ text, indices, highlightClass }: { text: string; indices?: readonly MatchIndex[]; highlightClass?: string }): JSX.Element {
37
+ if (!indices || indices.length === 0) {
38
+ return <>{text}</>;
39
+ }
40
+ const merged = mergeRanges(indices);
41
+ const parts: JSX.Element[] = [];
42
+ let cursor = 0;
43
+ for (const [start, end] of merged) {
44
+ if (start > cursor) {
45
+ parts.push(<span key={`p${cursor}`}>{text.slice(cursor, start)}</span>);
46
+ }
47
+ parts.push(<mark key={`m${start}`} className={highlightClass}>{text.slice(start, end + 1)}</mark>);
48
+ cursor = end + 1;
49
+ }
50
+ if (cursor < text.length) {
51
+ parts.push(<span key={`p${cursor}`}>{text.slice(cursor)}</span>);
52
+ }
53
+ return <>{parts}</>;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Task tree
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** A task node with children for recursive tree rendering. */
61
+ export interface TaskNode extends TaskData {
62
+ children: TaskNode[];
63
+ }
64
+
65
+ /** Assemble flat TaskData[] into a tree. */
66
+ export function buildTaskTree(taskList: TaskData[]): TaskNode[] {
67
+ const byId = new Map<string, TaskNode>(
68
+ taskList.map(t => [t.id, { ...t, children: [] }]),
69
+ );
70
+ const roots: TaskNode[] = [];
71
+ for (const node of byId.values()) {
72
+ if (node.parentTaskId && byId.has(node.parentTaskId)) {
73
+ byId.get(node.parentTaskId)!.children.push(node);
74
+ } else {
75
+ roots.push(node);
76
+ }
77
+ }
78
+ for (const node of byId.values()) {
79
+ node.children.sort((a, b) => a.sortOrder - b.sortOrder);
80
+ }
81
+ return roots.sort((a, b) => a.sortOrder - b.sortOrder);
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Status grouping
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** A group of tasks sharing the same status. */
89
+ export interface StatusGroup {
90
+ status: string;
91
+ label: string;
92
+ style: { color: string; icon: ReactNode };
93
+ tasks: TaskData[];
94
+ }
95
+
96
+ /** Group a flat list of tasks by status, ordered by urgency. Blocked tasks are separated into their own group. */
97
+ export function groupTasksByStatus(taskList: TaskData[], taskStatusById: Map<string, string>): StatusGroup[] {
98
+ const byStatus = new Map<string, TaskData[]>();
99
+ for (const task of taskList) {
100
+ const isBlocked = task.dependsOn.length > 0 &&
101
+ task.dependsOn.some((depId) => taskStatusById.get(depId) !== "complete");
102
+ const groupKey = isBlocked ? "blocked" : task.status;
103
+ const list = byStatus.get(groupKey);
104
+ if (list) {
105
+ list.push(task);
106
+ } else {
107
+ byStatus.set(groupKey, [task]);
108
+ }
109
+ }
110
+
111
+ const groups: StatusGroup[] = [];
112
+ const seen = new Set<string>();
113
+ for (const status of SIDEBAR_STATUS_ORDER) {
114
+ seen.add(status);
115
+ const tasks = byStatus.get(status);
116
+ if (tasks && tasks.length > 0) {
117
+ tasks.sort((a, b) => a.sortOrder - b.sortOrder);
118
+ const style = getStatusStyle(status);
119
+ groups.push({ status, label: style.label, style, tasks });
120
+ }
121
+ }
122
+ for (const [status, tasks] of byStatus) {
123
+ if (!seen.has(status) && tasks.length > 0) {
124
+ tasks.sort((a, b) => a.sortOrder - b.sortOrder);
125
+ const style = getStatusStyle(status);
126
+ groups.push({ status, label: style.label, style, tasks });
127
+ }
128
+ }
129
+ return groups;
130
+ }