@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.
- package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
- package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
- package/.rush/temp/operation/_phase_build/all.log +18 -0
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
- package/.rush/temp/operation/_phase_build/state.json +3 -0
- package/.rush/temp/operation/_phase_test/all.log +121 -0
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
- package/.rush/temp/operation/_phase_test/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +938 -0
- package/.storybook/main.ts +22 -0
- package/.storybook/preview.tsx +30 -0
- package/config/rig.json +4 -0
- package/config/rush-project.json +12 -0
- package/dist/index.css +1 -0
- package/dist/index.js +39221 -0
- package/eslint.config.cjs +5 -0
- package/package.json +83 -0
- package/rush-logs/web-components._phase_build.cache.log +4 -0
- package/rush-logs/web-components._phase_test.cache.log +4 -0
- package/src/components/chat/ChatInput.module.scss +81 -0
- package/src/components/chat/ChatInput.stories.tsx +91 -0
- package/src/components/chat/ChatInput.tsx +168 -0
- package/src/components/chat/index.ts +6 -0
- package/src/components/dag/DagView.module.scss +149 -0
- package/src/components/dag/DagView.stories.tsx +125 -0
- package/src/components/dag/DagView.tsx +109 -0
- package/src/components/dag/TaskNode.stories.tsx +133 -0
- package/src/components/dag/TaskNode.tsx +40 -0
- package/src/components/dag/useDagLayout.ts +139 -0
- package/src/components/display/Breadcrumbs.module.scss +71 -0
- package/src/components/display/Breadcrumbs.stories.tsx +80 -0
- package/src/components/display/Breadcrumbs.tsx +46 -0
- package/src/components/display/Button.module.scss +110 -0
- package/src/components/display/Button.stories.tsx +88 -0
- package/src/components/display/Button.tsx +40 -0
- package/src/components/display/ConfirmDialog.module.scss +67 -0
- package/src/components/display/ConfirmDialog.stories.tsx +81 -0
- package/src/components/display/ConfirmDialog.tsx +88 -0
- package/src/components/display/CopyButton.module.scss +41 -0
- package/src/components/display/CopyButton.stories.tsx +78 -0
- package/src/components/display/CopyButton.tsx +64 -0
- package/src/components/display/DemoBanner.module.scss +37 -0
- package/src/components/display/DemoBanner.stories.tsx +40 -0
- package/src/components/display/DemoBanner.tsx +23 -0
- package/src/components/display/EventHoverRow.module.scss +102 -0
- package/src/components/display/EventHoverRow.stories.tsx +99 -0
- package/src/components/display/EventHoverRow.tsx +154 -0
- package/src/components/display/EventRenderer.module.scss +272 -0
- package/src/components/display/EventRenderer.stories.tsx +186 -0
- package/src/components/display/EventRenderer.tsx +271 -0
- package/src/components/display/EventStream.module.scss +93 -0
- package/src/components/display/EventStream.stories.tsx +249 -0
- package/src/components/display/EventStream.tsx +369 -0
- package/src/components/display/FloatingActionBar.module.scss +107 -0
- package/src/components/display/FloatingActionBar.stories.tsx +122 -0
- package/src/components/display/FloatingActionBar.tsx +119 -0
- package/src/components/display/SessionAttemptSelector.module.scss +50 -0
- package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
- package/src/components/display/SessionAttemptSelector.tsx +49 -0
- package/src/components/display/SessionPicker.module.scss +200 -0
- package/src/components/display/SessionPicker.stories.tsx +169 -0
- package/src/components/display/SessionPicker.tsx +214 -0
- package/src/components/display/Skeleton.module.scss +58 -0
- package/src/components/display/Skeleton.stories.tsx +94 -0
- package/src/components/display/Skeleton.tsx +127 -0
- package/src/components/display/Spinner.module.scss +41 -0
- package/src/components/display/Spinner.stories.tsx +66 -0
- package/src/components/display/Spinner.tsx +32 -0
- package/src/components/display/SplashScreen.module.scss +20 -0
- package/src/components/display/SplashScreen.stories.tsx +26 -0
- package/src/components/display/SplashScreen.tsx +16 -0
- package/src/components/display/SplitButton.module.scss +166 -0
- package/src/components/display/SplitButton.stories.tsx +95 -0
- package/src/components/display/SplitButton.tsx +128 -0
- package/src/components/display/Tooltip.module.scss +84 -0
- package/src/components/display/Tooltip.stories.tsx +240 -0
- package/src/components/display/Tooltip.tsx +184 -0
- package/src/components/display/extractText.test.tsx +48 -0
- package/src/components/display/index.ts +20 -0
- package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
- package/src/components/editable/EditableCheckbox.tsx +39 -0
- package/src/components/editable/EditableField.module.scss +135 -0
- package/src/components/editable/EditableSelect.tsx +164 -0
- package/src/components/editable/EditableTextArea.stories.tsx +50 -0
- package/src/components/editable/EditableTextArea.tsx +148 -0
- package/src/components/editable/EditableTextField.stories.tsx +62 -0
- package/src/components/editable/EditableTextField.tsx +153 -0
- package/src/components/editable/EnvironmentSelect.module.scss +17 -0
- package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
- package/src/components/editable/EnvironmentSelect.tsx +87 -0
- package/src/components/editable/index.ts +13 -0
- package/src/components/editable/useEditableField.test.tsx +233 -0
- package/src/components/editable/useEditableField.ts +173 -0
- package/src/components/index.ts +20 -0
- package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
- package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
- package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
- package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
- package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
- package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
- package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
- package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
- package/src/components/knowledge/KnowledgeNav.tsx +138 -0
- package/src/components/knowledge/index.ts +3 -0
- package/src/components/layout/AppNav.module.scss +82 -0
- package/src/components/layout/AppNav.stories.tsx +115 -0
- package/src/components/layout/AppNav.tsx +133 -0
- package/src/components/layout/BottomStatusBar.module.scss +58 -0
- package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
- package/src/components/layout/BottomStatusBar.tsx +206 -0
- package/src/components/layout/Sidebar.module.scss +60 -0
- package/src/components/layout/Sidebar.stories.tsx +46 -0
- package/src/components/layout/Sidebar.tsx +84 -0
- package/src/components/layout/StatusBar.module.scss +108 -0
- package/src/components/layout/StatusBar.stories.tsx +119 -0
- package/src/components/layout/StatusBar.tsx +70 -0
- package/src/components/layout/index.ts +9 -0
- package/src/components/lists/EnvironmentNav.module.scss +118 -0
- package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
- package/src/components/lists/EnvironmentNav.tsx +133 -0
- package/src/components/lists/FindingsNav.module.scss +126 -0
- package/src/components/lists/FindingsNav.tsx +146 -0
- package/src/components/lists/TaskList.module.scss +206 -0
- package/src/components/lists/TaskList.stories.tsx +401 -0
- package/src/components/lists/TaskList.tsx +509 -0
- package/src/components/lists/index.ts +6 -0
- package/src/components/lists/listHelpers.tsx +130 -0
- package/src/components/notifications/Callout.module.scss +83 -0
- package/src/components/notifications/Callout.stories.tsx +81 -0
- package/src/components/notifications/Callout.tsx +64 -0
- package/src/components/notifications/Toast.module.scss +86 -0
- package/src/components/notifications/Toast.stories.tsx +71 -0
- package/src/components/notifications/Toast.tsx +51 -0
- package/src/components/notifications/ToastContainer.module.scss +23 -0
- package/src/components/notifications/ToastContainer.stories.tsx +66 -0
- package/src/components/notifications/ToastContainer.tsx +29 -0
- package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
- package/src/components/notifications/UpdateBanner.test.tsx +64 -0
- package/src/components/notifications/UpdateBanner.tsx +44 -0
- package/src/components/notifications/index.ts +8 -0
- package/src/components/panels/AboutPanel.stories.tsx +70 -0
- package/src/components/panels/AboutPanel.tsx +66 -0
- package/src/components/panels/AppearancePanel.stories.tsx +45 -0
- package/src/components/panels/AppearancePanel.tsx +97 -0
- package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
- package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
- package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
- package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
- package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
- package/src/components/panels/FindingsPanel.module.scss +94 -0
- package/src/components/panels/FindingsPanel.stories.tsx +109 -0
- package/src/components/panels/FindingsPanel.tsx +76 -0
- package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
- package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
- package/src/components/panels/PluginsPanel.tsx +77 -0
- package/src/components/panels/SettingsPanel.module.scss +336 -0
- package/src/components/panels/TaskActionButtons.module.scss +22 -0
- package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
- package/src/components/panels/TaskActionButtons.tsx +87 -0
- package/src/components/panels/TaskEditPanel.module.scss +202 -0
- package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
- package/src/components/panels/TaskEditPanel.tsx +328 -0
- package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
- package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
- package/src/components/panels/TaskOverviewPanel.tsx +270 -0
- package/src/components/panels/TokensPanel.stories.tsx +131 -0
- package/src/components/panels/TokensPanel.tsx +143 -0
- package/src/components/panels/WorkpadPanel.module.scss +39 -0
- package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
- package/src/components/panels/WorkpadPanel.tsx +63 -0
- package/src/components/panels/index.ts +13 -0
- package/src/components/personas/McpToolSelector.module.scss +109 -0
- package/src/components/personas/McpToolSelector.stories.tsx +129 -0
- package/src/components/personas/McpToolSelector.tsx +180 -0
- package/src/components/personas/PersonaManager.module.scss +233 -0
- package/src/components/personas/PersonaManager.stories.tsx +139 -0
- package/src/components/personas/PersonaManager.tsx +122 -0
- package/src/components/schedules/ScheduleManager.module.scss +98 -0
- package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
- package/src/components/schedules/ScheduleManager.tsx +160 -0
- package/src/components/settings/SettingsNav.module.scss +82 -0
- package/src/components/settings/SettingsNav.stories.tsx +83 -0
- package/src/components/settings/SettingsNav.tsx +104 -0
- package/src/components/streams/StreamDetailPanel.module.scss +206 -0
- package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
- package/src/components/streams/StreamDetailPanel.tsx +119 -0
- package/src/components/streams/StreamList.module.scss +92 -0
- package/src/components/streams/StreamList.stories.tsx +99 -0
- package/src/components/streams/StreamList.tsx +114 -0
- package/src/components/streams/index.ts +10 -0
- package/src/components/tools/AgentToolCard.module.scss +118 -0
- package/src/components/tools/AgentToolCard.stories.tsx +304 -0
- package/src/components/tools/AgentToolCard.tsx +247 -0
- package/src/components/tools/FileEditCard.stories.tsx +138 -0
- package/src/components/tools/FileEditCard.tsx +160 -0
- package/src/components/tools/FileReadCard.stories.tsx +120 -0
- package/src/components/tools/FileReadCard.tsx +106 -0
- package/src/components/tools/FindingCard.stories.tsx +124 -0
- package/src/components/tools/FindingCard.tsx +178 -0
- package/src/components/tools/GenericToolCard.stories.tsx +80 -0
- package/src/components/tools/GenericToolCard.tsx +111 -0
- package/src/components/tools/IpcCard.stories.tsx +129 -0
- package/src/components/tools/IpcCard.tsx +178 -0
- package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
- package/src/components/tools/KnowledgeCard.tsx +165 -0
- package/src/components/tools/MetadataCard.stories.tsx +32 -0
- package/src/components/tools/MetadataCard.tsx +39 -0
- package/src/components/tools/SearchCard.stories.tsx +74 -0
- package/src/components/tools/SearchCard.tsx +86 -0
- package/src/components/tools/ShellCard.stories.tsx +112 -0
- package/src/components/tools/ShellCard.tsx +106 -0
- package/src/components/tools/TaskCard.stories.tsx +123 -0
- package/src/components/tools/TaskCard.tsx +203 -0
- package/src/components/tools/TodoCard.module.scss +131 -0
- package/src/components/tools/TodoCard.stories.tsx +202 -0
- package/src/components/tools/TodoCard.tsx +200 -0
- package/src/components/tools/ToolCard.stories.tsx +177 -0
- package/src/components/tools/ToolCard.tsx +60 -0
- package/src/components/tools/ToolCardProps.ts +20 -0
- package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
- package/src/components/tools/ToolSearchCard.tsx +86 -0
- package/src/components/tools/WorkpadCard.stories.tsx +106 -0
- package/src/components/tools/WorkpadCard.tsx +125 -0
- package/src/components/tools/classifyTool.test.ts +44 -0
- package/src/components/tools/classifyTool.ts +134 -0
- package/src/components/tools/parseDiff.ts +95 -0
- package/src/components/tools/parseShellOutput.ts +28 -0
- package/src/components/tools/toolCardHelpers.test.ts +53 -0
- package/src/components/tools/toolCards.module.scss +234 -0
- package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
- package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
- package/src/components/workspace/WorkspaceBoard.tsx +232 -0
- package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
- package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
- package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
- package/src/context/GrackleContext.ts +28 -0
- package/src/context/GrackleContextTypes.ts +64 -0
- package/src/context/SidebarContext.tsx +53 -0
- package/src/context/ThemeContext.tsx +21 -0
- package/src/context/ToastContext.tsx +56 -0
- package/src/hooks/types.ts +864 -0
- package/src/hooks/useEventSelection.test.ts +204 -0
- package/src/hooks/useEventSelection.ts +158 -0
- package/src/hooks/useSmartScroll.ts +151 -0
- package/src/hooks/useTheme.ts +228 -0
- package/src/index.ts +210 -0
- package/src/mocks/MockGrackleProvider.tsx +1397 -0
- package/src/mocks/mockData.ts +1966 -0
- package/src/mocks/mockKnowledgeData.ts +294 -0
- package/src/scss.d.ts +12 -0
- package/src/styles/global.scss +244 -0
- package/src/styles/mixins.scss +278 -0
- package/src/styles/prism-theme.scss +148 -0
- package/src/styles/theme.scss +1102 -0
- package/src/test-utils/storybook-decorators.tsx +50 -0
- package/src/test-utils/storybook-helpers.ts +262 -0
- package/src/themes.ts +142 -0
- package/src/utils/boardColumns.ts +141 -0
- package/src/utils/breadcrumbs.test.ts +285 -0
- package/src/utils/breadcrumbs.ts +222 -0
- package/src/utils/dashboard.test.ts +156 -0
- package/src/utils/dashboard.ts +195 -0
- package/src/utils/eventContent.test.ts +353 -0
- package/src/utils/eventContent.ts +209 -0
- package/src/utils/findingCategory.ts +33 -0
- package/src/utils/format.ts +27 -0
- package/src/utils/iconSize.ts +18 -0
- package/src/utils/navigation.ts +205 -0
- package/src/utils/route-config.test.ts +128 -0
- package/src/utils/scrollUtils.test.ts +65 -0
- package/src/utils/scrollUtils.ts +49 -0
- package/src/utils/sessionEvents.test.ts +302 -0
- package/src/utils/sessionEvents.ts +233 -0
- package/src/utils/taskStatus.tsx +137 -0
- package/src/utils/time.ts +92 -0
- package/tsconfig.json +8 -0
- package/vite.config.ts +20 -0
- 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,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
|
+
}
|