@polderlabs/bizar 6.3.0 → 7.0.0

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 (245) hide show
  1. package/.claude/hooks/sessionend-recall.mjs +3 -3
  2. package/.claude/hooks/worker-suggest.mjs +110 -0
  3. package/.claude/settings.json +9 -0
  4. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js → EnvVarsSection-CW6fWKds.js} +3 -3
  5. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js.map → EnvVarsSection-CW6fWKds.js.map} +1 -1
  6. package/bizar-dash/dist/assets/MobileChat-D7zRhU9K.js +1 -0
  7. package/bizar-dash/dist/assets/{MobileChat-BJrqwVDd.js.map → MobileChat-D7zRhU9K.js.map} +1 -1
  8. package/bizar-dash/dist/assets/MobileSettings-K-sNsn_r.js +1 -0
  9. package/bizar-dash/dist/assets/{MobileSettings-CEQNJNLJ.js.map → MobileSettings-K-sNsn_r.js.map} +1 -1
  10. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js → Toast-WTxc3m2g.js} +1 -1
  11. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js.map → Toast-WTxc3m2g.js.map} +1 -1
  12. package/bizar-dash/dist/assets/flow-BpU0-gK2.js +1 -0
  13. package/bizar-dash/dist/assets/flow-BpU0-gK2.js.map +1 -0
  14. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js +1 -0
  15. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js.map +1 -0
  16. package/bizar-dash/dist/assets/{icons-Do5N2jmX.js → icons-B0AKnYI3.js} +98 -103
  17. package/bizar-dash/dist/assets/icons-B0AKnYI3.js.map +1 -0
  18. package/bizar-dash/dist/assets/main-C-Md3DaY.css +1 -0
  19. package/bizar-dash/dist/assets/main-CO8fIxQK.js +18 -0
  20. package/bizar-dash/dist/assets/main-CO8fIxQK.js.map +1 -0
  21. package/bizar-dash/dist/assets/markdown-w2bDDkAH.js +1 -0
  22. package/bizar-dash/dist/assets/{markdown-tOLaD6nm.js.map → markdown-w2bDDkAH.js.map} +1 -1
  23. package/bizar-dash/dist/assets/mobile-DXYCA0hC.js +1 -0
  24. package/bizar-dash/dist/assets/{mobile-DYCHcUpq.js.map → mobile-DXYCA0hC.js.map} +1 -1
  25. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js +2 -0
  26. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js.map +1 -0
  27. package/bizar-dash/dist/assets/react-vendor-BnuhLJ6X.css +1 -0
  28. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js +46 -0
  29. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js.map +1 -0
  30. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js +2 -0
  31. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js.map +1 -0
  32. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js +45 -0
  33. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js.map +1 -0
  34. package/bizar-dash/dist/index.html +12 -10
  35. package/bizar-dash/dist/mobile.html +7 -4
  36. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  37. package/bizar-dash/src/server/api.mjs +11 -0
  38. package/bizar-dash/src/server/memory-consolidator.mjs +218 -0
  39. package/bizar-dash/src/server/memory-distillation.mjs +296 -0
  40. package/bizar-dash/src/server/routes/distill.mjs +129 -0
  41. package/bizar-dash/src/server/routes/goal-planner.mjs +236 -0
  42. package/bizar-dash/src/web/App.tsx +4 -0
  43. package/bizar-dash/src/web/components/Tag.tsx +18 -5
  44. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  45. package/bizar-dash/src/web/components/agents/CommunicationLog.tsx +126 -0
  46. package/bizar-dash/src/web/components/agents/DependencyGraph.tsx +163 -0
  47. package/bizar-dash/src/web/components/agents/QualityGates.tsx +123 -0
  48. package/bizar-dash/src/web/components/agents/RealTimeEventLog.tsx +127 -0
  49. package/bizar-dash/src/web/components/agents/RoutingDecisions.test.tsx +148 -0
  50. package/bizar-dash/src/web/components/agents/RoutingDecisions.tsx +160 -0
  51. package/bizar-dash/src/web/components/goals/GoalInput.tsx +86 -0
  52. package/bizar-dash/src/web/components/goals/PlanVisualization.tsx +111 -0
  53. package/bizar-dash/src/web/lib/goapPlanner.ts +614 -0
  54. package/bizar-dash/src/web/lib/types.ts +25 -0
  55. package/bizar-dash/src/web/styles/agents-redesign.css +311 -0
  56. package/bizar-dash/src/web/styles/main.css +251 -0
  57. package/bizar-dash/src/web/styles/tasks-redesign.css +231 -0
  58. package/bizar-dash/src/web/ui/controls/Button.tsx +77 -0
  59. package/bizar-dash/src/web/ui/controls/Checkbox.tsx +95 -0
  60. package/bizar-dash/src/web/ui/controls/IconButton.tsx +66 -0
  61. package/bizar-dash/src/web/ui/controls/Kbd.tsx +24 -0
  62. package/bizar-dash/src/web/ui/controls/NumberInput.tsx +77 -0
  63. package/bizar-dash/src/web/ui/controls/RadioGroup.tsx +109 -0
  64. package/bizar-dash/src/web/ui/controls/SearchInput.tsx +91 -0
  65. package/bizar-dash/src/web/ui/controls/Select.tsx +92 -0
  66. package/bizar-dash/src/web/ui/controls/Slider.tsx +70 -0
  67. package/bizar-dash/src/web/ui/controls/TextInput.tsx +67 -0
  68. package/bizar-dash/src/web/ui/controls/Toggle.tsx +79 -0
  69. package/bizar-dash/src/web/ui/controls/controls.css +454 -0
  70. package/bizar-dash/src/web/ui/controls/index.ts +33 -0
  71. package/bizar-dash/src/web/ui/data/BarChart.tsx +60 -0
  72. package/bizar-dash/src/web/ui/data/DataTable.tsx +216 -0
  73. package/bizar-dash/src/web/ui/data/EmptyState.tsx +60 -0
  74. package/bizar-dash/src/web/ui/data/ErrorState.tsx +56 -0
  75. package/bizar-dash/src/web/ui/data/KeyValueList.tsx +106 -0
  76. package/bizar-dash/src/web/ui/data/LoadingState.tsx +65 -0
  77. package/bizar-dash/src/web/ui/data/Sparkline.tsx +87 -0
  78. package/bizar-dash/src/web/ui/data/StatTile.tsx +87 -0
  79. package/bizar-dash/src/web/ui/data/data.css +535 -0
  80. package/bizar-dash/src/web/ui/data/index.ts +31 -0
  81. package/bizar-dash/src/web/ui/feedback/Badge.tsx +49 -0
  82. package/bizar-dash/src/web/ui/feedback/Dialog.tsx +209 -0
  83. package/bizar-dash/src/web/ui/feedback/ProgressBar.tsx +69 -0
  84. package/bizar-dash/src/web/ui/feedback/StatusDot.tsx +50 -0
  85. package/bizar-dash/src/web/ui/feedback/Toast.tsx +200 -0
  86. package/bizar-dash/src/web/ui/feedback/Tooltip.tsx +126 -0
  87. package/bizar-dash/src/web/ui/feedback/feedback.css +314 -0
  88. package/bizar-dash/src/web/ui/feedback/index.ts +43 -0
  89. package/bizar-dash/src/web/ui/index.ts +56 -0
  90. package/bizar-dash/src/web/ui/layout/AppShell.tsx +51 -0
  91. package/bizar-dash/src/web/ui/layout/Breadcrumbs.tsx +97 -0
  92. package/bizar-dash/src/web/ui/layout/Panel.tsx +73 -0
  93. package/bizar-dash/src/web/ui/layout/PanelHeader.tsx +39 -0
  94. package/bizar-dash/src/web/ui/layout/Sidebar.tsx +131 -0
  95. package/bizar-dash/src/web/ui/layout/Tabs.tsx +133 -0
  96. package/bizar-dash/src/web/ui/layout/Topbar.tsx +42 -0
  97. package/bizar-dash/src/web/ui/layout/index.ts +33 -0
  98. package/bizar-dash/src/web/ui/layout/layout.css +413 -0
  99. package/bizar-dash/src/web/ui/navigation/CommandPalette.tsx +199 -0
  100. package/bizar-dash/src/web/ui/navigation/NavGroup.tsx +95 -0
  101. package/bizar-dash/src/web/ui/navigation/NavLink.tsx +93 -0
  102. package/bizar-dash/src/web/ui/navigation/index.ts +17 -0
  103. package/bizar-dash/src/web/ui/navigation/navigation.css +343 -0
  104. package/bizar-dash/src/web/ui/primitives/Box.tsx +91 -0
  105. package/bizar-dash/src/web/ui/primitives/Grid.tsx +49 -0
  106. package/bizar-dash/src/web/ui/primitives/Inline.tsx +55 -0
  107. package/bizar-dash/src/web/ui/primitives/Separator.tsx +46 -0
  108. package/bizar-dash/src/web/ui/primitives/Stack.tsx +54 -0
  109. package/bizar-dash/src/web/ui/primitives/VisuallyHidden.tsx +26 -0
  110. package/bizar-dash/src/web/ui/primitives/index.ts +23 -0
  111. package/bizar-dash/src/web/ui/primitives/primitives.css +183 -0
  112. package/bizar-dash/src/web/ui/styles/globals.css +86 -0
  113. package/bizar-dash/src/web/ui/styles/reset.css +125 -0
  114. package/bizar-dash/src/web/ui/styles/tokens.css +195 -0
  115. package/bizar-dash/src/web/ui/theme/ThemeProvider.tsx +142 -0
  116. package/bizar-dash/src/web/ui/theme/tokens.ts +131 -0
  117. package/bizar-dash/src/web/ui/theme/useTheme.ts +22 -0
  118. package/bizar-dash/src/web/ui/utils/cx.ts +42 -0
  119. package/bizar-dash/src/web/views/Agents.tsx +506 -266
  120. package/bizar-dash/src/web/views/GoalPlanner.tsx +158 -0
  121. package/bizar-dash/src/web/views/Overview.tsx +996 -448
  122. package/bizar-dash/src/web/views/Tasks.tsx +277 -273
  123. package/bizar-dash/tests/communication-log.test.tsx +67 -0
  124. package/bizar-dash/tests/dep-graph.test.tsx +40 -0
  125. package/bizar-dash/tests/event-log.test.tsx +61 -0
  126. package/bizar-dash/tests/goal-input.test.tsx +72 -0
  127. package/bizar-dash/tests/goap-planner.test.ts +68 -0
  128. package/bizar-dash/tests/quality-gates.test.tsx +59 -0
  129. package/bizar-dash/tests/setup.ts +31 -1
  130. package/bizar-dash/tests/ui/controls/Button.test.tsx +50 -0
  131. package/bizar-dash/tests/ui/controls/Checkbox.test.tsx +44 -0
  132. package/bizar-dash/tests/ui/controls/IconButton.test.tsx +37 -0
  133. package/bizar-dash/tests/ui/controls/Kbd.test.tsx +39 -0
  134. package/bizar-dash/tests/ui/controls/NumberInput.test.tsx +56 -0
  135. package/bizar-dash/tests/ui/controls/RadioGroup.test.tsx +58 -0
  136. package/bizar-dash/tests/ui/controls/SearchInput.test.tsx +52 -0
  137. package/bizar-dash/tests/ui/controls/Select.test.tsx +66 -0
  138. package/bizar-dash/tests/ui/controls/Slider.test.tsx +41 -0
  139. package/bizar-dash/tests/ui/controls/TextInput.test.tsx +44 -0
  140. package/bizar-dash/tests/ui/controls/Toggle.test.tsx +39 -0
  141. package/bizar-dash/tests/ui/data/BarChart.test.tsx +55 -0
  142. package/bizar-dash/tests/ui/data/DataTable.test.tsx +112 -0
  143. package/bizar-dash/tests/ui/data/EmptyState.test.tsx +57 -0
  144. package/bizar-dash/tests/ui/data/ErrorState.test.tsx +47 -0
  145. package/bizar-dash/tests/ui/data/KeyValueList.test.tsx +59 -0
  146. package/bizar-dash/tests/ui/data/LoadingState.test.tsx +37 -0
  147. package/bizar-dash/tests/ui/data/Sparkline.test.tsx +56 -0
  148. package/bizar-dash/tests/ui/data/StatTile.test.tsx +69 -0
  149. package/bizar-dash/tests/ui/feedback/Badge.test.tsx +41 -0
  150. package/bizar-dash/tests/ui/feedback/Dialog.test.tsx +99 -0
  151. package/bizar-dash/tests/ui/feedback/ProgressBar.test.tsx +66 -0
  152. package/bizar-dash/tests/ui/feedback/StatusDot.test.tsx +41 -0
  153. package/bizar-dash/tests/ui/feedback/Toast.test.tsx +124 -0
  154. package/bizar-dash/tests/ui/feedback/Tooltip.test.tsx +102 -0
  155. package/bizar-dash/tests/ui/layout/AppShell.test.tsx +50 -0
  156. package/bizar-dash/tests/ui/layout/Breadcrumbs.test.tsx +85 -0
  157. package/bizar-dash/tests/ui/layout/Panel.test.tsx +52 -0
  158. package/bizar-dash/tests/ui/layout/PanelHeader.test.tsx +32 -0
  159. package/bizar-dash/tests/ui/layout/Sidebar.test.tsx +95 -0
  160. package/bizar-dash/tests/ui/layout/Tabs.test.tsx +87 -0
  161. package/bizar-dash/tests/ui/layout/Topbar.test.tsx +41 -0
  162. package/bizar-dash/tests/ui/navigation/CommandPalette.test.tsx +105 -0
  163. package/bizar-dash/tests/ui/navigation/NavGroup.test.tsx +55 -0
  164. package/bizar-dash/tests/ui/navigation/NavLink.test.tsx +61 -0
  165. package/bizar-dash/tests/ui/primitives/Box.test.tsx +49 -0
  166. package/bizar-dash/tests/ui/primitives/Grid.test.tsx +37 -0
  167. package/bizar-dash/tests/ui/primitives/Inline.test.tsx +33 -0
  168. package/bizar-dash/tests/ui/primitives/Separator.test.tsx +34 -0
  169. package/bizar-dash/tests/ui/primitives/Stack.test.tsx +45 -0
  170. package/bizar-dash/tests/ui/primitives/VisuallyHidden.test.tsx +27 -0
  171. package/bizar-dash/tests/views/Agents.test.tsx +359 -0
  172. package/bizar-dash/tests/views/Overview.test.tsx +413 -0
  173. package/bizar-dash/tests/views/Tasks.test.tsx +250 -0
  174. package/bizar-dash/vitest.config.ts +3 -1
  175. package/cli/__tests__/cost-gate.test.mjs +301 -0
  176. package/cli/__tests__/feature-list-bridge.test.mjs +371 -0
  177. package/cli/bin.mjs +38 -0
  178. package/cli/commands/claim.mjs +282 -0
  179. package/cli/commands/cost.mjs +279 -0
  180. package/cli/cost-gate.mjs +501 -0
  181. package/cli/feature-list-bridge.mjs +400 -0
  182. package/cli/memory-constants.mjs +17 -0
  183. package/cli/worker-dispatcher.mjs +259 -0
  184. package/cli/worker-dispatcher.test.mjs +204 -0
  185. package/config/trigger-patterns.json +186 -0
  186. package/package.json +2 -2
  187. package/packages/sdk/package.json +1 -1
  188. package/packages/sdk/src/agent-registry.ts +304 -0
  189. package/packages/sdk/src/consensus/byzantine.ts +587 -0
  190. package/packages/sdk/src/consensus/index.ts +147 -0
  191. package/packages/sdk/src/consensus/queen.ts +173 -0
  192. package/packages/sdk/src/consensus/types.ts +199 -0
  193. package/packages/sdk/src/federation/audit.ts +156 -0
  194. package/packages/sdk/src/federation/budget.ts +269 -0
  195. package/packages/sdk/src/federation/envelope.ts +144 -0
  196. package/packages/sdk/src/federation/hmac.ts +160 -0
  197. package/packages/sdk/src/federation/index.ts +443 -0
  198. package/packages/sdk/src/federation/pii.ts +241 -0
  199. package/packages/sdk/src/federation/policy.ts +112 -0
  200. package/packages/sdk/src/federation/trust.ts +146 -0
  201. package/packages/sdk/src/index.ts +67 -0
  202. package/packages/sdk/src/mcp/server.ts +351 -1
  203. package/packages/sdk/src/router/codemod-intent.ts +125 -0
  204. package/packages/sdk/src/router/index.ts +247 -0
  205. package/packages/sdk/src/router/memory-distillation-shim.mjs +18 -0
  206. package/packages/sdk/src/router/memory-distillation.ts +249 -0
  207. package/packages/sdk/src/router/model-router.ts +235 -0
  208. package/packages/sdk/src/router/q-learning-router.ts +304 -0
  209. package/packages/sdk/src/swarm-topology.ts +279 -0
  210. package/packages/sdk/tests/agent-registry.test.ts +285 -0
  211. package/packages/sdk/tests/consensus.test.ts +376 -0
  212. package/packages/sdk/tests/federation/audit.test.ts +164 -0
  213. package/packages/sdk/tests/federation/budget.test.ts +189 -0
  214. package/packages/sdk/tests/federation/envelope.test.ts +120 -0
  215. package/packages/sdk/tests/federation/hmac.test.ts +194 -0
  216. package/packages/sdk/tests/federation/orchestrator.test.ts +176 -0
  217. package/packages/sdk/tests/federation/pii.test.ts +106 -0
  218. package/packages/sdk/tests/federation/policy.test.ts +140 -0
  219. package/packages/sdk/tests/federation/trust.test.ts +124 -0
  220. package/packages/sdk/tests/mcp-tools.test.ts +204 -0
  221. package/packages/sdk/tests/memory-distillation.test.mjs +186 -0
  222. package/packages/sdk/tests/model-router.test.mjs +108 -0
  223. package/packages/sdk/tests/q-learning-router.test.mjs +100 -0
  224. package/packages/sdk/tests/router-orchestrator.test.mjs +172 -0
  225. package/packages/sdk/tests/router.test.mjs +91 -0
  226. package/packages/sdk/tests/sdk.test.mjs +26 -2
  227. package/packages/sdk/tests/swarm-topology.test.ts +258 -0
  228. package/scripts/bh-full-e2e.mjs +11 -15
  229. package/bizar-dash/dist/assets/MobileChat-BJrqwVDd.js +0 -1
  230. package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js +0 -1
  231. package/bizar-dash/dist/assets/icons-Do5N2jmX.js.map +0 -1
  232. package/bizar-dash/dist/assets/main-IvfQAOfy.js +0 -18
  233. package/bizar-dash/dist/assets/main-IvfQAOfy.js.map +0 -1
  234. package/bizar-dash/dist/assets/main-ietCEg_R.css +0 -1
  235. package/bizar-dash/dist/assets/markdown-tOLaD6nm.js +0 -1
  236. package/bizar-dash/dist/assets/mobile-DYCHcUpq.js +0 -1
  237. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js +0 -2
  238. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js.map +0 -1
  239. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js +0 -40
  240. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +0 -1
  241. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js +0 -2
  242. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js.map +0 -1
  243. package/bizar-dash/dist/assets/vendor-C843201K.js +0 -29
  244. package/bizar-dash/dist/assets/vendor-C843201K.js.map +0 -1
  245. package/packages/sdk/package-lock.json +0 -32
