@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,270 @@
1
+ import type { JSX } from "react";
2
+ import Markdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import type { TaskData, Session, Environment, Workspace, UsageStats } from "../../hooks/types.js";
5
+ import { getStatusStyle, getStatusBadgeClassKey } from "../../utils/taskStatus.js";
6
+ import { formatCost, formatTokens } from "../../utils/format.js";
7
+ import { WorkpadPanel } from "./WorkpadPanel.js";
8
+ import styles from "./TaskOverviewPanel.module.scss";
9
+
10
+ // --- Internal helpers --------------------------------------------------------
11
+
12
+ function formatDate(iso: string | undefined): string {
13
+ if (!iso) {
14
+ return "\u2014";
15
+ }
16
+ const d = new Date(iso);
17
+ if (isNaN(d.getTime())) {
18
+ return "\u2014";
19
+ }
20
+ return d.toLocaleString(undefined, {
21
+ month: "short", day: "numeric", year: "numeric",
22
+ hour: "2-digit", minute: "2-digit",
23
+ });
24
+ }
25
+
26
+ function formatDuration(start: string | undefined, end: string | undefined): string | undefined {
27
+ if (!start || !end) {
28
+ return undefined;
29
+ }
30
+ const ms = new Date(end).getTime() - new Date(start).getTime();
31
+ if (isNaN(ms) || ms < 0) {
32
+ return undefined;
33
+ }
34
+ const mins = Math.floor(ms / 60000);
35
+ const secs = Math.floor((ms % 60000) / 1000);
36
+ if (mins === 0) {
37
+ return `${secs}s`;
38
+ }
39
+ const hours = Math.floor(mins / 60);
40
+ const remMins = mins % 60;
41
+ if (hours === 0) {
42
+ return `${mins}m ${secs}s`;
43
+ }
44
+ return `${hours}h ${remMins}m`;
45
+ }
46
+
47
+ function envStatusClass(status: string): string {
48
+ const s = status.toLowerCase();
49
+ if (s === "ready" || s === "running" || s === "available" || s === "connected") {
50
+ return styles.envDotGreen;
51
+ }
52
+ if (s === "provisioning" || s === "starting" || s === "pending" || s === "connecting") {
53
+ return styles.envDotYellow;
54
+ }
55
+ if (s === "error" || s === "failed" || s === "disconnected") {
56
+ return styles.envDotRed;
57
+ }
58
+ return styles.envDotGray;
59
+ }
60
+
61
+ function TaskStatusBadge({ status }: { status: string }): JSX.Element {
62
+ const style = getStatusStyle(status);
63
+ const classKey = getStatusBadgeClassKey(status);
64
+ return (
65
+ <span
66
+ className={`${styles.statusBadge} ${styles[classKey] ?? styles.statusPending}`}
67
+ data-testid="task-overview-status-badge"
68
+ >
69
+ {style.label}
70
+ </span>
71
+ );
72
+ }
73
+
74
+ // --- Public component --------------------------------------------------------
75
+
76
+ /** Props for {@link TaskOverviewPanel}. */
77
+ export interface TaskOverviewPanelProps {
78
+ /** The task to display. */
79
+ task: TaskData;
80
+ /** Lookup map for dependency resolution. */
81
+ tasksById: Map<string, TaskData>;
82
+ /** All available environments. */
83
+ environments: Environment[];
84
+ /** All workspaces. */
85
+ workspaces: Workspace[];
86
+ /** Sessions belonging to this task. */
87
+ taskSessions: Session[];
88
+ /** The currently selected environment id (from workspace default or user pick). */
89
+ selectedEnvId: string;
90
+ /** Usage stats for this task only. */
91
+ taskUsage?: UsageStats;
92
+ /** Aggregate usage stats including subtasks. */
93
+ treeUsage?: UsageStats;
94
+ }
95
+
96
+ /**
97
+ * Renders the overview tab content for a task detail page.
98
+ *
99
+ * Displays status badge, branch link, description (markdown), environment,
100
+ * dependencies, timeline, usage/cost, and review notes.
101
+ */
102
+ export function TaskOverviewPanel({
103
+ task, tasksById, environments, workspaces, taskSessions,
104
+ selectedEnvId, taskUsage, treeUsage,
105
+ }: TaskOverviewPanelProps): JSX.Element {
106
+ const latestSession = taskSessions.length > 0 ? taskSessions[taskSessions.length - 1] : undefined;
107
+ const envId = latestSession?.environmentId ?? "";
108
+ const env = envId ? environments.find((e) => e.id === envId) : undefined;
109
+ const workspace = workspaces.find((p) => p.id === task.workspaceId);
110
+ const selectedEnv = environments.find((e) => e.id === selectedEnvId);
111
+ const branchUrl = task.branch && workspace?.repoUrl
112
+ ? `${workspace.repoUrl.replace(/\/$/, "")}/tree/${encodeURIComponent(task.branch)}`
113
+ : undefined;
114
+
115
+ return (
116
+ <div className={styles.overviewDashboard} data-testid="task-overview-panel">
117
+ <div className={styles.overviewHero}>
118
+ <TaskStatusBadge status={task.status} />
119
+ {task.branch && (
120
+ <span className={styles.overviewBranchPill} data-testid="task-overview-branch">
121
+ {branchUrl ? (
122
+ <a href={branchUrl} target="_blank" rel="noreferrer noopener" className={styles.branchLink}>
123
+ {"\u{1F517}"} {task.branch}
124
+ </a>
125
+ ) : (
126
+ <span>{"\u{1F517}"} {task.branch}</span>
127
+ )}
128
+ </span>
129
+ )}
130
+ </div>
131
+ {typeof task.description === "string" && task.description && (
132
+ <div className={styles.overviewSection} data-testid="task-overview-description">
133
+ <div className={styles.overviewLabel}>Description</div>
134
+ <div className={styles.overviewMarkdown}>
135
+ <Markdown remarkPlugins={[remarkGfm]}>{task.description}</Markdown>
136
+ </div>
137
+ </div>
138
+ )}
139
+ {task.workpad && <WorkpadPanel workpad={task.workpad} />}
140
+ <div className={styles.overviewSection}>
141
+ <div className={styles.overviewLabel}>Environment</div>
142
+ {envId && env ? (
143
+ <div className={styles.envRow} data-testid="task-overview-environment">
144
+ <span className={`${styles.envDot} ${envStatusClass(env.status)}`} title={env.status} aria-label={`Status: ${env.status}`} role="img" />
145
+ <span className={styles.overviewValue}>{env.displayName}</span>
146
+ </div>
147
+ ) : selectedEnv ? (
148
+ <div className={styles.envRow} data-testid="task-overview-environment">
149
+ <span className={`${styles.envDot} ${envStatusClass(selectedEnv.status)}`} title={selectedEnv.status} aria-label={`Status: ${selectedEnv.status}`} role="img" />
150
+ <span className={styles.overviewValue}>{selectedEnv.displayName}</span>
151
+ <span className={styles.overviewMuted}>(workspace default)</span>
152
+ </div>
153
+ ) : (
154
+ <div className={styles.overviewMuted}>Set in workspace settings</div>
155
+ )}
156
+ </div>
157
+ <div className={styles.overviewSection} data-testid="task-overview-dependencies">
158
+ <div className={styles.overviewLabel}>Dependencies</div>
159
+ {task.dependsOn.length === 0 ? (
160
+ <div className={styles.overviewMuted}>None</div>
161
+ ) : (
162
+ <div className={styles.depList}>
163
+ {task.dependsOn.map((depId) => {
164
+ const dep = tasksById.get(depId);
165
+ const isDone = dep?.status === "complete";
166
+ return (
167
+ <div key={depId} className={`${styles.depItem} ${isDone ? styles.depDone : styles.depBlocked}`}>
168
+ <span>{isDone ? "\u2713" : "\u25CB"}</span>
169
+ <span>{dep?.title ?? depId}</span>
170
+ </div>
171
+ );
172
+ })}
173
+ </div>
174
+ )}
175
+ </div>
176
+ <div className={styles.overviewSection} data-testid="task-overview-timeline">
177
+ <div className={styles.overviewLabel}>Timeline</div>
178
+ <div className={styles.timeline}>
179
+ {task.createdAt && (
180
+ <div className={styles.timelineRow}>
181
+ <span className={styles.timelineKey}>Created</span>
182
+ <span className={styles.timelineValue}>{formatDate(task.createdAt)}</span>
183
+ </div>
184
+ )}
185
+ {task.assignedAt && (() => {
186
+ const delta = formatDuration(task.createdAt, task.assignedAt);
187
+ return (
188
+ <div className={styles.timelineRow}>
189
+ <span className={styles.timelineKey}>Assigned</span>
190
+ <span className={styles.timelineValue}>{formatDate(task.assignedAt)}</span>
191
+ {delta !== undefined && <span className={styles.timelineDelta}>{delta}</span>}
192
+ </div>
193
+ );
194
+ })()}
195
+ {task.startedAt && (() => {
196
+ const delta = formatDuration(task.assignedAt ?? task.createdAt, task.startedAt);
197
+ return (
198
+ <div className={styles.timelineRow}>
199
+ <span className={styles.timelineKey}>Started</span>
200
+ <span className={styles.timelineValue}>{formatDate(task.startedAt)}</span>
201
+ {delta !== undefined && <span className={styles.timelineDelta}>{delta}</span>}
202
+ </div>
203
+ );
204
+ })()}
205
+ {task.completedAt && (() => {
206
+ const delta = formatDuration(task.startedAt, task.completedAt);
207
+ return (
208
+ <div className={styles.timelineRow}>
209
+ <span className={styles.timelineKey}>Completed</span>
210
+ <span className={styles.timelineValue}>{formatDate(task.completedAt)}</span>
211
+ {delta !== undefined && <span className={styles.timelineDelta}>{delta}</span>}
212
+ </div>
213
+ );
214
+ })()}
215
+ {!task.createdAt && !task.assignedAt && !task.startedAt && !task.completedAt && (
216
+ <div className={styles.overviewMuted}>No timing data</div>
217
+ )}
218
+ </div>
219
+ </div>
220
+ {taskUsage && taskUsage.costMillicents > 0 && (
221
+ <div className={styles.overviewSection} data-testid="task-overview-usage">
222
+ <div className={styles.overviewLabel}>Usage</div>
223
+ <div className={styles.timeline}>
224
+ <div className={styles.timelineRow}>
225
+ <span className={styles.timelineKey}>Cost</span>
226
+ <span className={styles.timelineValue}>{formatCost(taskUsage.costMillicents)}</span>
227
+ <span className={styles.timelineDelta}>{taskUsage.sessionCount} session{taskUsage.sessionCount !== 1 ? "s" : ""}</span>
228
+ </div>
229
+ {treeUsage && treeUsage.costMillicents > taskUsage.costMillicents && (
230
+ <div className={styles.timelineRow}>
231
+ <span className={styles.timelineKey}>Total (incl. subtasks)</span>
232
+ <span className={styles.timelineValue}>{formatCost(treeUsage.costMillicents)}</span>
233
+ <span className={styles.timelineDelta}>{treeUsage.sessionCount} session{treeUsage.sessionCount !== 1 ? "s" : ""}</span>
234
+ </div>
235
+ )}
236
+ </div>
237
+ </div>
238
+ )}
239
+ {(task.tokenBudget > 0 || task.costBudgetMillicents > 0) && (
240
+ <div className={styles.overviewSection} data-testid="task-overview-budget">
241
+ <div className={styles.overviewLabel}>Budget</div>
242
+ <div className={styles.timeline}>
243
+ {task.tokenBudget > 0 && (
244
+ <div className={styles.timelineRow}>
245
+ <span className={styles.timelineKey}>Tokens</span>
246
+ <span className={styles.timelineValue}>
247
+ {formatTokens((taskUsage?.inputTokens ?? 0) + (taskUsage?.outputTokens ?? 0))} / {formatTokens(task.tokenBudget)}
248
+ </span>
249
+ </div>
250
+ )}
251
+ {task.costBudgetMillicents > 0 && (
252
+ <div className={styles.timelineRow}>
253
+ <span className={styles.timelineKey}>Cost</span>
254
+ <span className={styles.timelineValue}>
255
+ {formatCost(taskUsage?.costMillicents ?? 0)} / {formatCost(task.costBudgetMillicents)}
256
+ </span>
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+ )}
262
+ {task.reviewNotes && (
263
+ <div className={styles.overviewSection} data-testid="task-overview-review-notes">
264
+ <div className={styles.overviewLabel}>Review Notes</div>
265
+ <div className={styles.reviewNotes}>{task.reviewNotes}</div>
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,131 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn, userEvent } from "@storybook/test";
3
+ import { MOCK_TOKENS } from "../../test-utils/storybook-helpers.js";
4
+ import { TokensPanel } from "./TokensPanel.js";
5
+
6
+ const meta: Meta<typeof TokensPanel> = {
7
+ title: "App/Panels/TokensPanel",
8
+ component: TokensPanel,
9
+ args: {
10
+ tokens: MOCK_TOKENS,
11
+ onSetToken: fn(),
12
+ onDeleteToken: fn(),
13
+ onShowToast: fn(),
14
+ },
15
+ };
16
+
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof TokensPanel>;
20
+
21
+ /** Displays the three mock tokens with their names and targets visible. */
22
+ export const MockTokensDisplayed: Story = {
23
+ play: async ({ canvas }) => {
24
+ // All three token names should be visible
25
+ await expect(canvas.getByText("anthropic")).toBeInTheDocument();
26
+ await expect(canvas.getByText("github")).toBeInTheDocument();
27
+ await expect(canvas.getByText("gcp-service-account")).toBeInTheDocument();
28
+
29
+ // Targets should be shown
30
+ await expect(canvas.getByText("ANTHROPIC_API_KEY")).toBeInTheDocument();
31
+ await expect(canvas.getByText("GITHUB_TOKEN")).toBeInTheDocument();
32
+ await expect(canvas.getByText("/home/user/.config/gcloud/credentials.json")).toBeInTheDocument();
33
+ },
34
+ };
35
+
36
+ /** The add token form is present with name, value inputs and an Add Token button. */
37
+ export const AddFormPresent: Story = {
38
+ play: async ({ canvas }) => {
39
+ await expect(canvas.getByPlaceholderText("Token name")).toBeInTheDocument();
40
+ await expect(canvas.getByPlaceholderText("Value")).toBeInTheDocument();
41
+ await expect(canvas.getByRole("button", { name: "Add Token" })).toBeInTheDocument();
42
+ },
43
+ };
44
+
45
+ /** Filling in and submitting the form calls onSetToken with the correct args. */
46
+ export const AddFormFunctional: Story = {
47
+ play: async ({ canvas, args }) => {
48
+ const nameInput = canvas.getByPlaceholderText("Token name");
49
+ const valueInput = canvas.getByPlaceholderText("Value");
50
+ const envVarInput = canvas.getByPlaceholderText(/Env var name/);
51
+ const addButton = canvas.getByRole("button", { name: "Add Token" });
52
+
53
+ await userEvent.clear(nameInput);
54
+ await userEvent.type(nameInput, "new-mock-token");
55
+ await userEvent.clear(valueInput);
56
+ await userEvent.type(valueInput, "mock-value");
57
+ await userEvent.clear(envVarInput);
58
+ await userEvent.type(envVarInput, "NEW_MOCK_TOKEN");
59
+ await userEvent.click(addButton);
60
+
61
+ await expect(args.onSetToken).toHaveBeenCalledWith(
62
+ "new-mock-token",
63
+ "mock-value",
64
+ "env_var",
65
+ "NEW_MOCK_TOKEN",
66
+ "",
67
+ );
68
+ },
69
+ };
70
+
71
+ /** Clicking the delete button on a token shows a confirm dialog, and confirming calls onDeleteToken. */
72
+ export const DeleteRemovesFromList: Story = {
73
+ play: async ({ canvas, args }) => {
74
+ // Click the delete button for the "anthropic" token
75
+ const deleteButton = canvas.getByTitle("Delete anthropic");
76
+ await userEvent.click(deleteButton);
77
+
78
+ // Confirm dialog should appear (use findBy for async rendering after animation)
79
+ await expect(await canvas.findByText("Delete Token?")).toBeInTheDocument();
80
+ // "anthropic" appears in both the dialog description and the token list
81
+ const anthropicElements = canvas.getAllByText(/anthropic/);
82
+ await expect(anthropicElements.length).toBeGreaterThanOrEqual(1);
83
+
84
+ // Confirm deletion — the ConfirmDialog button label defaults to "Delete"
85
+ const confirmButton = await canvas.findByRole("button", { name: "Delete" });
86
+ await userEvent.click(confirmButton);
87
+
88
+ await expect(args.onDeleteToken).toHaveBeenCalledWith("anthropic");
89
+ },
90
+ };
91
+
92
+ /** Switching the type selector from env_var to file changes the placeholder text. */
93
+ export const TypeSelectorSwitchesFields: Story = {
94
+ play: async ({ canvas }) => {
95
+ // Default type is env_var — placeholder should show env var
96
+ await expect(canvas.getByPlaceholderText(/Env var name/)).toBeInTheDocument();
97
+
98
+ // Switch to file type
99
+ const select = canvas.getByDisplayValue("Environment Variable");
100
+ await userEvent.selectOptions(select, "file");
101
+
102
+ // Placeholder should change to file path
103
+ await expect(canvas.getByPlaceholderText(/File path/)).toBeInTheDocument();
104
+ },
105
+ };
106
+
107
+ /** The description text explaining token auto-push behavior is visible. */
108
+ export const DescriptionTextVisible: Story = {
109
+ play: async ({ canvas }) => {
110
+ await expect(
111
+ canvas.getByText(/API tokens are auto-pushed to environments when set or updated/),
112
+ ).toBeInTheDocument();
113
+ },
114
+ };
115
+
116
+ /** After adding a token, the name and value fields are cleared. */
117
+ export const FormClearsAfterAdd: Story = {
118
+ play: async ({ canvas }) => {
119
+ const nameInput = canvas.getByPlaceholderText("Token name");
120
+ const valueInput = canvas.getByPlaceholderText("Value");
121
+ const addButton = canvas.getByRole("button", { name: "Add Token" });
122
+
123
+ await userEvent.type(nameInput, "clear-test");
124
+ await userEvent.type(valueInput, "clearval");
125
+ await userEvent.click(addButton);
126
+
127
+ // Fields should be cleared after submit
128
+ await expect(nameInput).toHaveValue("");
129
+ await expect(valueInput).toHaveValue("");
130
+ },
131
+ };
@@ -0,0 +1,143 @@
1
+ import { useState, type JSX, type FormEvent } from "react";
2
+ import { X } from "lucide-react";
3
+ import type { ToastVariant } from "../../context/ToastContext.js";
4
+ import type { TokenInfo } from "../../hooks/types.js";
5
+ import { ICON_MD } from "../../utils/iconSize.js";
6
+ import { ConfirmDialog } from "../display/index.js";
7
+ import styles from "./SettingsPanel.module.scss";
8
+
9
+ /** Token type options for the add form. */
10
+ const TOKEN_TYPES: Array<{ value: string; label: string }> = [
11
+ { value: "env_var", label: "Environment Variable" },
12
+ { value: "file", label: "File" },
13
+ ];
14
+
15
+ /** Props for the TokensPanel component. */
16
+ interface TokensPanelProps {
17
+ /** List of stored token metadata. */
18
+ tokens: TokenInfo[];
19
+ /** Stores or updates a token. */
20
+ onSetToken: (name: string, value: string, tokenType: string, envVar: string, filePath: string) => void;
21
+ /** Deletes a token by name. */
22
+ onDeleteToken: (name: string) => void;
23
+ /** Display a toast notification. */
24
+ onShowToast?: (message: string, variant?: ToastVariant) => void;
25
+ }
26
+
27
+ /** Token management panel with list, add form, and delete confirmation. */
28
+ export function TokensPanel({ tokens, onSetToken, onDeleteToken, onShowToast }: TokensPanelProps): JSX.Element {
29
+
30
+ const [name, setName] = useState("");
31
+ const [value, setValue] = useState("");
32
+ const [tokenType, setTokenType] = useState("env_var");
33
+ const [target, setTarget] = useState("");
34
+ const [confirmDeleteToken, setConfirmDeleteToken] = useState<string | null>(null);
35
+
36
+ const handleSubmit = (e: FormEvent): void => {
37
+ e.preventDefault();
38
+ if (!name || !value) {
39
+ return;
40
+ }
41
+ const envVar = tokenType === "env_var" ? (target || name.toUpperCase() + "_TOKEN") : "";
42
+ const filePath = tokenType === "file" ? target : "";
43
+ onSetToken(name, value, tokenType, envVar, filePath);
44
+ onShowToast?.("Token saved successfully", "success");
45
+ setName("");
46
+ setValue("");
47
+ setTarget("");
48
+ };
49
+
50
+ const handleDelete = (tokenName: string): void => {
51
+ setConfirmDeleteToken(tokenName);
52
+ };
53
+
54
+ const handleConfirmDelete = (): void => {
55
+ if (confirmDeleteToken) {
56
+ onDeleteToken(confirmDeleteToken);
57
+ onShowToast?.("Token deleted", "info");
58
+ }
59
+ setConfirmDeleteToken(null);
60
+ };
61
+
62
+ return (
63
+ <>
64
+ <ConfirmDialog
65
+ isOpen={confirmDeleteToken !== null}
66
+ title="Delete Token?"
67
+ description={confirmDeleteToken ? `"${confirmDeleteToken}" will be permanently removed.` : undefined}
68
+ onConfirm={handleConfirmDelete}
69
+ onCancel={() => setConfirmDeleteToken(null)}
70
+ />
71
+ <section className={styles.section}>
72
+ <h3 className={styles.sectionTitle}>Tokens</h3>
73
+ <p className={styles.sectionDescription}>
74
+ API tokens are auto-pushed to environments when set or updated. Values are write-only.
75
+ </p>
76
+
77
+ {tokens.length === 0 ? (
78
+ <div className={styles.emptyStateInfo}>Add your first API token to enable service integrations.</div>
79
+ ) : (
80
+ <div className={styles.tokenList}>
81
+ {tokens.map((t) => (
82
+ <div key={t.name} className={styles.tokenRow}>
83
+ <span className={styles.tokenBadge}>{t.tokenType}</span>
84
+ <span className={styles.tokenName}>{t.name}</span>
85
+ <span className={styles.tokenTarget}>
86
+ {t.tokenType === "env_var" ? t.envVar : t.filePath}
87
+ </span>
88
+ <button
89
+ className={styles.deleteButton}
90
+ onClick={() => handleDelete(t.name)}
91
+ title={`Delete ${t.name}`}
92
+ aria-label={`Delete ${t.name}`}
93
+ >
94
+ <X size={ICON_MD} aria-hidden="true" />
95
+ </button>
96
+ </div>
97
+ ))}
98
+ </div>
99
+ )}
100
+
101
+ <form className={styles.addForm} onSubmit={handleSubmit}>
102
+ <div className={styles.formRow}>
103
+ <input
104
+ className={styles.input}
105
+ type="text"
106
+ placeholder="Token name"
107
+ value={name}
108
+ onChange={(e) => setName(e.target.value)}
109
+ />
110
+ <input
111
+ className={styles.input}
112
+ type="password"
113
+ placeholder="Value"
114
+ value={value}
115
+ onChange={(e) => setValue(e.target.value)}
116
+ />
117
+ </div>
118
+ <div className={styles.formRow}>
119
+ <select
120
+ className={styles.select}
121
+ value={tokenType}
122
+ onChange={(e) => setTokenType(e.target.value)}
123
+ >
124
+ {TOKEN_TYPES.map((tt) => (
125
+ <option key={tt.value} value={tt.value}>{tt.label}</option>
126
+ ))}
127
+ </select>
128
+ <input
129
+ className={styles.input}
130
+ type="text"
131
+ placeholder={tokenType === "env_var" ? "Env var name (e.g. API_TOKEN)" : "File path (e.g. /home/user/.token)"}
132
+ value={target}
133
+ onChange={(e) => setTarget(e.target.value)}
134
+ />
135
+ <button className={styles.addButton} type="submit">
136
+ Add Token
137
+ </button>
138
+ </div>
139
+ </form>
140
+ </section>
141
+ </>
142
+ );
143
+ }
@@ -0,0 +1,39 @@
1
+ .workpadSection {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-xs);
5
+ padding: var(--space-sm) 0;
6
+ border-top: 1px solid var(--border-subtle);
7
+ }
8
+
9
+ .workpadLabel {
10
+ font-size: var(--font-size-xs);
11
+ font-weight: 600;
12
+ text-transform: uppercase;
13
+ letter-spacing: 0.05em;
14
+ color: var(--text-tertiary);
15
+ }
16
+
17
+ .workpadStatus {
18
+ font-size: var(--font-size-sm);
19
+ font-weight: 500;
20
+ color: var(--text-primary);
21
+ }
22
+
23
+ .workpadSummary {
24
+ font-size: var(--font-size-sm);
25
+ color: var(--text-secondary);
26
+ line-height: 1.5;
27
+ }
28
+
29
+ .workpadExtra {
30
+ font-size: var(--font-size-xs);
31
+ font-family: var(--font-mono, monospace);
32
+ color: var(--text-secondary);
33
+ background: var(--bg-inset);
34
+ padding: var(--space-xs) var(--space-sm);
35
+ border-radius: 4px;
36
+ overflow-x: auto;
37
+ white-space: pre-wrap;
38
+ word-break: break-word;
39
+ }
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect } from "@storybook/test";
3
+ import { WorkpadPanel } from "./WorkpadPanel.js";
4
+
5
+ const meta: Meta<typeof WorkpadPanel> = {
6
+ title: "App/Panels/WorkpadPanel",
7
+ component: WorkpadPanel,
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ export const WithFullWorkpad: Story = {
14
+ args: {
15
+ workpad: JSON.stringify({
16
+ status: "completed",
17
+ summary: "Implemented JWT auth middleware with tests. Opened PR #475.",
18
+ extra: { branch: "feat/auth-middleware", pr: 475, files_changed: 3 },
19
+ }),
20
+ },
21
+ play: async ({ canvas }) => {
22
+ await expect(canvas.getByTestId("workpad-status")).toHaveTextContent("completed");
23
+ await expect(canvas.getByTestId("workpad-summary")).toHaveTextContent("JWT auth middleware");
24
+ await expect(canvas.getByTestId("workpad-extra")).toHaveTextContent("feat/auth-middleware");
25
+ },
26
+ };
27
+
28
+ export const StatusOnly: Story = {
29
+ args: {
30
+ workpad: JSON.stringify({ status: "blocked" }),
31
+ },
32
+ play: async ({ canvas }) => {
33
+ await expect(canvas.getByTestId("workpad-status")).toHaveTextContent("blocked");
34
+ await expect(canvas.queryByTestId("workpad-summary")).toBeNull();
35
+ await expect(canvas.queryByTestId("workpad-extra")).toBeNull();
36
+ },
37
+ };
38
+
39
+ export const EmptyWorkpad: Story = {
40
+ args: {
41
+ workpad: "",
42
+ },
43
+ play: async ({ canvas }) => {
44
+ await expect(canvas.queryByTestId("workpad-panel")).toBeNull();
45
+ },
46
+ };
47
+
48
+ export const InvalidJson: Story = {
49
+ args: {
50
+ workpad: "this is not json",
51
+ },
52
+ play: async ({ canvas }) => {
53
+ await expect(canvas.getByTestId("workpad-panel")).toBeInTheDocument();
54
+ await expect(canvas.getByText("this is not json")).toBeInTheDocument();
55
+ },
56
+ };