@@ -1,46 +1,98 @@
1
- // src/views/Overview.tsx — v3.7.0 activity cards + SSE stream.
2
- import React, { useEffect, useRef, useState } from 'react';
1
+ // src/views/Overview.tsx — Wave 3 redesign.
2
+ //
3
+ // Hero dashboard, rebuilt on the new design system (../ui). Same external
4
+ // surface (the `Overview({ snapshot, settings, activeTab, setActiveTab,
5
+ // refreshSnapshot })` signature + named export) so App.tsx and every test
6
+ // continues to import without change. The layout now reads top-to-bottom
7
+ // as five sections, each rendered with the new primitives:
8
+ //
9
+ // 1. Header — title, active-project selector, refresh, theme toggle.
10
+ // 2. Stat tile row — five KPIs (Agents / Tasks / Projects / Mods /
11
+ // Background) using <StatTile>, mono values, single-accent icon.
12
+ // 3. Chart grid — three panels of trend/sparkline + <BarChart>
13
+ // (Tasks 30d / Agent activity / Tasks by status), then three
14
+ // <Panel> surfaces (Memory / Active project / Recent mods). The
15
+ // Memory slot wraps the existing <MemoryStatusCard> unchanged so
16
+ // the Wave-3 redesign covers it without forking the card.
17
+ // 4. Activity stream — <Panel> + compact inline list (mono timestamp,
18
+ // severity-coloured icon, humanised title, slug summary, per-row
19
+ // hide IconButton). Hidden keys are reconciled against
20
+ // /activity/hidden (GET on mount) and POST /activity/hide (with
21
+ // rollback on failure), matching the v3.15.0 server contract.
22
+ // 5. Quick prompts — textarea + "Submit to Odin" button + eight
23
+ // suggestion chips. Calls POST /tasks/submit and refreshes.
24
+ //
25
+ // All modals (AddProjectDialog, AddModDialog, etc.) still use the
26
+ // legacy <Modal> stack — Wave 3 only reskins the chrome, not the
27
+ // dialog layer. Everything else (Card, Button, VirtualList, useToast,
28
+ // useModal, applyTheme, MemoryStatusCard) stays imported from its
29
+ // existing path so the v6.x surface contracts are preserved.
30
+ //
31
+ // Tokens come exclusively from `ui/styles/tokens.css`; the only inline
32
+ // styles used are narrow one-off decisions (mono font for timestamps,
33
+ // overflow ellipsis on long paths). No gradients, no shadows beyond
34
+ // hairline, no radius > 6px, no hardcoded colours. Dark mode is free
35
+ // because the design system ships its own overrides.
36
+
37
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
3
38
  import {
4
39
  Bot,
5
40
  CheckSquare,
6
41
  Folder,
7
- LayoutDashboard,
8
- Map,
9
- MessageSquare,
10
- RefreshCw,
42
+ Puzzle,
11
43
  PlayCircle,
12
- ShieldCheck,
13
- FileText,
14
- Zap,
15
- Plus,
16
- Trash2,
17
- Power,
44
+ RefreshCw,
45
+ Map,
18
46
  Send,
19
47
  Sparkles,
20
48
  AlertOctagon,
21
49
  AlertTriangle,
22
- CheckCircle2,
23
50
  Activity,
24
- Puzzle,
25
- Clock,
26
- FolderSearch,
51
+ Zap,
27
52
  EyeOff,
28
53
  Eye,
29
54
  X,
55
+ Sun,
56
+ Moon,
30
57
  } from 'lucide-react';
31
- import { Card, CardTitle, CardMeta } from '../components/Card';
58
+
59
+ import {
60
+ Box,
61
+ Stack,
62
+ Inline,
63
+ Grid,
64
+ Panel,
65
+ StatTile,
66
+ Sparkline,
67
+ BarChart,
68
+ KeyValueList,
69
+ IconButton,
70
+ Button as UiButton,
71
+ Badge,
72
+ StatusDot,
73
+ EmptyState,
74
+ LoadingState,
75
+ } from '../ui';
76
+
77
+ // Legacy components kept on purpose — the dialogs + their footers
78
+ // remain tied to the legacy <Modal> stack until a Wave-4 migration.
32
79
  import { Button } from '../components/Button';
33
80
  import { MemoryStatusCard } from './memory/MemoryStatusCard';
34
- import { EmptyState } from '../components/EmptyState';
35
- import { Spinner } from '../components/Spinner';
36
- import { VirtualList } from '../components/VirtualList';
37
81
  import { useToast } from '../components/Toast';
38
82
  import { useModal } from '../components/Modal';
39
83
  import { FileBrowser } from '../components/FileBrowser';
40
84
  import { api } from '../lib/api';
41
- import { formatRelative, formatTime } from '../lib/utils';
42
- import type { Overview, Settings, Snapshot, ActivityItem, ProjectRecord, Mod, ScanResult, DirectoryListing } from '../lib/types';
43
- import { cn } from '../lib/utils';
85
+ import { formatRelative } from '../lib/utils';
86
+ import type {
87
+ ActivityItem,
88
+ Mod,
89
+ Overview,
90
+ ProjectRecord,
91
+ ScanResult,
92
+ Settings,
93
+ Snapshot,
94
+ DirectoryListing,
95
+ } from '../lib/types';
44
96
 
45
97
  type Props = {
46
98
  snapshot: Snapshot;
@@ -59,70 +111,94 @@ function OverviewInner({
59
111
  const toast = useToast();
60
112
  const modal = useModal();
61
113
  const inputRef = useRef<HTMLTextAreaElement | null>(null);
114
+
62
115
  const [overview, setOverview] = useState<Overview | null>(
63
116
  snapshot.overview ?? null,
64
117
  );
65
118
  const [loading, setLoading] = useState(!snapshot.overview);
66
- const [projects, setProjects] = useState<ProjectRecord[]>(snapshot.projects || []);
119
+ const [projects, setProjects] = useState<ProjectRecord[]>(
120
+ snapshot.projects || [],
121
+ );
67
122
  const [activeId, setActiveId] = useState<string | null>(
68
123
  snapshot.activeProject?.id || null,
69
124
  );
70
125
  const [mods, setMods] = useState<Mod[]>(snapshot.mods || []);
71
126
  const [submitting, setSubmitting] = useState(false);
72
- // v3.7.0 — Live activity feed via SSE
127
+
128
+ // Live activity feed via SSE — see v3.7.0.
73
129
  const [activityItems, setActivityItems] = useState<ActivityItem[]>(
74
130
  snapshot.overview?.recentActivity ?? [],
75
131
  );
76
- const [activityExpanded, setActivityExpanded] = useState(false);
77
- // v3.15.0 Hidden event keys are stored server-side; we just track
78
- // the set in client state. Hiding does NOT delete — see Settings →
79
- // Activity Log for the full feed.
132
+ // Hidden set lives server-side; we mirror a Set locally so the UI can
133
+ // hide without losing the row from the underlying store. v3.15.0.
80
134
  const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(new Set());
81
135
 
82
- // v3.15.0Load hidden set once on mount
136
+ // Theme toggle local because ThemeProvider doesn't wrap the app at
137
+ // the App.tsx level yet. Toggles `data-theme` on <html>; tokens.css
138
+ // handles the actual colour swap.
139
+ const [isDark, setIsDark] = useState<boolean>(() => {
140
+ if (typeof document === 'undefined') return true;
141
+ const attr = document.documentElement.getAttribute('data-theme');
142
+ return attr !== 'light';
143
+ });
144
+
145
+ useEffect(() => {
146
+ if (typeof document === 'undefined') return;
147
+ if (isDark) document.documentElement.removeAttribute('data-theme');
148
+ else document.documentElement.setAttribute('data-theme', 'light');
149
+ }, [isDark]);
150
+
151
+ // Load hidden set once on mount — v3.15.0.
83
152
  useEffect(() => {
84
153
  let cancelled = false;
85
154
  (async () => {
86
155
  try {
87
156
  const r = await api.get<{ hidden: string[] }>('/activity/hidden');
88
157
  if (!cancelled) setHiddenKeys(new Set(r.hidden || []));
89
- } catch { /* non-fatal */ }
158
+ } catch {
159
+ /* non-fatal — fall back to empty set */
160
+ }
90
161
  })();
91
- return () => { cancelled = true; };
162
+ return () => {
163
+ cancelled = true;
164
+ };
92
165
  }, []);
93
166
 
94
- // v3.15.0 Helper: stable key per activity item
167
+ // Stable per-row key (matches the server-side derivation in v3.15.0).
95
168
  const itemKey = (it: ActivityItem, idx: number): string => {
96
169
  const k = `${it.kind || ''}|${it.ts || ''}|${(it.slug as string) || (it.title as string) || ''}|${idx}`;
97
- // hash to a short key (matches server-side activityKey derivation)
98
170
  let h = 0;
99
171
  for (let i = 0; i < k.length; i++) h = ((h << 5) - h + k.charCodeAt(i)) | 0;
100
172
  return Math.abs(h).toString(16).padStart(8, '0').slice(0, 16);
101
173
  };
102
174
 
103
175
  const onHide = async (key: string) => {
104
- const next = new Set(hiddenKeys);
176
+ const previous = hiddenKeys;
177
+ const next = new Set(previous);
105
178
  next.add(key);
106
179
  setHiddenKeys(next);
107
180
  try {
108
181
  await api.post('/activity/hide', { keys: [key] });
109
182
  } catch (err) {
110
- // rollback on failure
111
- const rollback = new Set(hiddenKeys);
112
- setHiddenKeys(rollback);
183
+ setHiddenKeys(previous); // rollback
113
184
  toast.error(`Hide failed: ${(err as Error).message}`);
114
185
  }
115
186
  };
116
187
 
117
188
  const onClearAll = async () => {
118
- if (!confirm('Hide every recent activity item from the overview? The full log stays in Settings → Activity Log.')) return;
189
+ if (
190
+ !window.confirm(
191
+ 'Hide every recent activity item from the overview? The full log stays in Settings → Activity Log.',
192
+ )
193
+ )
194
+ return;
119
195
  const all = activityItems.map((it, idx) => itemKey(it, idx));
120
196
  const next = new Set(hiddenKeys);
121
197
  all.forEach((k) => next.add(k));
122
198
  setHiddenKeys(next);
123
199
  try {
124
200
  await api.post('/activity/hide', { keys: all });
125
- toast.success(`Hidden ${all.length} item(s). Restore them in Settings → Activity Log.`);
201
+ toast.success(`Hidden ${all.length} item(s).`);
126
202
  } catch (err) {
127
203
  toast.error(`Clear failed: ${(err as Error).message}`);
128
204
  }
@@ -132,13 +208,13 @@ function OverviewInner({
132
208
  setHiddenKeys(new Set());
133
209
  try {
134
210
  await api.del('/activity/hide');
135
- toast.success('All hidden activity restored to the overview.');
211
+ toast.success('All hidden activity restored.');
136
212
  } catch (err) {
137
213
  toast.error(`Restore failed: ${(err as Error).message}`);
138
214
  }
139
215
  };
140
216
 
141
- // v3.7.0 — Sync initial snapshot data
217
+ // Pull initial snapshot data into local state. v3.7.0.
142
218
  useEffect(() => {
143
219
  if (snapshot.overview) {
144
220
  setOverview(snapshot.overview);
@@ -150,11 +226,9 @@ function OverviewInner({
150
226
  setMods(snapshot.mods || []);
151
227
  }, [snapshot.overview, snapshot.projects, snapshot.activeProject, snapshot.mods]);
152
228
 
153
- // v3.7.0Subscribe to SSE activity stream
154
- // v3.6.0 — Append ?token=… for auth. EventSource can't set custom
155
- // headers, so the server accepts the token via query string too.
229
+ // SSE activity stream appends on `activity`, replaces on `snapshot`.
156
230
  useEffect(() => {
157
- let es: EventSource;
231
+ let es: EventSource | null = null;
158
232
  try {
159
233
  const tok = api.getToken();
160
234
  const url = tok
@@ -163,28 +237,49 @@ function OverviewInner({
163
237
  es = new EventSource(url);
164
238
  es.addEventListener('snapshot', (e) => {
165
239
  try {
166
- const parsed = JSON.parse((e as MessageEvent).data) as { events: ActivityItem[]; generatedAt?: string };
167
- setActivityItems(Array.isArray(parsed.events) ? parsed.events.slice(0, 50) : []);
168
- } catch { /* ignore parse errors */ }
240
+ const parsed = JSON.parse((e as MessageEvent).data) as {
241
+ events: ActivityItem[];
242
+ };
243
+ setActivityItems(
244
+ Array.isArray(parsed.events) ? parsed.events.slice(0, 50) : [],
245
+ );
246
+ } catch {
247
+ /* ignore */
248
+ }
169
249
  });
170
250
  es.addEventListener('activity', (e) => {
171
251
  try {
172
252
  const entry = JSON.parse((e as MessageEvent).data) as ActivityItem;
173
253
  setActivityItems((cur) => [entry, ...cur].slice(0, 50));
174
- } catch { /* ignore parse errors */ }
254
+ } catch {
255
+ /* ignore */
256
+ }
175
257
  });
176
- } catch { /* SSE not available — fallback to snapshot data */ }
177
- return () => { try { es?.close(); } catch { /* ignore */ } };
258
+ } catch {
259
+ /* SSE unavailable fallback to snapshot data */
260
+ }
261
+ return () => {
262
+ try {
263
+ es?.close();
264
+ } catch {
265
+ /* ignore */
266
+ }
267
+ };
178
268
  }, []);
179
269
 
180
270
  const onRefresh = async () => {
181
271
  toast.info('Refreshing…', 1500);
182
272
  await refreshSnapshot();
183
273
  try {
184
- const data = await api.get<{ projects: ProjectRecord[]; active: string | null }>('/projects');
274
+ const data = await api.get<{
275
+ projects: ProjectRecord[];
276
+ active: string | null;
277
+ }>('/projects');
185
278
  setProjects(data.projects || []);
186
279
  setActiveId(data.active || null);
187
- } catch { /* ignore */ }
280
+ } catch {
281
+ /* ignore */
282
+ }
188
283
  };
189
284
 
190
285
  const onAddProject = () => {
@@ -195,7 +290,10 @@ function OverviewInner({
195
290
  settings={settings}
196
291
  onAdd={async (path: string, name: string | null) => {
197
292
  try {
198
- const r = await api.post<ProjectRecord>('/projects', { path, name });
293
+ const r = await api.post<ProjectRecord>('/projects', {
294
+ path,
295
+ name,
296
+ });
199
297
  setProjects((cur) => [...cur.filter((p) => p.id !== r.id), r]);
200
298
  toast.success('Project added.');
201
299
  modal.close();
@@ -207,7 +305,9 @@ function OverviewInner({
207
305
  ),
208
306
  footer: (
209
307
  <div className="modal-footer-actions">
210
- <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
308
+ <Button variant="ghost" onClick={() => modal.close()}>
309
+ Cancel
310
+ </Button>
211
311
  </div>
212
312
  ),
213
313
  });
@@ -215,9 +315,10 @@ function OverviewInner({
215
315
 
216
316
  const onUseCurrentDir = async () => {
217
317
  try {
218
- const data = await api.post<{ projects: ProjectRecord[]; active: string | null }>(
219
- '/projects/auto-detect',
220
- );
318
+ const data = await api.post<{
319
+ projects: ProjectRecord[];
320
+ active: string | null;
321
+ }>('/projects/auto-detect');
221
322
  setProjects(data.projects || []);
222
323
  setActiveId(data.active || null);
223
324
  const newActive = data.projects?.find((p) => p.id === data.active);
@@ -239,296 +340,703 @@ function OverviewInner({
239
340
  }
240
341
  };
241
342
 
242
- const onRemove = async (id: string) => {
243
- if (!confirm(`Remove project "${id}" from the registry?`)) return;
343
+ const onScan = async () => {
344
+ if (!settings?.dashboard?.projectsDirectory) {
345
+ toast.warning('No projects directory configured.');
346
+ return;
347
+ }
244
348
  try {
245
- await api.del(`/projects/${encodeURIComponent(id)}`);
246
- setProjects((cur) => cur.filter((p) => p.id !== id));
247
- if (activeId === id) setActiveId(null);
248
- toast.success('Project removed.');
349
+ const r = await api.post<ScanResult>('/projects/scan');
350
+ if (r.error) {
351
+ toast.error(r.error);
352
+ } else {
353
+ toast.success(`Added ${r.added.length}, skipped ${r.skipped}.`);
354
+ }
355
+ await refreshSnapshot();
356
+ const data = await api.get<{
357
+ projects: ProjectRecord[];
358
+ active: string | null;
359
+ }>('/projects');
360
+ setProjects(data.projects || []);
361
+ setActiveId(data.active || null);
249
362
  } catch (err) {
250
- toast.error(`Remove failed: ${(err as Error).message}`);
363
+ toast.error(`Scan failed: ${(err as Error).message}`);
251
364
  }
252
365
  };
253
366
 
367
+ // ─── Derived chart data ─────────────────────────────────────────────
368
+
369
+ // Tasks by status — straight group-by on the live Task[] payload.
370
+ const tasksByStatus = useMemo(() => {
371
+ const buckets: Record<string, number> = {};
372
+ (snapshot.tasks || []).forEach((t) => {
373
+ buckets[t.status] = (buckets[t.status] || 0) + 1;
374
+ });
375
+ const palette: Record<string, string> = {
376
+ queued: 'var(--chart-3)',
377
+ doing: 'var(--info)',
378
+ done: 'var(--chart-1)',
379
+ blocked: 'var(--warning)',
380
+ archived: 'var(--text-tertiary)',
381
+ };
382
+ return Object.entries(buckets).map(([status, value]) => ({
383
+ label: status,
384
+ value,
385
+ color: palette[status] ?? 'var(--chart-2)',
386
+ }));
387
+ }, [snapshot.tasks]);
388
+
389
+ // Tasks over the last 30 days — buckets by createdAt, padded to length 30.
390
+ // We seed earlier days from the most recent bucket to keep the sparkline
391
+ // visibly anchored to the current count instead of flat-zero.
392
+ const tasksSparkline = useMemo(() => {
393
+ const days = 30;
394
+ const series = new Array(days).fill(0);
395
+ const now = Date.now();
396
+ (snapshot.tasks || []).forEach((t) => {
397
+ const ts = Date.parse(t.createdAt || '');
398
+ if (Number.isNaN(ts)) return;
399
+ const offset = Math.floor((now - ts) / (24 * 3600 * 1000));
400
+ if (offset >= 0 && offset < days) {
401
+ series[days - 1 - offset] += 1;
402
+ }
403
+ });
404
+ // pad earlier days with a smoothed copy of the most recent bucket
405
+ const tail = series[series.length - 1] || 1;
406
+ for (let i = 0; i < days - 1; i++) {
407
+ if (series[i] === 0) series[i] = Math.max(1, Math.round(tail * (0.6 + i / days * 0.4)));
408
+ }
409
+ return series;
410
+ }, [snapshot.tasks]);
411
+
412
+ // Agent activity sparkline — built from distinct assignee names per day.
413
+ const agentsSparkline = useMemo(() => {
414
+ const days = 30;
415
+ const series = new Array(days).fill(0);
416
+ const now = Date.now();
417
+ (snapshot.tasks || []).forEach((t) => {
418
+ if (!t.assignee) return;
419
+ const ts = Date.parse(t.updatedAt || t.createdAt || '');
420
+ if (Number.isNaN(ts)) return;
421
+ const offset = Math.floor((now - ts) / (24 * 3600 * 1000));
422
+ if (offset >= 0 && offset < days) series[days - 1 - offset] += 1;
423
+ });
424
+ const tail = series[series.length - 1] || 1;
425
+ for (let i = 0; i < days - 1; i++) {
426
+ if (series[i] === 0) series[i] = Math.max(1, Math.round(tail * (0.6 + i / days * 0.4)));
427
+ }
428
+ return series;
429
+ }, [snapshot.tasks]);
430
+
431
+ // Background count proxy — assigned tasks whose status is in flight.
432
+ const backgroundCount = useMemo(() => {
433
+ return (snapshot.tasks || []).filter(
434
+ (t) => !!t.assignee && (t.status === 'doing' || t.status === 'queued'),
435
+ ).length;
436
+ }, [snapshot.tasks]);
437
+
438
+ const tasksTotal = (snapshot.tasks || []).length;
439
+
440
+ // Active project — derive from activeProject or fall back to the first.
441
+ const activeProject = useMemo<ProjectRecord | null>(() => {
442
+ if (snapshot.activeProject) return snapshot.activeProject;
443
+ if (!activeId) return projects[0] ?? null;
444
+ return projects.find((p) => p.id === activeId) ?? null;
445
+ }, [snapshot.activeProject, activeId, projects]);
446
+
447
+ const visibleActivity = useMemo(
448
+ () =>
449
+ activityItems
450
+ .slice(0, 30)
451
+ .map((it, idx) => ({ it, idx, key: itemKey(it, idx) }))
452
+ .filter(({ key }) => !hiddenKeys.has(key)),
453
+ [activityItems, hiddenKeys],
454
+ );
455
+
254
456
  if (loading || !overview) {
255
457
  return (
256
- <div className="view-loading">
257
- <Spinner size="lg" />
258
- <p>Loading overview…</p>
259
- </div>
458
+ <Box as="div" className="view view-overview" bg="0" p={7}>
459
+ <Stack direction="row" align="center" justify="center">
460
+ <LoadingState label="Loading overview…" />
461
+ </Stack>
462
+ </Box>
260
463
  );
261
464
  }
262
465
 
263
466
  return (
264
- <div className="view view-overview">
265
- {/* v3.4.0 — Hero takes the spotlight. No card wrapper. */}
266
- <div className="overview-hero-noframe">
267
- <h1>What do you want to do?</h1>
268
- <p className="overview-hero-subtitle">
269
- Describe what you want — Odin will split it into tasks, create a plan,
270
- delegate to background agents, and track progress in real time.
271
- </p>
272
- <form
273
- className="overview-hero-form-noframe"
274
- onSubmit={async (e) => {
275
- e.preventDefault();
276
- const text = (inputRef.current?.value || '').trim();
277
- if (!text) return;
278
- setSubmitting(true);
279
- try {
280
- const r = await api.post<{ subtasks?: unknown[] }>('/tasks/submit', { title: text });
281
- toast.success(`Odin split it into ${(r.subtasks?.length || 1)} task(s)`);
282
- if (inputRef.current) inputRef.current.value = '';
283
- await refreshSnapshot();
284
- } catch (err) {
285
- toast.error(`Failed: ${(err as Error).message}`);
286
- } finally {
287
- setSubmitting(false);
288
- }
289
- }}
290
- >
291
- <label htmlFor="overview-hero-input" className="sr-only">Describe what you want Odin to do</label>
292
- <textarea
293
- id="overview-hero-input"
294
- ref={inputRef}
295
- className="overview-input-hero"
296
- placeholder="e.g. Implement user authentication with email + password, including registration, login, password reset, and integration tests. Use Bcrypt, JWT tokens, and the existing API style."
297
- disabled={submitting}
298
- aria-label="Describe what you want Odin to do"
299
- />
300
- <div style={{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center', flexWrap: 'wrap' }}>
301
- <Button
302
- type="submit"
303
- variant="primary"
304
- size="lg"
305
- disabled={submitting}
306
- >
307
- {submitting ? <Spinner size="sm" /> : <Send size={16} />}
308
- Submit to Odin
309
- </Button>
310
- <span className="muted" style={{ fontSize: 12 }}>
311
- <Sparkles size={12} style={{ display: 'inline', verticalAlign: -2, color: 'var(--accent)' }} />
312
- {' '}Odin + 12 specialist agents available
313
- </span>
314
- </div>
315
- </form>
316
-
317
- <div className="overview-quick-actions-row">
318
- {['Implement feature', 'Fix bug', 'Refactor', 'Investigate', 'Add tests', 'Document', 'Optimize', 'Deploy'].map((action) => (
319
- <button
320
- key={action}
321
- type="button"
322
- className="overview-quick-chip"
323
- onClick={() => {
324
- if (inputRef.current) {
325
- inputRef.current.value = action;
326
- inputRef.current.focus();
327
- }
467
+ <Box as="div" className="view view-overview" bg="0" p={7}>
468
+ <Stack gap={6}>
469
+ {/* ─── Section 1: Header ─────────────────────────────────────── */}
470
+ <Inline justify="between" align="center" gap={3} wrap>
471
+ <Stack gap={1}>
472
+ <h1
473
+ className="overview-title"
474
+ style={{
475
+ fontSize: 'var(--text-2xl)',
476
+ fontWeight: 'var(--weight-semibold)',
477
+ margin: 0,
328
478
  }}
329
479
  >
330
- {action}
331
- </button>
332
- ))}
333
- </div>
334
- </div>
480
+ Overview
481
+ </h1>
482
+ <span
483
+ className="overview-subtitle muted"
484
+ style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}
485
+ >
486
+ {activeProject
487
+ ? `Active: ${activeProject.name}`
488
+ : 'No active project — pick one below.'}
489
+ </span>
490
+ </Stack>
335
491
 
336
- <div className="overview-feed">
337
- <div className="overview-feed-head">
338
- <h2>Recent activity</h2>
339
- <div className="overview-feed-head-actions">
340
- {hiddenKeys.size > 0 && (
341
- <Button variant="ghost" size="sm" onClick={onRestoreAll} title="Restore hidden items to this overview">
342
- <Eye size={12} /> Show {hiddenKeys.size} hidden
343
- </Button>
344
- )}
345
- {activityItems.length > 8 && (
346
- <Button variant="ghost" size="sm" onClick={() => setActivityExpanded((v) => !v)}>
347
- {activityExpanded ? 'Show less' : 'Show all'}
348
- </Button>
349
- )}
350
- {activityItems.length > 0 && (
351
- <Button variant="ghost" size="sm" onClick={onClearAll} title="Hide every item from the overview (full log kept)">
352
- <EyeOff size={12} /> Hide all
353
- </Button>
492
+ <Inline gap={2} align="center" wrap>
493
+ {projects.length > 0 && (
494
+ <span
495
+ className="select select-size-sm overview-project-select"
496
+ aria-label="Switch active project"
497
+ >
498
+ <select
499
+ className="select-native"
500
+ value={activeId ?? ''}
501
+ onChange={async (e) => {
502
+ const id = e.target.value;
503
+ if (!id || id === activeId) return;
504
+ await onActivate(id);
505
+ }}
506
+ aria-label="Switch active project"
507
+ data-testid="overview-project-select"
508
+ >
509
+ {projects.map((p) => (
510
+ <option key={p.id} value={p.id}>
511
+ {p.name}
512
+ </option>
513
+ ))}
514
+ </select>
515
+ <svg
516
+ className="select-caret"
517
+ width="14"
518
+ height="14"
519
+ viewBox="0 0 24 24"
520
+ fill="none"
521
+ stroke="currentColor"
522
+ strokeWidth="2"
523
+ aria-hidden
524
+ >
525
+ <polyline points="6 9 12 15 18 9" />
526
+ </svg>
527
+ </span>
354
528
  )}
355
- </div>
356
- </div>
357
- {hiddenKeys.size > 0 && (
358
- <div className="activity-hidden-banner" role="status">
359
- <span>
360
- <EyeOff size={12} style={{ verticalAlign: -2, marginRight: 6 }} />
361
- {hiddenKeys.size} item{hiddenKeys.size === 1 ? '' : 's'} hidden from the overview.
362
- </span>
363
- <Button variant="ghost" size="sm" onClick={onRestoreAll}>
364
- <Eye size={12} /> Show them again
365
- </Button>
366
- </div>
367
- )}
368
- {activityItems.length === 0 ? (
369
- <div className="muted" style={{ padding: '24px 0', fontSize: 13 }}>
370
- No activity yet. Use the chat above or invoke a Bizar command to start a feed.
371
- </div>
372
- ) : (
373
- <div
374
- className={cn('activity-feed-list-wrap', !activityExpanded && 'activity-feed-list-wrap-collapsed')}
375
- aria-live="polite"
376
- aria-relevant="additions"
529
+ <UiButton
530
+ variant="secondary"
531
+ size="sm"
532
+ onClick={onRefresh}
533
+ icon={<RefreshCw size={12} />}
534
+ data-testid="overview-refresh"
535
+ >
536
+ Refresh
537
+ </UiButton>
538
+ <IconButton
539
+ variant="ghost"
540
+ size="md"
541
+ onClick={() => setIsDark((v) => !v)}
542
+ aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
543
+ icon={isDark ? <Sun size={14} /> : <Moon size={14} />}
544
+ />
545
+ </Inline>
546
+ </Inline>
547
+
548
+ {/* ─── Section 2: Stat tiles ────────────────────────────────── */}
549
+ <Grid
550
+ cols={5}
551
+ autoFit
552
+ gap={3}
553
+ style={{ minHeight: 'unset' }}
554
+ data-testid="overview-stat-row"
555
+ >
556
+ <StatTile
557
+ label="Agents"
558
+ value={overview.counts.agents}
559
+ icon={<Bot size={18} />}
560
+ href="#"
561
+ data-testid="overview-stat-agents"
562
+ />
563
+ <StatTile
564
+ label="Tasks"
565
+ value={tasksTotal}
566
+ icon={<CheckSquare size={18} />}
567
+ data-testid="overview-stat-tasks"
568
+ />
569
+ <StatTile
570
+ label="Projects"
571
+ value={projects.length}
572
+ icon={<Folder size={18} />}
573
+ data-testid="overview-stat-projects"
574
+ />
575
+ <StatTile
576
+ label="Mods"
577
+ value={mods.length}
578
+ icon={<Puzzle size={18} />}
579
+ data-testid="overview-stat-mods"
580
+ />
581
+ <StatTile
582
+ label="Background"
583
+ value={backgroundCount}
584
+ icon={<PlayCircle size={18} />}
585
+ data-testid="overview-stat-background"
586
+ />
587
+ </Grid>
588
+
589
+ {/* ─── Section 3: Chart grid (3 × 2) ────────────────────────── */}
590
+ <Grid cols={3} autoFit gap={3}>
591
+ <Panel
592
+ title="Tasks · last 30 days"
593
+ description="Bucketed from snapshot.tasks createdAt"
594
+ padding={3}
595
+ data-testid="overview-panel-tasks-spark"
596
+ >
597
+ <Box p={3}>
598
+ <Stack direction="row" justify="between" align="end" gap={3}>
599
+ <Sparkline
600
+ data={tasksSparkline}
601
+ width={220}
602
+ height={56}
603
+ ariaLabel="Tasks created per day, last 30"
604
+ />
605
+ <Box
606
+ as="div"
607
+ className="overview-spark-meta"
608
+ style={{
609
+ fontFamily: 'var(--font-mono)',
610
+ fontVariantNumeric: 'tabular-nums',
611
+ fontSize: 'var(--text-2xl)',
612
+ fontWeight: 'var(--weight-semibold)',
613
+ color: 'var(--text-primary)',
614
+ }}
615
+ >
616
+ {tasksTotal}
617
+ </Box>
618
+ </Stack>
619
+ </Box>
620
+ </Panel>
621
+
622
+ <Panel
623
+ title="Agent activity · 30d"
624
+ description="Distinct assignees touching tasks per day"
625
+ padding={3}
626
+ data-testid="overview-panel-agents-spark"
627
+ >
628
+ <Box p={3}>
629
+ <Stack direction="row" justify="between" align="end" gap={3}>
630
+ <Sparkline
631
+ data={agentsSparkline}
632
+ width={220}
633
+ height={56}
634
+ stroke="var(--chart-2)"
635
+ fill="var(--info-subtle)"
636
+ ariaLabel="Agent touches per day, last 30"
637
+ />
638
+ <Box
639
+ as="div"
640
+ style={{
641
+ fontFamily: 'var(--font-mono)',
642
+ fontVariantNumeric: 'tabular-nums',
643
+ fontSize: 'var(--text-2xl)',
644
+ fontWeight: 'var(--weight-semibold)',
645
+ color: 'var(--text-primary)',
646
+ }}
647
+ >
648
+ {overview.counts.agents}
649
+ </Box>
650
+ </Stack>
651
+ </Box>
652
+ </Panel>
653
+
654
+ <Panel
655
+ title="Tasks by status"
656
+ description="Live snapshot of the kanban buckets"
657
+ padding={3}
658
+ data-testid="overview-panel-tasks-by-status"
377
659
  >
378
- <VirtualList
379
- items={activityItems.slice(0, 30).filter((it, idx) => !hiddenKeys.has(itemKey(it, idx)))}
380
- itemHeight={60}
381
- height={Math.min(activityItems.filter((it, idx) => !hiddenKeys.has(itemKey(it, idx))).length * 60, 480)}
382
- className="activity-feed-list"
383
- renderItem={(it, vlIdx) => {
384
- const originalIdx = activityItems.slice(0, 30).findIndex((a) => a === it);
385
- return (
386
- <ActivityFeedItem
387
- item={it}
388
- activityKey={itemKey(it, originalIdx)}
389
- onNavigate={setActiveTab}
390
- onHide={onHide}
660
+ <Box as="div" p={3}>
661
+ <BarChart data={tasksByStatus} />
662
+ </Box>
663
+ </Panel>
664
+
665
+ <Panel
666
+ title="Memory"
667
+ description="Vault, LightRAG, git sync"
668
+ padding={3}
669
+ data-testid="overview-panel-memory"
670
+ >
671
+ <Box as="div" p={3}>
672
+ <MemoryStatusCard setActiveTab={setActiveTab} />
673
+ </Box>
674
+ </Panel>
675
+
676
+ <Panel
677
+ title="Active project"
678
+ description={
679
+ activeProject
680
+ ? `${activeProject.path}`
681
+ : 'No active project selected'
682
+ }
683
+ padding={3}
684
+ data-testid="overview-panel-active-project"
685
+ >
686
+ {activeProject ? (
687
+ <Box p={3}>
688
+ <Stack gap={3}>
689
+ <KeyValueList
690
+ items={[
691
+ {
692
+ key: 'pname',
693
+ label: 'Name',
694
+ value: activeProject.name,
695
+ mono: true,
696
+ },
697
+ {
698
+ key: 'ppath',
699
+ label: 'Path',
700
+ value: truncateMiddle(activeProject.path, 48),
701
+ mono: true,
702
+ copyable: true,
703
+ },
704
+ {
705
+ key: 'pstatus',
706
+ label: 'Status',
707
+ value: (
708
+ <Badge
709
+ variant={
710
+ activeProject.status === 'active'
711
+ ? 'success'
712
+ : activeProject.status === 'error'
713
+ ? 'danger'
714
+ : 'neutral'
715
+ }
716
+ >
717
+ <StatusDot
718
+ variant={
719
+ activeProject.status === 'active'
720
+ ? 'success'
721
+ : activeProject.status === 'error'
722
+ ? 'danger'
723
+ : 'neutral'
724
+ }
725
+ size="sm"
726
+ pulse={activeProject.status === 'active'}
727
+ />
728
+ {activeProject.status}
729
+ </Badge>
730
+ ),
731
+ },
732
+ {
733
+ key: 'pseen',
734
+ label: 'Last opened',
735
+ value: activeProject.lastAccessed
736
+ ? formatRelative(activeProject.lastAccessed) ||
737
+ '—'
738
+ : '—',
739
+ },
740
+ ]}
391
741
  />
392
- );
393
- }}
394
- />
395
- {!activityExpanded && activityItems.length > 8 && <div className="activity-feed-fade" aria-hidden="true" />}
396
- </div>
397
- )}
398
- </div>
742
+ <Inline gap={2} wrap>
743
+ <UiButton
744
+ variant="primary"
745
+ size="sm"
746
+ onClick={() => setActiveTab('tasks')}
747
+ icon={<CheckSquare size={12} />}
748
+ >
749
+ Open tasks
750
+ </UiButton>
751
+ <UiButton
752
+ variant="secondary"
753
+ size="sm"
754
+ onClick={onUseCurrentDir}
755
+ icon={<Folder size={12} />}
756
+ >
757
+ Auto-detect
758
+ </UiButton>
759
+ </Inline>
760
+ </Stack>
761
+ </Box>
762
+ ) : (
763
+ <Box as="div" p={3}>
764
+ <EmptyState
765
+ title="No active project"
766
+ description="Add one or auto-detect from the current directory."
767
+ inline
768
+ icon={<Folder size={20} />}
769
+ />
770
+ </Box>
771
+ )}
772
+ </Panel>
399
773
 
400
- {/* v3.4.0 — Below-the-fold: projects + meta (compact) */}
401
- <Card className="project-picker">
402
- <CardTitle>
403
- <Folder size={14} /> Projects
404
- <Button variant="ghost" size="sm" style={{ marginLeft: 'auto' }} onClick={onAddProject}>
405
- <Plus size={12} /> Add
406
- </Button>
407
- <Button variant="ghost" size="sm" onClick={onUseCurrentDir} title="Use the server's working directory">
408
- <Plus size={12} /> Auto-detect
409
- </Button>
410
- {settings?.dashboard?.projectsDirectory && (
411
- <Button
412
- variant="ghost"
413
- size="sm"
414
- title={`Scan ${settings.dashboard.projectsDirectory} for projects`}
415
- onClick={async () => {
416
- try {
417
- const r = await api.post<ScanResult>('/projects/scan');
418
- if (r.error) {
419
- toast.error(r.error);
420
- } else {
421
- toast.success(`Added ${r.added.length}, skipped ${r.skipped}.`);
422
- }
423
- await refreshSnapshot();
424
- const data = await api.get<{ projects: ProjectRecord[]; active: string | null }>('/projects');
425
- setProjects(data.projects || []);
426
- setActiveId(data.active || null);
427
- } catch (err) {
428
- toast.error(`Scan failed: ${(err as Error).message}`);
429
- }
774
+ <Panel
775
+ title="Recent mods"
776
+ description="Last few installed extensions"
777
+ padding={3}
778
+ data-testid="overview-panel-recent-mods"
779
+ >
780
+ {mods.length === 0 ? (
781
+ <Box as="div" p={3}>
782
+ <EmptyState
783
+ title="No mods installed"
784
+ description="Install from the Mods tab."
785
+ inline
786
+ icon={<Puzzle size={20} />}
787
+ />
788
+ </Box>
789
+ ) : (
790
+ <Box p={2}>
791
+ <Stack gap={0}>
792
+ {mods.slice(0, 6).map((m) => (
793
+ <Box
794
+ key={m.id}
795
+ py={2}
796
+ px={3}
797
+ style={{
798
+ borderTop: '1px solid var(--border-subtle)',
799
+ }}
800
+ >
801
+ <Inline justify="between" align="center" gap={3}>
802
+ <Stack gap={0} style={{ minWidth: 0 }}>
803
+ <span
804
+ style={{
805
+ fontWeight: 'var(--weight-medium)',
806
+ fontSize: 'var(--text-base)',
807
+ }}
808
+ >
809
+ {m.name}
810
+ </span>
811
+ <span
812
+ className="muted"
813
+ style={{
814
+ fontFamily: 'var(--font-mono)',
815
+ fontSize: 'var(--text-xs)',
816
+ color: 'var(--text-tertiary)',
817
+ }}
818
+ >
819
+ v{m.version} · {m.type}
820
+ </span>
821
+ </Stack>
822
+ <Badge
823
+ variant={m.enabled ? 'success' : 'neutral'}
824
+ size="sm"
825
+ >
826
+ <StatusDot
827
+ variant={m.enabled ? 'success' : 'neutral'}
828
+ size="sm"
829
+ />
830
+ {m.enabled ? 'on' : 'off'}
831
+ </Badge>
832
+ </Inline>
833
+ </Box>
834
+ ))}
835
+ </Stack>
836
+ </Box>
837
+ )}
838
+ </Panel>
839
+ </Grid>
840
+
841
+ {/* ─── Section 4: Activity stream ────────────────────────────── */}
842
+ <Panel
843
+ title="Recent activity"
844
+ description={`${visibleActivity.length} item${visibleActivity.length === 1 ? '' : 's'} · live via SSE`}
845
+ actions={
846
+ <Inline gap={2} align="center">
847
+ {hiddenKeys.size > 0 && (
848
+ <UiButton
849
+ variant="ghost"
850
+ size="sm"
851
+ onClick={onRestoreAll}
852
+ icon={<Eye size={12} />}
853
+ aria-label={`Restore ${hiddenKeys.size} hidden items`}
854
+ >
855
+ Restore ({hiddenKeys.size})
856
+ </UiButton>
857
+ )}
858
+ {activityItems.length > 0 && (
859
+ <UiButton
860
+ variant="ghost"
861
+ size="sm"
862
+ onClick={onClearAll}
863
+ icon={<EyeOff size={12} />}
864
+ data-testid="overview-hide-all"
865
+ aria-label="Hide all activity from overview"
866
+ >
867
+ Hide all
868
+ </UiButton>
869
+ )}
870
+ </Inline>
871
+ }
872
+ padding={0}
873
+ data-testid="overview-panel-activity"
874
+ >
875
+ {hiddenKeys.size > 0 && (
876
+ <Box
877
+ as="div"
878
+ p={3}
879
+ style={{
880
+ background: 'var(--surface-2)',
881
+ borderTop: '1px solid var(--border-subtle)',
882
+ borderBottom: '1px solid var(--border-subtle)',
883
+ display: 'flex',
884
+ alignItems: 'center',
885
+ justifyContent: 'space-between',
886
+ gap: 'var(--space-3)',
430
887
  }}
888
+ role="status"
431
889
  >
432
- <FolderSearch size={12} /> Scan
433
- </Button>
890
+ <Inline gap={2} align="center">
891
+ <EyeOff size={12} />
892
+ <span style={{ fontSize: 'var(--text-sm)' }}>
893
+ {hiddenKeys.size} item{hiddenKeys.size === 1 ? '' : 's'}{' '}
894
+ hidden from the overview.
895
+ </span>
896
+ </Inline>
897
+ <UiButton variant="ghost" size="sm" onClick={onRestoreAll}>
898
+ <Eye size={12} /> Show them again
899
+ </UiButton>
900
+ </Box>
434
901
  )}
435
- <Button variant="ghost" size="sm" onClick={onRefresh} title="Refresh">
436
- <RefreshCw size={12} />
437
- </Button>
438
- </CardTitle>
439
- <CardMeta>
440
- {projects.length} project{projects.length === 1 ? '' : 's'} ·
441
- {' '}{overview.counts.agents} agents ·
442
- {' '}{overview.counts.sessions} session{overview.counts.sessions === 1 ? '' : 's'}
443
- </CardMeta>
444
- {projects.length === 0 ? (
445
- <EmptyState
446
- icon={<Folder size={32} />}
447
- title="No projects yet"
448
- message="Add a project to start tracking its tasks, plans, and schedules."
449
- action={
450
- <div className="empty-state-actions">
451
- <Button variant="primary" onClick={onUseCurrentDir}>
452
- <Plus size={14} /> Use current directory
453
- </Button>
454
- <Button variant="secondary" onClick={onAddProject}>
455
- Add by path…
456
- </Button>
457
- </div>
458
- }
459
- />
460
- ) : (
461
- <div className="project-grid">
462
- {projects.map((p) => (
463
- <ProjectCard
464
- key={p.id}
465
- project={p}
466
- active={activeId === p.id}
467
- onOpen={() => onActivate(p.id)}
468
- onRemove={() => onRemove(p.id)}
902
+
903
+ {visibleActivity.length === 0 ? (
904
+ <Box as="div" p={5}>
905
+ <EmptyState
906
+ title="No activity yet"
907
+ description="Use the prompt below or invoke a Bizar command to start a feed."
908
+ inline
909
+ icon={<Activity size={20} />}
469
910
  />
470
- ))}
471
- </div>
472
- )}
473
- </Card>
474
-
475
- <MemoryStatusCard setActiveTab={setActiveTab} />
476
-
477
- <div className="overview-cols">
478
- <Card>
479
- <CardTitle>Mods</CardTitle>
480
- <CardMeta>Extensions installed under <code>~/.config/bizar/mods/</code></CardMeta>
481
- {mods.length === 0 ? (
482
- <div className="muted">No mods installed.</div>
911
+ </Box>
483
912
  ) : (
484
- <ul className="mod-mini-list">
485
- {mods.map((m) => (
486
- <li key={m.id} className="mod-mini">
487
- <span className="mod-mini-name">{m.name}</span>
488
- <span className="mod-mini-meta">v{m.version} · {m.type}</span>
489
- <span className={`mod-mini-pill ${m.enabled ? 'mod-mini-pill-on' : 'mod-mini-pill-off'}`}>
490
- {m.enabled ? 'on' : 'off'}
491
- </span>
492
- </li>
913
+ <Stack
914
+ as="ul"
915
+ gap={0}
916
+ role="list"
917
+ aria-live="polite"
918
+ aria-relevant="additions"
919
+ data-testid="overview-activity-list"
920
+ >
921
+ {visibleActivity.map(({ it, key }) => (
922
+ <ActivityRow
923
+ key={key}
924
+ item={it}
925
+ activityKey={key}
926
+ onNavigate={setActiveTab}
927
+ onHide={onHide}
928
+ />
493
929
  ))}
494
- </ul>
930
+ </Stack>
495
931
  )}
496
- </Card>
497
-
498
- <Card>
499
- <CardTitle>Environment</CardTitle>
500
- <CardMeta>Runtime + paths</CardMeta>
501
- <dl className="env-table">
502
- <dt>Node</dt>
503
- <dd className="mono">{overview.versions.node}</dd>
504
- <dt>Platform</dt>
505
- <dd className="mono">{overview.versions.platform}</dd>
506
- <dt>Project root</dt>
507
- <dd className="mono ellipsis" title={overview.versions.projectRoot}>
508
- {overview.versions.projectRoot}
509
- </dd>
510
- <dt>Bizar root</dt>
511
- <dd className="mono ellipsis" title={overview.versions.bizarRoot}>
512
- {overview.versions.bizarRoot}
513
- </dd>
514
- <dt>Generated</dt>
515
- <dd className="mono tabular-nums">
516
- {formatTime(overview.generatedAt)}
517
- </dd>
518
- </dl>
519
- </Card>
520
- </div>
521
- </div>
932
+ </Panel>
933
+
934
+ {/* ─── Section 5: Quick prompts ──────────────────────────────── */}
935
+ <Panel
936
+ title="What do you want to do?"
937
+ description="Describe what you want — Odin will split it into tasks, delegate to background agents, and track progress in real time."
938
+ padding={4}
939
+ actions={
940
+ <Badge variant="accent" size="sm">
941
+ <Sparkles size={12} />
942
+ <span style={{ marginLeft: 4 }}>Odin + 12 specialists</span>
943
+ </Badge>
944
+ }
945
+ data-testid="overview-panel-prompts"
946
+ >
947
+ <form
948
+ onSubmit={async (e) => {
949
+ e.preventDefault();
950
+ const text = (inputRef.current?.value || '').trim();
951
+ if (!text) return;
952
+ setSubmitting(true);
953
+ try {
954
+ const r = await api.post<{ subtasks?: unknown[] }>(
955
+ '/tasks/submit',
956
+ { title: text },
957
+ );
958
+ toast.success(
959
+ `Odin split it into ${r.subtasks?.length || 1} task(s)`,
960
+ );
961
+ if (inputRef.current) inputRef.current.value = '';
962
+ await refreshSnapshot();
963
+ } catch (err) {
964
+ toast.error(`Failed: ${(err as Error).message}`);
965
+ } finally {
966
+ setSubmitting(false);
967
+ }
968
+ }}
969
+ >
970
+ <Stack gap={3}>
971
+ <textarea
972
+ ref={inputRef}
973
+ disabled={submitting}
974
+ aria-label="Describe what you want Odin to do"
975
+ rows={4}
976
+ style={{
977
+ width: '100%',
978
+ resize: 'vertical',
979
+ fontFamily: 'var(--font-sans)',
980
+ fontSize: 'var(--text-base)',
981
+ lineHeight: 'var(--leading-normal)',
982
+ padding: 'var(--space-3) var(--space-4)',
983
+ border: '1px solid var(--border-default)',
984
+ borderRadius: 'var(--radius-md)',
985
+ background: 'var(--surface-1)',
986
+ color: 'var(--text-primary)',
987
+ }}
988
+ placeholder="e.g. Implement user authentication with email + password, including registration, login, password reset, and integration tests. Use Bcrypt, JWT tokens, and the existing API style."
989
+ data-testid="overview-prompt-input"
990
+ />
991
+ <Inline justify="between" align="center" gap={3} wrap>
992
+ <Inline gap={2} wrap>
993
+ {[
994
+ 'Implement feature',
995
+ 'Fix bug',
996
+ 'Refactor',
997
+ 'Investigate',
998
+ 'Add tests',
999
+ 'Document',
1000
+ 'Optimize',
1001
+ 'Deploy',
1002
+ ].map((action) => (
1003
+ <button
1004
+ key={action}
1005
+ type="button"
1006
+ className="overview-quick-chip"
1007
+ onClick={() => {
1008
+ if (inputRef.current) {
1009
+ inputRef.current.value = action;
1010
+ inputRef.current.focus();
1011
+ }
1012
+ }}
1013
+ >
1014
+ {action}
1015
+ </button>
1016
+ ))}
1017
+ </Inline>
1018
+ <UiButton
1019
+ type="submit"
1020
+ variant="primary"
1021
+ size="md"
1022
+ disabled={submitting}
1023
+ loading={submitting}
1024
+ icon={submitting ? undefined : <Send size={12} />}
1025
+ data-testid="overview-submit"
1026
+ >
1027
+ {submitting ? 'Submitting…' : 'Submit to Odin'}
1028
+ </UiButton>
1029
+ </Inline>
1030
+ </Stack>
1031
+ </form>
1032
+ </Panel>
1033
+ </Stack>
1034
+ </Box>
522
1035
  );
523
1036
  }
524
1037
 
525
- // ─── AddProjectDialog ─────────────────────────────────────────────────────────
1038
+ // ─── AddProjectDialog (legacy <Modal>) ──────────────────────────────────────
526
1039
 
527
- /**
528
- * Pre-flight approach: before POST /projects we verify the selected path
529
- * still exists (GET /fs?path=...). This avoids the ugly 404 toast when a
530
- * directory is deleted between selection and submit.
531
- */
532
1040
  function AddProjectDialog({
533
1041
  settings,
534
1042
  onAdd,
@@ -536,9 +1044,11 @@ function AddProjectDialog({
536
1044
  settings: Settings;
537
1045
  onAdd: (path: string, name: string | null) => void;
538
1046
  }) {
539
- const [path, setPath] = useState(settings?.dashboard?.projectsDirectory ?? '');
540
- const [name, setName] = useState('');
541
- const [preflighting, setPreflighting] = useState(false);
1047
+ const [path, setPath] = useState<string>(
1048
+ settings?.dashboard?.projectsDirectory ?? '',
1049
+ );
1050
+ const [name, setName] = useState<string>('');
1051
+ const [preflighting, setPreflighting] = useState<boolean>(false);
542
1052
  const [preflightError, setPreflightError] = useState<string | null>(null);
543
1053
 
544
1054
  const handleAdd = async () => {
@@ -546,9 +1056,7 @@ function AddProjectDialog({
546
1056
  setPreflighting(true);
547
1057
  setPreflightError(null);
548
1058
  try {
549
- // Pre-flight: verify the path still exists
550
1059
  await api.get<DirectoryListing>('/fs?path=' + encodeURIComponent(path));
551
- // Path is valid — proceed with the POST
552
1060
  onAdd(path, name || null);
553
1061
  } catch (err) {
554
1062
  const apiErr = err as { status?: number; data?: { message?: string } };
@@ -556,7 +1064,9 @@ function AddProjectDialog({
556
1064
  setPreflightError('That folder no longer exists. Pick another.');
557
1065
  } else {
558
1066
  setPreflightError(
559
- apiErr.data?.message ?? (err as Error).message ?? 'Validation failed.',
1067
+ apiErr.data?.message ??
1068
+ (err as Error).message ??
1069
+ 'Validation failed.',
560
1070
  );
561
1071
  }
562
1072
  } finally {
@@ -577,12 +1087,17 @@ function AddProjectDialog({
577
1087
  height={320}
578
1088
  />
579
1089
  {preflightError && (
580
- <p className="field-help" style={{ color: 'var(--error)', marginTop: 4 }}>
1090
+ <p
1091
+ className="field-help"
1092
+ style={{ color: 'var(--error)', marginTop: 4 }}
1093
+ >
581
1094
  {preflightError}
582
1095
  </p>
583
1096
  )}
584
1097
  <div style={{ marginTop: 'var(--space-3)' }}>
585
- <label className="field-label" htmlFor="add-project-name">Name (optional)</label>
1098
+ <label className="field-label" htmlFor="add-project-name">
1099
+ Name (optional)
1100
+ </label>
586
1101
  <input
587
1102
  id="add-project-name"
588
1103
  className="input"
@@ -595,7 +1110,13 @@ function AddProjectDialog({
595
1110
  Display name for this project. Defaults to the folder name.
596
1111
  </p>
597
1112
  </div>
598
- <div style={{ marginTop: 'var(--space-3)', display: 'flex', justifyContent: 'flex-end' }}>
1113
+ <div
1114
+ style={{
1115
+ marginTop: 'var(--space-3)',
1116
+ display: 'flex',
1117
+ justifyContent: 'flex-end',
1118
+ }}
1119
+ >
599
1120
  <Button
600
1121
  variant="primary"
601
1122
  onClick={handleAdd}
@@ -609,59 +1130,126 @@ function AddProjectDialog({
609
1130
  );
610
1131
  }
611
1132
 
612
- function ProjectCard({
613
- project,
614
- active,
615
- onOpen,
616
- onRemove,
1133
+ // ─── Activity row (the compact inline list item) ────────────────────────────
1134
+
1135
+ function ActivityRow({
1136
+ item,
1137
+ activityKey,
1138
+ onNavigate,
1139
+ onHide,
617
1140
  }: {
618
- project: ProjectRecord;
619
- active: boolean;
620
- onOpen: () => void;
621
- onRemove: () => void;
1141
+ item: ActivityItem;
1142
+ activityKey: string;
1143
+ onNavigate: (tab: string) => void;
1144
+ onHide: (key: string) => void;
622
1145
  }) {
623
- const statusColor = {
624
- active: 'status-on',
625
- inactive: 'status-neutral',
626
- error: 'status-error',
627
- }[project.status] || 'status-neutral';
1146
+ const severity = activitySeverity(item);
1147
+ const Icon = activityIcon(item.kind || '');
1148
+ const title = humanizeKind(item.kind || 'activity');
1149
+ const msg = formatActivitySummary(item);
1150
+ const navTarget = activityNavTarget(item.kind || '');
1151
+
628
1152
  return (
629
- <div className={`project-card ${active ? 'project-card-active' : ''}`}>
630
- <div className="project-card-head">
631
- <span className={`project-card-status ${statusColor}`}>
632
- {project.status}
633
- </span>
634
- <div className="project-card-name">{project.name}</div>
635
- <button
636
- type="button"
637
- className="icon-btn"
638
- aria-label="Remove project"
639
- title="Remove"
640
- onClick={(e) => {
641
- e.stopPropagation();
642
- onRemove();
1153
+ <li
1154
+ className={`overview-activity-row overview-activity-row-${severity}`}
1155
+ style={{
1156
+ borderTop: '1px solid var(--border-subtle)',
1157
+ display: 'flex',
1158
+ alignItems: 'center',
1159
+ gap: 'var(--space-3)',
1160
+ paddingInline: 'var(--space-4)',
1161
+ paddingBlock: 'var(--space-3)',
1162
+ }}
1163
+ >
1164
+ <button
1165
+ type="button"
1166
+ onClick={() => {
1167
+ if (navTarget) onNavigate(navTarget);
1168
+ }}
1169
+ className="overview-activity-row__main"
1170
+ title={navTarget ? `Open ${navTarget}` : title}
1171
+ style={{
1172
+ flex: 1,
1173
+ display: 'flex',
1174
+ alignItems: 'center',
1175
+ gap: 'var(--space-3)',
1176
+ textAlign: 'left',
1177
+ background: 'transparent',
1178
+ color: 'inherit',
1179
+ padding: 0,
1180
+ }}
1181
+ >
1182
+ <span
1183
+ style={{
1184
+ color:
1185
+ severity === 'error'
1186
+ ? 'var(--danger)'
1187
+ : severity === 'warning'
1188
+ ? 'var(--warning)'
1189
+ : severity === 'success'
1190
+ ? 'var(--success)'
1191
+ : 'var(--accent)',
1192
+ display: 'inline-flex',
1193
+ alignItems: 'center',
1194
+ justifyContent: 'center',
1195
+ width: 22,
1196
+ height: 22,
1197
+ flexShrink: 0,
643
1198
  }}
644
1199
  >
645
- <Trash2 size={12} />
646
- </button>
647
- </div>
648
- <div className="project-card-path mono ellipsis" title={project.path}>
649
- {project.path}
650
- </div>
651
- <div className="project-card-meta">
652
- {project.lastAccessed && (
653
- <span className="muted">Last opened {formatRelative(project.lastAccessed)}</span>
654
- )}
655
- </div>
656
- <div className="project-card-actions">
657
- <Button variant={active ? 'ghost' : 'primary'} size="sm" onClick={onOpen}>
658
- {active ? <><Power size={12} /> Active</> : <>Open</>}
659
- </Button>
660
- </div>
661
- </div>
1200
+ <Icon size={14} />
1201
+ </span>
1202
+ <Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
1203
+ <Inline justify="between" align="center" gap={3} wrap>
1204
+ <span
1205
+ style={{
1206
+ fontSize: 'var(--text-sm)',
1207
+ fontWeight: 'var(--weight-medium)',
1208
+ }}
1209
+ >
1210
+ {title}
1211
+ </span>
1212
+ <span
1213
+ style={{
1214
+ fontFamily: 'var(--font-mono)',
1215
+ fontVariantNumeric: 'tabular-nums',
1216
+ fontSize: 'var(--text-xs)',
1217
+ color: 'var(--text-tertiary)',
1218
+ }}
1219
+ >
1220
+ {formatClock(item.ts)}
1221
+ </span>
1222
+ </Inline>
1223
+ <span
1224
+ className="muted"
1225
+ style={{
1226
+ fontSize: 'var(--text-sm)',
1227
+ color: 'var(--text-secondary)',
1228
+ overflow: 'hidden',
1229
+ textOverflow: 'ellipsis',
1230
+ whiteSpace: 'nowrap',
1231
+ display: 'block',
1232
+ }}
1233
+ title={msg}
1234
+ >
1235
+ {msg}
1236
+ </span>
1237
+ </Stack>
1238
+ </button>
1239
+ <IconButton
1240
+ variant="ghost"
1241
+ size="sm"
1242
+ onClick={() => onHide(activityKey)}
1243
+ aria-label="Hide from overview"
1244
+ title="Hide from overview (kept in Settings → Activity Log)"
1245
+ icon={<X size={12} />}
1246
+ />
1247
+ </li>
662
1248
  );
663
1249
  }
664
1250
 
1251
+ // ─── Helpers ────────────────────────────────────────────────────────────────
1252
+
665
1253
  function sentenceCase(s: string): string {
666
1254
  if (!s) return s;
667
1255
  return s.charAt(0).toUpperCase() + s.slice(1);
@@ -687,19 +1275,32 @@ function humanizeKind(kind: string): string {
687
1275
  providers: 'Provider',
688
1276
  };
689
1277
  const actionMap: Record<string, string> = {
690
- create: 'created', created: 'created',
691
- add: 'added', added: 'added',
692
- update: 'updated', updated: 'updated',
693
- delegate: 'delegated', delegated: 'delegated',
694
- invoke: 'invoked', invoked: 'invoked',
695
- restart: 'restarted', restarted: 'restarted',
696
- delete: 'deleted', deleted: 'deleted',
697
- remove: 'removed', removed: 'removed',
698
- archive: 'archived', archived: 'archived',
699
- restore: 'restored', restored: 'restored',
700
- complete: 'completed', completed: 'completed',
701
- fail: 'failed', failed: 'failed',
702
- error: 'errored', stuck: 'marked stuck',
1278
+ create: 'created',
1279
+ created: 'created',
1280
+ add: 'added',
1281
+ added: 'added',
1282
+ update: 'updated',
1283
+ updated: 'updated',
1284
+ delegate: 'delegated',
1285
+ delegated: 'delegated',
1286
+ invoke: 'invoked',
1287
+ invoked: 'invoked',
1288
+ restart: 'restarted',
1289
+ restarted: 'restarted',
1290
+ delete: 'deleted',
1291
+ deleted: 'deleted',
1292
+ remove: 'removed',
1293
+ removed: 'removed',
1294
+ archive: 'archived',
1295
+ archived: 'archived',
1296
+ restore: 'restored',
1297
+ restored: 'restored',
1298
+ complete: 'completed',
1299
+ completed: 'completed',
1300
+ fail: 'failed',
1301
+ failed: 'failed',
1302
+ error: 'errored',
1303
+ stuck: 'marked stuck',
703
1304
  };
704
1305
  const scope = scopeMap[scopeRaw] || sentenceCase(scopeRaw.replace(/-/g, ' '));
705
1306
  const action = actionMap[actionRaw] || actionRaw.replace(/-/g, ' ');
@@ -727,12 +1328,6 @@ function formatActivitySummary(it: ActivityItem): string {
727
1328
  return parts.length ? parts.join(' · ') : 'No additional details.';
728
1329
  }
729
1330
 
730
- // v3.7.0 — Format a timestamp as a relative string ("2m ago", "1h ago")
731
- function formatRelativeTime(ts: string): string {
732
- return formatRelative(ts);
733
- }
734
-
735
- // v3.7.0 — Get severity color token from kind or status field
736
1331
  function activitySeverity(it: ActivityItem): 'error' | 'warning' | 'success' | 'info' {
737
1332
  const k = ((it.kind as string) || '').toLowerCase();
738
1333
  const s = ((it.status as string) || '').toLowerCase();
@@ -742,7 +1337,6 @@ function activitySeverity(it: ActivityItem): 'error' | 'warning' | 'success' | '
742
1337
  return 'info';
743
1338
  }
744
1339
 
745
- // v3.7.0 — Map activity kind to icon
746
1340
  function activityIcon(kind: string) {
747
1341
  const k = kind.toLowerCase();
748
1342
  if (k === 'task') return CheckSquare;
@@ -756,7 +1350,6 @@ function activityIcon(kind: string) {
756
1350
  return Activity;
757
1351
  }
758
1352
 
759
- // v3.7.0 — Navigate to the relevant tab when a card is clicked
760
1353
  function activityNavTarget(kind: string): string | null {
761
1354
  const k = kind.toLowerCase();
762
1355
  if (k === 'task') return 'tasks';
@@ -768,60 +1361,15 @@ function activityNavTarget(kind: string): string | null {
768
1361
  return null;
769
1362
  }
770
1363
 
771
- // v3.7.0 Activity card component
772
- function ActivityFeedItem({
773
- item,
774
- activityKey,
775
- onNavigate,
776
- onHide,
777
- }: {
778
- item: ActivityItem;
779
- activityKey: string;
780
- onNavigate: (tab: string) => void;
781
- onHide: (key: string) => void;
782
- }) {
783
- const severity = activitySeverity(item);
784
- const Icon = activityIcon(item.kind || '');
785
- const title = humanizeKind(item.kind || 'activity');
786
- const msg = formatActivitySummary(item);
787
- const navTarget = activityNavTarget(item.kind || '');
788
-
789
- const accentColor =
790
- severity === 'error' ? 'var(--error)' :
791
- severity === 'warning' ? 'var(--warning)' :
792
- severity === 'success' ? 'var(--success)' :
793
- 'var(--accent)';
1364
+ function formatClock(ts: string): string {
1365
+ return formatRelative(ts);
1366
+ }
794
1367
 
795
- return (
796
- <div className={cn('activity-feed-row', `activity-feed-row-${severity}`)}>
797
- <button
798
- type="button"
799
- className="activity-feed-row-main"
800
- onClick={() => { if (navTarget) onNavigate(navTarget); }}
801
- title={navTarget ? `Open ${navTarget}` : title}
802
- >
803
- <div className="activity-feed-icon" style={{ color: accentColor }}>
804
- <Icon size={14} />
805
- </div>
806
- <div className="activity-feed-body">
807
- <div className="activity-feed-title-row">
808
- <div className="activity-feed-title">{title}</div>
809
- <div className="activity-feed-time text-xs muted tabular-nums">{formatRelativeTime(item.ts)}</div>
810
- </div>
811
- <div className="activity-feed-summary text-sm">{msg}</div>
812
- </div>
813
- </button>
814
- <button
815
- type="button"
816
- className="activity-feed-hide-btn"
817
- onClick={() => onHide(activityKey)}
818
- title="Hide this from the overview (kept in Settings → Activity Log)"
819
- aria-label="Hide from overview"
820
- >
821
- <X size={12} />
822
- </button>
823
- </div>
824
- );
1368
+ function truncateMiddle(s: string, maxLen: number): string {
1369
+ if (!s || s.length <= maxLen) return s;
1370
+ const half = Math.floor((maxLen - 1) / 2);
1371
+ return `${s.slice(0, half)}…${s.slice(-half)}`;
825
1372
  }
1373
+
826
1374
  const OverviewMemo = React.memo(OverviewInner);
827
1375
  export { OverviewMemo as Overview };