@polderlabs/bizar 3.23.1 → 4.2.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 (361) hide show
  1. package/README.md +83 -16
  2. package/bizar-dash/.bizar/activity.log +3 -0
  3. package/bizar-dash/.bizar/graph/.graphify_analysis.json +1540 -0
  4. package/bizar-dash/.bizar/graph/.graphify_labels.json +1 -0
  5. package/bizar-dash/.bizar/graph/GRAPH_REPORT.md +404 -0
  6. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/0661b816358db84dfdb7f10443db8a61152fd20b3fee950caf8fbf32920a1790.json +1 -0
  7. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/16da06d774142a6e5d74829ad982531286a6193ef798de8f593db029e123ec32.json +1 -0
  8. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/27e5697c01254c69c64c5a33896669073b40afb5ba88375102ae77fa5404136d.json +1 -0
  9. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/532997898027254aa4c0932243dc71d2f55888181232b65974961934997dc85b.json +1 -0
  10. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/84e3451a64c7f99f0cd6a88b0748d7426780486f53ab4935b7476b28c08f8293.json +1 -0
  11. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/88e5ea5f3e78fa69353fe9e6a8d1a05391d54605e25600cb125531eb5a6432ff.json +1 -0
  12. package/bizar-dash/.bizar/graph/cache/stat-index.json +1 -0
  13. package/bizar-dash/.bizar/graph/graph.html +307 -0
  14. package/bizar-dash/.bizar/graph/graph.json +40267 -0
  15. package/bizar-dash/.bizar/graph/manifest.json +762 -0
  16. package/bizar-dash/.graphifyignore +40 -0
  17. package/bizar-dash/.obsidian/README.md +14 -0
  18. package/bizar-dash/.obsidian/daily/2026-06-25.md +9 -0
  19. package/bizar-dash/.obsidian/vault.json +7 -0
  20. package/bizar-dash/.od-skills/web-prototype-e1429c1737/SKILL.md +103 -0
  21. package/bizar-dash/.od-skills/web-prototype-e1429c1737/assets/template.html +338 -0
  22. package/bizar-dash/.od-skills/web-prototype-e1429c1737/example.html +81 -0
  23. package/bizar-dash/.od-skills/web-prototype-e1429c1737/open-design.json +172 -0
  24. package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/checklist.md +44 -0
  25. package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/layouts.md +247 -0
  26. package/bizar-dash/CHANGELOG.md +474 -0
  27. package/bizar-dash/DESIGN.md +136 -0
  28. package/bizar-dash/dist/assets/main-B3RgW6FS.js +331 -0
  29. package/bizar-dash/dist/assets/main-B3RgW6FS.js.map +1 -0
  30. package/bizar-dash/dist/assets/main-DlJ8wgLR.css +1 -0
  31. package/bizar-dash/dist/assets/mobile-CsZQAswA.css +1 -0
  32. package/bizar-dash/dist/assets/mobile-DbxIw8BG.js +2 -0
  33. package/bizar-dash/dist/assets/mobile-DbxIw8BG.js.map +1 -0
  34. package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js +354 -0
  35. package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js.map +1 -0
  36. package/bizar-dash/dist/index.html +40 -0
  37. package/bizar-dash/dist/mobile.html +22 -0
  38. package/bizar-dash/plans/debug-plan/comments.json +1 -0
  39. package/bizar-dash/plans/debug-plan/meta.json +10 -0
  40. package/bizar-dash/plans/debug-plan/plan.json +174 -0
  41. package/bizar-dash/scripts/smoke-bg-retry.mjs +246 -0
  42. package/bizar-dash/src/cli/dashboard-ports.mjs +488 -0
  43. package/bizar-dash/src/cli.mjs +522 -0
  44. package/bizar-dash/src/server/activity-log.mjs +174 -0
  45. package/bizar-dash/src/server/agents-store.mjs +465 -0
  46. package/bizar-dash/src/server/api.mjs +138 -0
  47. package/bizar-dash/src/server/artifact-template.html +810 -0
  48. package/bizar-dash/src/server/artifacts-store.mjs +733 -0
  49. package/bizar-dash/src/server/auth.mjs +381 -0
  50. package/bizar-dash/src/server/background-store.mjs +447 -0
  51. package/bizar-dash/src/server/bg-poller.mjs +305 -0
  52. package/bizar-dash/src/server/bg-retry.mjs +574 -0
  53. package/bizar-dash/src/server/browser.mjs +40 -0
  54. package/bizar-dash/src/server/diagnostics-store.mjs +148 -0
  55. package/bizar-dash/src/server/dialog-poller.mjs +105 -0
  56. package/bizar-dash/src/server/dialog-store.mjs +177 -0
  57. package/bizar-dash/src/server/glyphs/mdx-compiler.mjs +583 -0
  58. package/bizar-dash/src/server/lib/path-safe.mjs +283 -0
  59. package/bizar-dash/src/server/memory-git.mjs +329 -0
  60. package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
  61. package/bizar-dash/src/server/memory-schema.mjs +145 -0
  62. package/bizar-dash/src/server/memory-secrets.mjs +84 -0
  63. package/bizar-dash/src/server/memory-store.mjs +471 -0
  64. package/bizar-dash/src/server/mod-security.mjs +398 -0
  65. package/bizar-dash/src/server/mods-loader.mjs +1067 -0
  66. package/bizar-dash/src/server/notifications-store.mjs +243 -0
  67. package/bizar-dash/src/server/obsidian-store.mjs +318 -0
  68. package/bizar-dash/src/server/opencode-runner.mjs +279 -0
  69. package/bizar-dash/src/server/pair-store.mjs +138 -0
  70. package/bizar-dash/src/server/projects-store.mjs +364 -0
  71. package/bizar-dash/src/server/providers-store.mjs +789 -0
  72. package/bizar-dash/src/server/routes/_shared.mjs +415 -0
  73. package/bizar-dash/src/server/routes/activity.mjs +110 -0
  74. package/bizar-dash/src/server/routes/agents.mjs +134 -0
  75. package/bizar-dash/src/server/routes/artifacts.mjs +279 -0
  76. package/bizar-dash/src/server/routes/auth.mjs +69 -0
  77. package/bizar-dash/src/server/routes/background.mjs +165 -0
  78. package/bizar-dash/src/server/routes/chat.mjs +485 -0
  79. package/bizar-dash/src/server/routes/config.mjs +129 -0
  80. package/bizar-dash/src/server/routes/diagnostics.mjs +50 -0
  81. package/bizar-dash/src/server/routes/dialogs.mjs +86 -0
  82. package/bizar-dash/src/server/routes/fs.mjs +429 -0
  83. package/bizar-dash/src/server/routes/history.mjs +78 -0
  84. package/bizar-dash/src/server/routes/memory.mjs +658 -0
  85. package/bizar-dash/src/server/routes/misc.mjs +89 -0
  86. package/bizar-dash/src/server/routes/mods.mjs +359 -0
  87. package/bizar-dash/src/server/routes/notifications.mjs +54 -0
  88. package/bizar-dash/src/server/routes/obsidian.mjs +239 -0
  89. package/bizar-dash/src/server/routes/opencode-sessions.mjs +55 -0
  90. package/bizar-dash/src/server/routes/overview.mjs +91 -0
  91. package/bizar-dash/src/server/routes/pair.mjs +67 -0
  92. package/bizar-dash/src/server/routes/projects.mjs +175 -0
  93. package/bizar-dash/src/server/routes/providers.mjs +47 -0
  94. package/bizar-dash/src/server/routes/schedules.mjs +104 -0
  95. package/bizar-dash/src/server/routes/settings.mjs +51 -0
  96. package/bizar-dash/src/server/routes/skills.mjs +76 -0
  97. package/bizar-dash/src/server/routes/tasks.mjs +468 -0
  98. package/bizar-dash/src/server/routes/themes.mjs +51 -0
  99. package/bizar-dash/src/server/routes-v2/auth.mjs +59 -0
  100. package/bizar-dash/src/server/routes-v2/events.mjs +107 -0
  101. package/bizar-dash/src/server/routes-v2/health.mjs +21 -0
  102. package/bizar-dash/src/server/routes-v2/index.mjs +90 -0
  103. package/bizar-dash/src/server/routes-v2/sessions.mjs +111 -0
  104. package/bizar-dash/src/server/schedules-runner.mjs +369 -0
  105. package/bizar-dash/src/server/schedules-store.mjs +268 -0
  106. package/bizar-dash/src/server/search-store.mjs +220 -0
  107. package/bizar-dash/src/server/serve-info.mjs +684 -0
  108. package/bizar-dash/src/server/server.mjs +796 -0
  109. package/bizar-dash/src/server/settings-store.mjs +45 -0
  110. package/bizar-dash/src/server/skills-store.mjs +300 -0
  111. package/bizar-dash/src/server/state.mjs +437 -0
  112. package/bizar-dash/src/server/tailscale-store.mjs +113 -0
  113. package/bizar-dash/src/server/task-delegator.mjs +824 -0
  114. package/bizar-dash/src/server/tasks-store.mjs +508 -0
  115. package/bizar-dash/src/server/tui.mjs +923 -0
  116. package/bizar-dash/src/server/update-store.mjs +168 -0
  117. package/bizar-dash/src/server/v2-auth-file.mjs +99 -0
  118. package/bizar-dash/src/server/v2-event-bus.mjs +128 -0
  119. package/bizar-dash/src/server/watcher.mjs +81 -0
  120. package/bizar-dash/src/server/yaml.mjs +238 -0
  121. package/bizar-dash/src/web/App.tsx +718 -0
  122. package/bizar-dash/src/web/MobileApp.tsx +408 -0
  123. package/bizar-dash/src/web/components/ArtifactCreateDialog.tsx +95 -0
  124. package/bizar-dash/src/web/components/ArtifactListDialog.tsx +55 -0
  125. package/bizar-dash/src/web/components/ArtifactViewer.tsx +143 -0
  126. package/bizar-dash/src/web/components/AuditDialog.tsx +79 -0
  127. package/bizar-dash/src/web/components/BgStatusBadge.tsx +32 -0
  128. package/bizar-dash/src/web/components/Button.tsx +56 -0
  129. package/bizar-dash/src/web/components/CanvasContextMenu.tsx +68 -0
  130. package/bizar-dash/src/web/components/Card.tsx +40 -0
  131. package/bizar-dash/src/web/components/CollapsibleSection.tsx +72 -0
  132. package/bizar-dash/src/web/components/CommandDialog.tsx +66 -0
  133. package/bizar-dash/src/web/components/EmptyState.tsx +30 -0
  134. package/bizar-dash/src/web/components/FileBrowser.tsx +633 -0
  135. package/bizar-dash/src/web/components/HelpDialog.tsx +55 -0
  136. package/bizar-dash/src/web/components/KillConfirmDialog.tsx +88 -0
  137. package/bizar-dash/src/web/components/Modal.tsx +215 -0
  138. package/bizar-dash/src/web/components/Notifications.tsx +241 -0
  139. package/bizar-dash/src/web/components/SearchModal.tsx +224 -0
  140. package/bizar-dash/src/web/components/Sidebar.tsx +66 -0
  141. package/bizar-dash/src/web/components/Spinner.tsx +19 -0
  142. package/bizar-dash/src/web/components/StatusBadge.tsx +25 -0
  143. package/bizar-dash/src/web/components/Tag.tsx +28 -0
  144. package/bizar-dash/src/web/components/Toast.tsx +150 -0
  145. package/bizar-dash/src/web/components/Topbar.tsx +368 -0
  146. package/bizar-dash/src/web/components/VisualPlanDialog.tsx +58 -0
  147. package/bizar-dash/src/web/components/chat/AgentChip.tsx +68 -0
  148. package/bizar-dash/src/web/components/chat/ChatBubble.tsx +60 -0
  149. package/bizar-dash/src/web/components/chat/ChatThread.tsx +88 -0
  150. package/bizar-dash/src/web/components/chat/ChatTopBar.tsx +50 -0
  151. package/bizar-dash/src/web/components/chat/Composer.tsx +164 -0
  152. package/bizar-dash/src/web/components/chat/ConfirmModal.tsx +54 -0
  153. package/bizar-dash/src/web/components/chat/EmptyState.tsx +21 -0
  154. package/bizar-dash/src/web/components/chat/FirstRunGreeting.tsx +18 -0
  155. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +32 -0
  156. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +119 -0
  157. package/bizar-dash/src/web/components/chat/LoadingSkeleton.tsx +19 -0
  158. package/bizar-dash/src/web/components/chat/MessageBubble.tsx +60 -0
  159. package/bizar-dash/src/web/components/chat/SessionList.tsx +153 -0
  160. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +9 -0
  161. package/bizar-dash/src/web/components/chat/SuggestionCards.tsx +55 -0
  162. package/bizar-dash/src/web/components/chat/WelcomeScreen.tsx +36 -0
  163. package/bizar-dash/src/web/components/chat/index.ts +20 -0
  164. package/bizar-dash/src/web/components/chat/useAutoGrowTextarea.ts +18 -0
  165. package/bizar-dash/src/web/components/chat/useChat.ts +219 -0
  166. package/bizar-dash/src/web/components/chat/useSlashCommands.ts +47 -0
  167. package/bizar-dash/src/web/index.html +37 -0
  168. package/bizar-dash/src/web/lib/api.ts +193 -0
  169. package/bizar-dash/src/web/lib/markdown.tsx +59 -0
  170. package/bizar-dash/src/web/lib/types.ts +668 -0
  171. package/bizar-dash/src/web/lib/utils.ts +114 -0
  172. package/bizar-dash/src/web/lib/ws.ts +176 -0
  173. package/bizar-dash/src/web/main.tsx +34 -0
  174. package/bizar-dash/src/web/mobile/MobileApp.tsx +278 -0
  175. package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +38 -0
  176. package/bizar-dash/src/web/mobile/MobileTopbar.tsx +115 -0
  177. package/bizar-dash/src/web/mobile/components/MobileBottomSheet.tsx +182 -0
  178. package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +64 -0
  179. package/bizar-dash/src/web/mobile/components/MobileModal.tsx +123 -0
  180. package/bizar-dash/src/web/mobile/views/MobileActivity.tsx +273 -0
  181. package/bizar-dash/src/web/mobile/views/MobileAgents.tsx +374 -0
  182. package/bizar-dash/src/web/mobile/views/MobileArtifactCanvas.tsx +439 -0
  183. package/bizar-dash/src/web/mobile/views/MobileArtifacts.tsx +143 -0
  184. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +180 -0
  185. package/bizar-dash/src/web/mobile/views/MobileConfig.tsx +175 -0
  186. package/bizar-dash/src/web/mobile/views/MobileHistory.tsx +191 -0
  187. package/bizar-dash/src/web/mobile/views/MobileMods.tsx +149 -0
  188. package/bizar-dash/src/web/mobile/views/MobileMore.tsx +121 -0
  189. package/bizar-dash/src/web/mobile/views/MobileNotifications.tsx +137 -0
  190. package/bizar-dash/src/web/mobile/views/MobilePlans.tsx +26 -0
  191. package/bizar-dash/src/web/mobile/views/MobileSchedules.tsx +685 -0
  192. package/bizar-dash/src/web/mobile/views/MobileSearchModal.tsx +172 -0
  193. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +388 -0
  194. package/bizar-dash/src/web/mobile/views/MobileSkills.tsx +153 -0
  195. package/bizar-dash/src/web/mobile/views/MobileTasks.tsx +564 -0
  196. package/bizar-dash/src/web/mobile.html +20 -0
  197. package/bizar-dash/src/web/mobile.tsx +13 -0
  198. package/bizar-dash/src/web/styles/chat.css +198 -0
  199. package/bizar-dash/src/web/styles/glyphs.css +960 -0
  200. package/bizar-dash/src/web/styles/main.css +8855 -0
  201. package/bizar-dash/src/web/styles/mobile-chat.css +5 -0
  202. package/bizar-dash/src/web/styles/mobile.css +1960 -0
  203. package/bizar-dash/src/web/views/Activity.tsx +1203 -0
  204. package/bizar-dash/src/web/views/Agents.tsx +701 -0
  205. package/bizar-dash/src/web/views/Artifacts.tsx +1480 -0
  206. package/bizar-dash/src/web/views/BackgroundAgents.tsx +450 -0
  207. package/bizar-dash/src/web/views/Chat.tsx +190 -0
  208. package/bizar-dash/src/web/views/Config.tsx +2063 -0
  209. package/bizar-dash/src/web/views/History.tsx +335 -0
  210. package/bizar-dash/src/web/views/ModView.tsx +207 -0
  211. package/bizar-dash/src/web/views/Mods.tsx +693 -0
  212. package/bizar-dash/src/web/views/Overview.tsx +812 -0
  213. package/bizar-dash/src/web/views/Providers.tsx +302 -0
  214. package/bizar-dash/src/web/views/Schedules.tsx +802 -0
  215. package/bizar-dash/src/web/views/Settings.tsx +1787 -0
  216. package/bizar-dash/src/web/views/Skills.tsx +431 -0
  217. package/bizar-dash/src/web/views/Tasks.tsx +1523 -0
  218. package/bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx +613 -0
  219. package/bizar-dash/src/web/views/glyphs/components.tsx +1098 -0
  220. package/bizar-dash/templates/mod/FORMAT.md +76 -0
  221. package/bizar-dash/templates/mod/hello-mod/README.md +19 -0
  222. package/bizar-dash/templates/mod/hello-mod/agents/greeter.md +8 -0
  223. package/bizar-dash/templates/mod/hello-mod/commands/hello.md +6 -0
  224. package/bizar-dash/templates/mod/hello-mod/mod.json +20 -0
  225. package/bizar-dash/templates/mod/hello-mod/routes/ping.mjs +9 -0
  226. package/bizar-dash/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  227. package/bizar-dash/tests/dashboard-ports.test.mjs +138 -0
  228. package/bizar-dash/tests/graphify-mod-spawn.node.test.mjs +90 -0
  229. package/bizar-dash/tests/memory-cli.test.mjs +542 -0
  230. package/bizar-dash/tests/memory-config.test.mjs +422 -0
  231. package/bizar-dash/tests/memory-git.test.mjs +246 -0
  232. package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
  233. package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
  234. package/bizar-dash/tests/memory-schema.test.mjs +198 -0
  235. package/bizar-dash/tests/memory-secrets.test.mjs +121 -0
  236. package/bizar-dash/tests/memory-store.test.mjs +390 -0
  237. package/bizar-dash/tests/memory-sync.test.mjs +141 -0
  238. package/bizar-dash/tests/mod-instructions.node.test.mjs +188 -0
  239. package/bizar-dash/tests/mod-security.test.mjs +273 -0
  240. package/bizar-dash/tests/mod-upgrade.node.test.mjs +357 -0
  241. package/bizar-dash/tests/no-agent-browser.node.test.mjs +98 -0
  242. package/bizar-dash/tests/obsidian-back-compat.test.mjs +199 -0
  243. package/bizar-dash/tests/opencode-runner.test.mjs +172 -0
  244. package/bizar-dash/tests/path-safe.test.mjs +108 -0
  245. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +333 -0
  246. package/bizar-dash/tests/smoke-v2.mjs +212 -0
  247. package/bizar-dash/tests/strip-thinking.test.mjs +175 -0
  248. package/bizar-dash/tests/submit-feedback.test.mjs +353 -0
  249. package/bizar-dash/tests/tmux-wrap.test.mjs +145 -0
  250. package/bizar-dash/tests/yaml.test.mjs +128 -0
  251. package/bizar-dash/tsconfig.json +23 -0
  252. package/bizar-dash/vite.config.ts +27 -0
  253. package/cli/atomic.mjs +73 -0
  254. package/cli/banner.mjs +1 -1
  255. package/cli/bin.mjs +44 -47
  256. package/cli/bootstrap.mjs +1 -1
  257. package/cli/copy.mjs +25 -16
  258. package/cli/dev-link.mjs +17 -1
  259. package/cli/doctor.mjs +4 -4
  260. package/cli/doctor.test.mjs +2 -2
  261. package/cli/init.mjs +135 -1
  262. package/cli/install.mjs +31 -39
  263. package/cli/memory-constants.mjs +59 -0
  264. package/cli/memory.mjs +1436 -0
  265. package/cli/service.mjs +5 -6
  266. package/cli/utils.mjs +6 -3
  267. package/config/AGENTS.md +7 -7
  268. package/config/agents/_shared/AGENT_BASELINE.md +59 -61
  269. package/config/opencode.json +13 -38
  270. package/config/skills/memory-protocol/SKILL.md +105 -0
  271. package/config/skills/obsidian/SKILL.md +270 -65
  272. package/install.sh +59 -19
  273. package/package.json +49 -14
  274. package/packages/sdk/LICENSE +21 -0
  275. package/packages/sdk/README.md +80 -0
  276. package/packages/sdk/src/client.ts +183 -0
  277. package/packages/sdk/src/errors.ts +120 -0
  278. package/packages/sdk/src/events.ts +153 -0
  279. package/packages/sdk/src/index.ts +63 -0
  280. package/packages/sdk/src/types.ts +176 -0
  281. package/packages/sdk/src/version.ts +4 -0
  282. package/packages/sdk/tests/client.test.ts +217 -0
  283. package/packages/sdk/tests/errors.test.ts +108 -0
  284. package/packages/sdk/tests/events.test.ts +139 -0
  285. package/packages/sdk/tests/fixtures/fetch-mock.ts +152 -0
  286. package/packages/sdk/tests/fixtures/sse-mock.ts +30 -0
  287. package/packages/sdk/tsconfig.json +25 -0
  288. package/packages/sdk/vitest.config.ts +9 -0
  289. package/plugins/bizar/LICENSE +21 -0
  290. package/plugins/bizar/README.md +448 -0
  291. package/plugins/bizar/dist/index.js +29901 -0
  292. package/plugins/bizar/index.ts +1491 -0
  293. package/plugins/bizar/scripts/check-forbidden-imports.sh +33 -0
  294. package/plugins/bizar/src/background-state.ts +551 -0
  295. package/plugins/bizar/src/background.ts +1250 -0
  296. package/plugins/bizar/src/commands-impl.ts +464 -0
  297. package/plugins/bizar/src/commands.ts +1168 -0
  298. package/plugins/bizar/src/dashboard-client.ts +233 -0
  299. package/plugins/bizar/src/event-stream.ts +606 -0
  300. package/plugins/bizar/src/fingerprint.ts +120 -0
  301. package/plugins/bizar/src/handoff.ts +79 -0
  302. package/plugins/bizar/src/http-client.ts +467 -0
  303. package/plugins/bizar/src/key-rotation.ts +218 -0
  304. package/plugins/bizar/src/logger.ts +144 -0
  305. package/plugins/bizar/src/loop.ts +176 -0
  306. package/plugins/bizar/src/opencode-runner.ts +390 -0
  307. package/plugins/bizar/src/options.ts +421 -0
  308. package/plugins/bizar/src/plan-fs.ts +323 -0
  309. package/plugins/bizar/src/reasoning-clean.ts +454 -0
  310. package/plugins/bizar/src/report.ts +178 -0
  311. package/plugins/bizar/src/research-prompt.ts +35 -0
  312. package/plugins/bizar/src/serve-info.ts +228 -0
  313. package/plugins/bizar/src/serve.ts +496 -0
  314. package/plugins/bizar/src/settings.ts +349 -0
  315. package/plugins/bizar/src/state.ts +298 -0
  316. package/plugins/bizar/src/tools/bg-collect.ts +104 -0
  317. package/plugins/bizar/src/tools/bg-get-comments.ts +239 -0
  318. package/plugins/bizar/src/tools/bg-kill.ts +87 -0
  319. package/plugins/bizar/src/tools/bg-spawn.ts +401 -0
  320. package/plugins/bizar/src/tools/bg-status.ts +99 -0
  321. package/plugins/bizar/src/tools/open-kb.ts +191 -0
  322. package/plugins/bizar/src/tools/plan-action.ts +767 -0
  323. package/plugins/bizar/src/tools/read-glyph-feedback.ts +170 -0
  324. package/plugins/bizar/src/tools/wait-for-feedback.ts +402 -0
  325. package/plugins/bizar/tests/attach-handler-bug.test.ts +169 -0
  326. package/plugins/bizar/tests/background-state.test.ts +277 -0
  327. package/plugins/bizar/tests/background.test.ts +402 -0
  328. package/plugins/bizar/tests/block.test.ts +195 -0
  329. package/plugins/bizar/tests/canonical-key-order.test.ts +75 -0
  330. package/plugins/bizar/tests/commands-impl.test.ts +442 -0
  331. package/plugins/bizar/tests/commands.test.ts +584 -0
  332. package/plugins/bizar/tests/config.test.ts +122 -0
  333. package/plugins/bizar/tests/dashboard-client.test.ts +159 -0
  334. package/plugins/bizar/tests/dispose.test.ts +336 -0
  335. package/plugins/bizar/tests/event-stream.test.ts +409 -0
  336. package/plugins/bizar/tests/event.test.ts +262 -0
  337. package/plugins/bizar/tests/fingerprint.test.ts +162 -0
  338. package/plugins/bizar/tests/http-client.test.ts +404 -0
  339. package/plugins/bizar/tests/init-helpers.test.ts +203 -0
  340. package/plugins/bizar/tests/integration/slash-command.test.ts +348 -0
  341. package/plugins/bizar/tests/integration/tool-routing.test.ts +314 -0
  342. package/plugins/bizar/tests/key-rotation.test.ts +396 -0
  343. package/plugins/bizar/tests/loop.test.ts +397 -0
  344. package/plugins/bizar/tests/options.test.ts +276 -0
  345. package/plugins/bizar/tests/reasoning-clean.test.ts +422 -0
  346. package/plugins/bizar/tests/serve.test.ts +339 -0
  347. package/plugins/bizar/tests/settings.test.ts +351 -0
  348. package/plugins/bizar/tests/stall-think.test.ts +750 -0
  349. package/plugins/bizar/tests/state.test.ts +276 -0
  350. package/plugins/bizar/tests/tools/bg-collect.test.ts +337 -0
  351. package/plugins/bizar/tests/tools/bg-get-comments.test.ts +485 -0
  352. package/plugins/bizar/tests/tools/bg-kill.test.ts +235 -0
  353. package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +147 -0
  354. package/plugins/bizar/tests/tools/bg-spawn.test.ts +311 -0
  355. package/plugins/bizar/tests/tools/bg-status.test.ts +217 -0
  356. package/plugins/bizar/tests/tools/opencode-runner.test.ts +115 -0
  357. package/plugins/bizar/tests/tools/plan-action.test.ts +599 -0
  358. package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +253 -0
  359. package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +390 -0
  360. package/plugins/bizar/tests/update-deadlock.test.ts +145 -0
  361. package/plugins/bizar/tsconfig.json +29 -0
@@ -0,0 +1,1523 @@
1
+ // src/views/Tasks.tsx — v3.1.0 task board: archive, filters, sort, bulk, recurring, timers, comments, real-time.
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import {
4
+ CheckSquare,
5
+ Plus,
6
+ Trash2,
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ Search,
10
+ X as XIcon,
11
+ Clock,
12
+ PlayCircle,
13
+ PauseCircle,
14
+ MessageSquare,
15
+ Activity,
16
+ Link2,
17
+ Calendar,
18
+ Paperclip,
19
+ Tag as TagIcon,
20
+ Archive,
21
+ ArchiveRestore,
22
+ Filter,
23
+ ArrowUpDown,
24
+ Square,
25
+ CheckSquare as CheckSquareIcon,
26
+ Bot,
27
+ RotateCw,
28
+ AlertOctagon,
29
+ Sparkles,
30
+ RefreshCw,
31
+ Send,
32
+ FileText,
33
+ } from 'lucide-react';
34
+ import { Button } from '../components/Button';
35
+ import { Card, CardTitle } from '../components/Card';
36
+ import { EmptyState } from '../components/EmptyState';
37
+ import { Spinner } from '../components/Spinner';
38
+ import { StatusBadge } from '../components/StatusBadge';
39
+ import { Tag } from '../components/Tag';
40
+ import { useModal } from '../components/Modal';
41
+ import { useToast } from '../components/Toast';
42
+ import { BgStatusBadge } from '../components/BgStatusBadge';
43
+ import { openKillConfirmDialog } from '../components/KillConfirmDialog';
44
+ import { api, enhancePrompt } from '../lib/api';
45
+ import { cn, formatRelative, priorityColors, autoTitleFromContent } from '../lib/utils';
46
+ import type { Agent, Settings, Snapshot, Task } from '../lib/types';
47
+ import { openArtifactViewer } from '../components/ArtifactViewer';
48
+
49
+ type Props = {
50
+ snapshot: Snapshot;
51
+ settings: Settings;
52
+ activeTab: string;
53
+ setActiveTab: (id: string) => void;
54
+ refreshSnapshot: () => Promise<void>;
55
+ };
56
+
57
+ type Column = {
58
+ id: Task['status'] | string;
59
+ label: string;
60
+ kind: 'info' | 'accent' | 'success' | 'warning' | 'error';
61
+ };
62
+
63
+ const COLUMNS: Column[] = [
64
+ { id: 'queued', label: 'Queued', kind: 'info' },
65
+ { id: 'doing', label: 'Doing', kind: 'accent' },
66
+ { id: 'blocked', label: 'Blocked', kind: 'warning' },
67
+ { id: 'done', label: 'Done', kind: 'success' },
68
+ ];
69
+
70
+ /**
71
+ * v3.7.4 — Decide whether a task belongs in the given kanban column.
72
+ * Archived tasks (any prior session's "finished and tucked away" tasks)
73
+ * show up in the DONE column so a fresh dashboard load still surfaces
74
+ * everything that exists on disk.
75
+ */
76
+ function matchesColumn(task: Task, colId: string): boolean {
77
+ const isArchived = task.status === 'archived' || task.archived === true;
78
+ if (colId === 'done') return task.status === 'done' || isArchived;
79
+ return !isArchived && task.status === colId;
80
+ }
81
+
82
+ const PRIORITIES = ['low', 'normal', 'high'] as const;
83
+ type Priority = (typeof PRIORITIES)[number];
84
+ type SortKey = 'priority' | 'due' | 'created' | 'updated';
85
+
86
+ const SORTS: { id: SortKey; label: string }[] = [
87
+ { id: 'updated', label: 'Last updated' },
88
+ { id: 'created', label: 'Created' },
89
+ { id: 'priority', label: 'Priority' },
90
+ { id: 'due', label: 'Due date' },
91
+ ];
92
+
93
+ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
94
+ const toast = useToast();
95
+ const modal = useModal();
96
+ const [tasks, setTasks] = useState<Task[]>(snapshot.tasks || []);
97
+ const [loading, setLoading] = useState(!snapshot.tasks);
98
+ const [filter, setFilter] = useState('');
99
+ const [assigneeFilter, setAssigneeFilter] = useState<string>('');
100
+ const [priorityFilter, setPriorityFilter] = useState<string>('');
101
+ const [tagFilter, setTagFilter] = useState<string>('');
102
+ const [sortKey, setSortKey] = useState<SortKey>('updated');
103
+ const [showArchived, setShowArchived] = useState(false);
104
+ const [selected, setSelected] = useState<Set<string>>(new Set());
105
+ const [tick, setTick] = useState(0); // for live timer re-render
106
+
107
+ const reload = async (opts: { archived?: boolean } = {}) => {
108
+ try {
109
+ const params = opts.archived ? '?archived=only' : '';
110
+ const data = await api.get<Task[]>(`/tasks${params}`);
111
+ setTasks(Array.isArray(data) ? data : []);
112
+ } catch (err) {
113
+ toast.error(`Tasks load failed: ${(err as Error).message}`);
114
+ } finally {
115
+ setLoading(false);
116
+ }
117
+ };
118
+
119
+ useEffect(() => {
120
+ if (snapshot.tasks) {
121
+ setTasks(snapshot.tasks);
122
+ setLoading(false);
123
+ }
124
+ }, [snapshot.tasks]);
125
+
126
+ // Periodic tick for live timer display.
127
+ useEffect(() => {
128
+ const id = setInterval(() => setTick((t) => t + 1), 1000);
129
+ return () => clearInterval(id);
130
+ }, []);
131
+
132
+ const allTags = useMemo(() => {
133
+ const set = new Set<string>();
134
+ for (const t of tasks) for (const tag of t.tags || []) set.add(tag);
135
+ return Array.from(set).sort();
136
+ }, [tasks]);
137
+
138
+ const filtered = useMemo(() => {
139
+ let out = tasks;
140
+ if (assigneeFilter) {
141
+ out = out.filter((t) => (t.assignee || '') === assigneeFilter);
142
+ }
143
+ if (priorityFilter) {
144
+ out = out.filter((t) => (t.priority || 'normal') === priorityFilter);
145
+ }
146
+ if (tagFilter) {
147
+ out = out.filter((t) => (t.tags || []).includes(tagFilter));
148
+ }
149
+ if (filter.trim()) {
150
+ const q = filter.toLowerCase();
151
+ out = out.filter((t) => {
152
+ const title = (t.title || '').toLowerCase();
153
+ const desc = (t.description || '').toLowerCase();
154
+ const tags = (t.tags || []).join(' ').toLowerCase();
155
+ return title.includes(q) || desc.includes(q) || tags.includes(q);
156
+ });
157
+ }
158
+ return out;
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, [tasks, filter, assigneeFilter, priorityFilter, tagFilter]);
161
+
162
+ const sorted = useMemo(() => {
163
+ const out = [...filtered];
164
+ const priorityWeight = { high: 0, normal: 1, low: 2 } as Record<string, number>;
165
+ switch (sortKey) {
166
+ case 'priority':
167
+ out.sort((a, b) => (priorityWeight[a.priority] ?? 1) - (priorityWeight[b.priority] ?? 1));
168
+ break;
169
+ case 'created':
170
+ out.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
171
+ break;
172
+ case 'due':
173
+ out.sort((a, b) => {
174
+ const ax = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
175
+ const bx = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
176
+ return ax - bx;
177
+ });
178
+ break;
179
+ case 'updated':
180
+ default:
181
+ out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
182
+ break;
183
+ }
184
+ return out;
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
+ }, [filtered, sortKey]);
187
+
188
+ const moveTask = async (taskId: string, newStatus: string) => {
189
+ const t = tasks.find((x) => x.id === taskId);
190
+ if (!t) return;
191
+ const prev = t.status;
192
+ setTasks((cur) =>
193
+ cur.map((x) => (x.id === taskId ? { ...x, status: newStatus } : x)),
194
+ );
195
+ try {
196
+ await api.patch(`/tasks/${encodeURIComponent(taskId)}/status`, {
197
+ status: newStatus,
198
+ });
199
+ toast.success(`Moved to ${newStatus}.`, 1500);
200
+ } catch (err) {
201
+ setTasks((cur) =>
202
+ cur.map((x) => (x.id === taskId ? { ...x, status: prev } : x)),
203
+ );
204
+ toast.error(`Move failed: ${(err as Error).message}`);
205
+ }
206
+ };
207
+
208
+ const deleteTask = async (taskId: string) => {
209
+ if (!confirm('Delete this task?')) return;
210
+ try {
211
+ await api.del(`/tasks/${encodeURIComponent(taskId)}`);
212
+ setTasks((cur) => cur.filter((t) => t.id !== taskId));
213
+ toast.success('Task deleted.', 1500);
214
+ } catch (err) {
215
+ toast.error(`Delete failed: ${(err as Error).message}`);
216
+ }
217
+ };
218
+
219
+ const archiveTask = async (taskId: string) => {
220
+ try {
221
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(taskId)}/archive`);
222
+ setTasks((cur) => cur.filter((t) => t.id !== taskId));
223
+ toast.success('Task archived.', 1500);
224
+ } catch (err) {
225
+ toast.error(`Archive failed: ${(err as Error).message}`);
226
+ }
227
+ };
228
+
229
+ const unarchiveTask = async (taskId: string) => {
230
+ try {
231
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(taskId)}/unarchive`);
232
+ setTasks((cur) => cur.map((t) => (t.id === taskId ? updated : t)));
233
+ toast.success('Task restored.', 1500);
234
+ } catch (err) {
235
+ toast.error(`Restore failed: ${(err as Error).message}`);
236
+ }
237
+ };
238
+
239
+ const onBulkAction = async (action: 'archive' | 'delete' | 'move' | 'priority' | 'assign' | 'tag', params: Record<string, unknown> = {}) => {
240
+ if (selected.size === 0) return;
241
+ const ids = Array.from(selected);
242
+ try {
243
+ const out = await api.post<{ affected: { id: string; ok: boolean; error?: string }[] }>('/tasks/bulk', {
244
+ ids,
245
+ action,
246
+ params,
247
+ });
248
+ const ok = out.affected.filter((r) => r.ok).length;
249
+ const failed = out.affected.length - ok;
250
+ if (action === 'archive' || action === 'delete') {
251
+ setTasks((cur) => cur.filter((t) => !ids.includes(t.id)));
252
+ } else {
253
+ await reload();
254
+ }
255
+ toast.success(`Bulk ${action}: ${ok} ok${failed ? `, ${failed} failed` : ''}.`, 2500);
256
+ setSelected(new Set());
257
+ } catch (err) {
258
+ toast.error(`Bulk failed: ${(err as Error).message}`);
259
+ }
260
+ };
261
+
262
+ const onAssignMe = async (t: Task) => {
263
+ try {
264
+ const me = 'me';
265
+ const updated = await api.put<Task>(`/tasks/${encodeURIComponent(t.id)}`, { assignee: me });
266
+ setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
267
+ toast.success('Assigned to me.', 1200);
268
+ } catch (err) {
269
+ toast.error(`Assign failed: ${(err as Error).message}`);
270
+ }
271
+ };
272
+
273
+ const onMarkWorkedBy = async (t: Task, agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => {
274
+ try {
275
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(t.id)}/work`, {
276
+ agent,
277
+ status,
278
+ complete,
279
+ });
280
+ setTasks((cur) => cur.map((x) => (x.id === t.id ? updated : x)));
281
+ toast.success(`Marked ${status} by ${agent}.`, 1500);
282
+ } catch (err) {
283
+ toast.error(`Mark failed: ${(err as Error).message}`);
284
+ }
285
+ };
286
+
287
+ const toggleSelect = (id: string) => {
288
+ setSelected((cur) => {
289
+ const next = new Set(cur);
290
+ if (next.has(id)) next.delete(id);
291
+ else next.add(id);
292
+ return next;
293
+ });
294
+ };
295
+
296
+ const toggleSelectAll = (ids: string[]) => {
297
+ setSelected((cur) => {
298
+ const next = new Set(cur);
299
+ const allSelected = ids.length > 0 && ids.every((id) => next.has(id));
300
+ if (allSelected) {
301
+ ids.forEach((id) => next.delete(id));
302
+ return next;
303
+ }
304
+ ids.forEach((id) => next.add(id));
305
+ return next;
306
+ });
307
+ };
308
+
309
+ // v3.6.2 — Pipeline: open task chat session.
310
+ const onOpenTaskChat = (taskId: string) => {
311
+ setActiveTab('chat');
312
+ // The Chat view reads crossState.initialChatTaskId to pre-load a session.
313
+ // We set it via a custom event that Chat.tsx listens to on mount.
314
+ window.dispatchEvent(new CustomEvent('bizar:setChatTask', { detail: { taskId } }));
315
+ };
316
+
317
+ // v3.6.2 — Pipeline: open task artifact viewer.
318
+ const onOpenTaskArtifact = (artifactId: string) => {
319
+ openArtifactViewer(modal, artifactId);
320
+ };
321
+
322
+ return (
323
+ <div className="view view-tasks">
324
+ <header className="view-header">
325
+ <div className="view-header-text">
326
+ <h2 className="view-title">
327
+ <CheckSquare size={18} /> Tasks ({sorted.length})
328
+ </h2>
329
+ <p className="view-subtitle">
330
+ Personal kanban. Click a card for details (subtasks, deps, timer, comments, activity).
331
+ </p>
332
+ </div>
333
+ </header>
334
+
335
+ {/* v3.4.0 — Compact single-row toolbar: search + filters + sort + actions */}
336
+ <div className="tasks-toolbar">
337
+ <div className="tasks-toolbar-group">
338
+ <div className="search-input" style={{ width: 200 }}>
339
+ <Search size={12} />
340
+ <input
341
+ className="input"
342
+ type="text"
343
+ placeholder="Search…"
344
+ value={filter}
345
+ onChange={(e) => setFilter(e.target.value)}
346
+ />
347
+ {filter && (
348
+ <button
349
+ type="button"
350
+ className="icon-btn"
351
+ aria-label="Clear search"
352
+ onClick={() => setFilter('')}
353
+ >
354
+ <XIcon size={12} />
355
+ </button>
356
+ )}
357
+ </div>
358
+ </div>
359
+
360
+ <div className="tasks-toolbar-divider" />
361
+
362
+ <div className="tasks-toolbar-group">
363
+ <span className="tasks-toolbar-label">Filter</span>
364
+ <select
365
+ className="select select-sm"
366
+ value={assigneeFilter}
367
+ onChange={(e) => setAssigneeFilter(e.target.value)}
368
+ title="Assignee"
369
+ >
370
+ <option value="">All assignees</option>
371
+ <option value="me">Me</option>
372
+ {(snapshot.agents || []).map((a) => (
373
+ <option key={a.name} value={a.name}>@{a.name}</option>
374
+ ))}
375
+ </select>
376
+ <select
377
+ className="select select-sm"
378
+ value={priorityFilter}
379
+ onChange={(e) => setPriorityFilter(e.target.value)}
380
+ title="Priority"
381
+ >
382
+ <option value="">All priorities</option>
383
+ {PRIORITIES.map((p) => (
384
+ <option key={p} value={p}>{p}</option>
385
+ ))}
386
+ </select>
387
+ {allTags.length > 0 && (
388
+ <select
389
+ className="select select-sm"
390
+ value={tagFilter}
391
+ onChange={(e) => setTagFilter(e.target.value)}
392
+ title="Tag"
393
+ >
394
+ <option value="">All tags</option>
395
+ {allTags.map((t) => (
396
+ <option key={t} value={t}>#{t}</option>
397
+ ))}
398
+ </select>
399
+ )}
400
+ </div>
401
+
402
+ <div className="tasks-toolbar-divider" />
403
+
404
+ <div className="tasks-toolbar-group">
405
+ <span className="tasks-toolbar-label">Sort</span>
406
+ <select
407
+ className="select select-sm"
408
+ value={sortKey}
409
+ onChange={(e) => setSortKey(e.target.value as SortKey)}
410
+ title="Sort by"
411
+ >
412
+ {SORTS.map((s) => (
413
+ <option key={s.id} value={s.id}>{s.label}</option>
414
+ ))}
415
+ </select>
416
+ </div>
417
+
418
+ <div className="tasks-toolbar-spacer" />
419
+
420
+ <div className="tasks-toolbar-group">
421
+ <Button
422
+ variant={showArchived ? 'primary' : 'ghost'}
423
+ size="sm"
424
+ onClick={() => {
425
+ setShowArchived((v) => !v);
426
+ reload({ archived: !showArchived });
427
+ }}
428
+ title={showArchived ? 'Showing archived' : 'Show archived'}
429
+ >
430
+ {showArchived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
431
+ {showArchived ? 'Active' : 'Archived'}
432
+ </Button>
433
+ <Button variant="ghost" size="sm" onClick={() => reload()} title="Refresh" aria-label="Refresh tasks">
434
+ <RefreshCw size={14} />
435
+ </Button>
436
+ <Button
437
+ variant="accent"
438
+ size="sm"
439
+ onClick={() => openSubmitTaskModal(modal, toast, setTasks, reload, refreshSnapshot)}
440
+ >
441
+ <Send size={14} /> Odin
442
+ </Button>
443
+ <Button
444
+ variant="primary"
445
+ size="sm"
446
+ onClick={() => openTaskModal(modal, toast, null, 'queued', setTasks, reload, refreshSnapshot, snapshot.agents)}
447
+ >
448
+ <Plus size={14} /> Add
449
+ </Button>
450
+ </div>
451
+ </div>
452
+
453
+ {selected.size > 0 && (
454
+ <div className="task-bulk-bar">
455
+ <span className="text-sm">
456
+ <CheckSquareIcon size={14} /> {selected.size} selected
457
+ </span>
458
+ <div className="task-bulk-bar-actions">
459
+ <Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'queued' })}>
460
+ Queued
461
+ </Button>
462
+ <Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'doing' })}>
463
+ Doing
464
+ </Button>
465
+ <Button size="sm" variant="ghost" onClick={() => onBulkAction('move', { status: 'done' })}>
466
+ Done
467
+ </Button>
468
+ <Button size="sm" variant="ghost" onClick={() => onBulkAction('priority', { priority: 'high' })}>
469
+ High
470
+ </Button>
471
+ <Button size="sm" variant="ghost" onClick={() => onBulkAction('archive')}>
472
+ <Archive size={12} /> Archive
473
+ </Button>
474
+ <Button size="sm" variant="danger" onClick={() => onBulkAction('delete')}>
475
+ <Trash2 size={12} /> Delete
476
+ </Button>
477
+ <Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
478
+ Clear
479
+ </Button>
480
+ </div>
481
+ </div>
482
+ )}
483
+
484
+ {loading ? (
485
+ <div className="view-loading"><Spinner size="lg" /></div>
486
+ ) : (
487
+ <div className="kanban">
488
+ {COLUMNS.map((col) => (
489
+ <KanbanColumn
490
+ key={col.id}
491
+ column={col}
492
+ // v3.7.4 — Archived tasks (`status: "archived"` OR `archived: true`)
493
+ // surface in the DONE column so earlier-existing tasks are
494
+ // visible from a fresh dashboard load. The TaskCard itself
495
+ // shows an "Archived" badge so the user can distinguish them
496
+ // from completed tasks.
497
+ tasks={sorted.filter((t) => matchesColumn(t, col.id))}
498
+ selected={selected}
499
+ onToggleSelect={toggleSelect}
500
+ onToggleSelectAll={toggleSelectAll}
501
+ allTaskIdsInCol={sorted.filter((t) => matchesColumn(t, col.id)).map((t) => t.id)}
502
+ onMove={moveTask}
503
+ onDelete={deleteTask}
504
+ onArchive={archiveTask}
505
+ onUnarchive={unarchiveTask}
506
+ onEdit={(t) => openTaskDetail(modal, toast, t, setTasks, reload, refreshSnapshot, snapshot.agents, tick)}
507
+ onAdd={() => openTaskModal(modal, toast, null, col.id, setTasks, reload, refreshSnapshot, snapshot.agents)}
508
+ onAssignMe={onAssignMe}
509
+ onMarkWorkedBy={onMarkWorkedBy}
510
+ onOpenChat={onOpenTaskChat}
511
+ onOpenArtifact={onOpenTaskArtifact}
512
+ isArchivedView={showArchived}
513
+ />
514
+ ))}
515
+ </div>
516
+ )}
517
+ </div>
518
+ );
519
+ }
520
+
521
+ function KanbanColumn({
522
+ column,
523
+ tasks,
524
+ selected,
525
+ onToggleSelect,
526
+ onToggleSelectAll,
527
+ allTaskIdsInCol,
528
+ onMove,
529
+ onDelete,
530
+ onArchive,
531
+ onUnarchive,
532
+ onEdit,
533
+ onAdd,
534
+ onAssignMe,
535
+ onMarkWorkedBy,
536
+ onOpenChat,
537
+ onOpenArtifact,
538
+ isArchivedView,
539
+ }: {
540
+ column: Column;
541
+ tasks: Task[];
542
+ selected: Set<string>;
543
+ onToggleSelect: (id: string) => void;
544
+ onToggleSelectAll: (ids: string[]) => void;
545
+ allTaskIdsInCol: string[];
546
+ onMove: (id: string, status: string) => void;
547
+ onDelete: (id: string) => void;
548
+ onArchive: (id: string) => void;
549
+ onUnarchive: (id: string) => void;
550
+ onEdit: (task: Task) => void;
551
+ onAdd: () => void;
552
+ onAssignMe: (task: Task) => void;
553
+ onMarkWorkedBy: (task: Task, agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => void;
554
+ onOpenChat: (taskId: string) => void;
555
+ onOpenArtifact: (artifactId: string) => void;
556
+ isArchivedView: boolean;
557
+ }) {
558
+ const [dragOver, setDragOver] = useState(false);
559
+ const allSelected = tasks.length > 0 && allTaskIdsInCol.every((id) => selected.has(id));
560
+ return (
561
+ <div
562
+ className={cn('kanban-column', dragOver && 'kanban-column-drop')}
563
+ data-column={column.id}
564
+ onDragOver={(e) => {
565
+ e.preventDefault();
566
+ setDragOver(true);
567
+ }}
568
+ onDragLeave={() => setDragOver(false)}
569
+ onDrop={(e) => {
570
+ e.preventDefault();
571
+ setDragOver(false);
572
+ const id = e.dataTransfer.getData('text/task-id');
573
+ if (id) onMove(id, column.id);
574
+ }}
575
+ >
576
+ <div className="kanban-col-header">
577
+ <button
578
+ type="button"
579
+ className="icon-btn"
580
+ onClick={() => onToggleSelectAll(allTaskIdsInCol)}
581
+ title={allSelected ? 'Deselect all' : 'Select all'}
582
+ aria-label={allSelected ? 'Deselect all' : 'Select all'}
583
+ >
584
+ {allSelected ? <CheckSquareIcon size={12} /> : <Square size={12} />}
585
+ </button>
586
+ <CardTitle>
587
+ <StatusBadge kind={column.kind} dot>
588
+ {column.label}
589
+ </StatusBadge>
590
+ </CardTitle>
591
+ <span className="kanban-col-count tabular-nums">{tasks.length}</span>
592
+ </div>
593
+ <div className="kanban-col-body">
594
+ {tasks.length === 0 ? (
595
+ <div className="kanban-empty">No tasks</div>
596
+ ) : (
597
+ tasks.map((t) => (
598
+ <TaskCard
599
+ key={t.id}
600
+ task={t}
601
+ focused={selected.has(t.id)}
602
+ onFocus={() => onToggleSelect(t.id)}
603
+ onMove={(dir) => {
604
+ const idx = COLUMNS.findIndex((c) => c.id === t.status);
605
+ const next = idx + dir;
606
+ if (next >= 0 && next < COLUMNS.length)
607
+ onMove(t.id, COLUMNS[next].id);
608
+ }}
609
+ onEdit={() => onEdit(t)}
610
+ onDelete={() => onDelete(t.id)}
611
+ onArchive={() => onArchive(t.id)}
612
+ onUnarchive={() => onUnarchive(t.id)}
613
+ onAssignMe={() => onAssignMe(t)}
614
+ onMarkWorkedBy={(agent, status, complete) => onMarkWorkedBy(t, agent, status, complete)}
615
+ onOpenChat={onOpenChat}
616
+ onOpenArtifact={onOpenArtifact}
617
+ isArchivedView={isArchivedView}
618
+ />
619
+ ))
620
+ )}
621
+ </div>
622
+ {!isArchivedView && (
623
+ <footer className="kanban-col-footer">
624
+ <Button variant="ghost" size="sm" onClick={onAdd} className="w-full">
625
+ <Plus size={12} /> Add task
626
+ </Button>
627
+ </footer>
628
+ )}
629
+ </div>
630
+ );
631
+ }
632
+
633
+ function TaskCard({
634
+ task,
635
+ focused,
636
+ onFocus,
637
+ onMove,
638
+ onEdit,
639
+ onDelete,
640
+ onArchive,
641
+ onUnarchive,
642
+ onAssignMe,
643
+ onMarkWorkedBy,
644
+ onOpenChat,
645
+ onOpenArtifact,
646
+ isArchivedView,
647
+ }: {
648
+ task: Task;
649
+ focused: boolean;
650
+ onFocus: () => void;
651
+ onMove: (dir: -1 | 1) => void;
652
+ onEdit: () => void;
653
+ onDelete: () => void;
654
+ onArchive: () => void;
655
+ onUnarchive: () => void;
656
+ onAssignMe: () => void;
657
+ onMarkWorkedBy: (agent: string, status: 'doing' | 'done' | 'idle', complete: boolean) => void;
658
+ onOpenChat: (taskId: string) => void;
659
+ onOpenArtifact: (artifactId: string) => void;
660
+ isArchivedView: boolean;
661
+ }) {
662
+ const toast = useToast();
663
+ const modal = useModal();
664
+ const isTimer = task._timerStart;
665
+ const isWorking = (task.workedBy || task.status === 'doing') && !isArchivedView;
666
+
667
+ // v3.6.2 — Pipeline: determine if task has a chat session or artifact.
668
+ type TaskPipelineMeta = {
669
+ sessionId?: string;
670
+ bgInstanceId?: string;
671
+ status?: string;
672
+ artifactIds?: string[];
673
+ artifactId?: string;
674
+ };
675
+ const metadata = (task.metadata || {}) as TaskPipelineMeta;
676
+ const hasChatSession = !!(metadata.sessionId || metadata.bgInstanceId);
677
+ const artifactIds: string[] = metadata.artifactIds || (metadata.artifactId ? [metadata.artifactId] : []);
678
+ const primaryArtifactId = artifactIds[0];
679
+ return (
680
+ <div
681
+ className={cn(
682
+ 'task-card',
683
+ `priority-${task.priority}`,
684
+ focused && 'task-card-focused',
685
+ isTimer ? 'task-card-timer is-working' : null,
686
+ isWorking && !isTimer ? 'is-working' : null,
687
+ )}
688
+ data-task-id={task.id}
689
+ data-task-parent={task.parent || ''}
690
+ draggable={!isArchivedView}
691
+ onDragStart={(e) => {
692
+ e.dataTransfer.setData('text/task-id', task.id);
693
+ e.dataTransfer.effectAllowed = 'move';
694
+ }}
695
+ onClick={onFocus}
696
+ >
697
+ <div className="task-card-head">
698
+ {/* v3.7.0 — Visible checkbox for bulk select */}
699
+ <button
700
+ type="button"
701
+ className={cn('task-card-checkbox', focused && 'task-card-checkbox-on')}
702
+ aria-label={focused ? 'Deselect task' : 'Select task'}
703
+ title={focused ? 'Deselect' : 'Select'}
704
+ onClick={(e) => {
705
+ e.stopPropagation();
706
+ onFocus();
707
+ }}
708
+ >
709
+ {focused ? <CheckSquareIcon size={13} /> : <Square size={13} />}
710
+ </button>
711
+ <span
712
+ className="priority-dot"
713
+ style={{ background: priorityColors[task.priority] || 'var(--info)' }}
714
+ />
715
+ <div className="task-card-title">{task.title}</div>
716
+ {isTimer && <span className="task-card-timer-pill"><Clock size={10} /> running</span>}
717
+ {task.workedBy && task.status === 'doing' && !isTimer && (
718
+ <span className="task-card-timer-pill" title={`@${task.workedBy}`}>
719
+ <Bot size={10} /> @{task.workedBy}
720
+ </span>
721
+ )}
722
+ {task.recurring && (
723
+ <span className="task-card-timer-pill" title="Recurring">
724
+ <RotateCw size={10} />
725
+ </span>
726
+ )}
727
+ </div>
728
+ {task.description && (
729
+ <div className="task-card-desc">{task.description.slice(0, 160)}</div>
730
+ )}
731
+ <div className="task-card-badges">
732
+ {task.assignee && !task.workedBy && <span className="task-card-badge"><TagIcon size={10} /> @{task.assignee}</span>}
733
+ {task.timeSpent ? <span className="task-card-badge"><Clock size={10} /> {formatDuration(task.timeSpent)}</span> : null}
734
+ {task.dependencies?.length ? <span className="task-card-badge"><Link2 size={10} /> {task.dependencies.length}</span> : null}
735
+ {task.recurring ? <span className="task-card-badge"><Calendar size={10} /> {task.recurring.cron || task.recurring.freq || 'recurring'}</span> : null}
736
+ {task.comments?.length ? <span className="task-card-badge"><MessageSquare size={10} /> {task.comments.length}</span> : null}
737
+ {task.attachments?.length ? <span className="task-card-badge"><Paperclip size={10} /> {task.attachments.length}</span> : null}
738
+ {metadata.bgInstanceId ? (
739
+ <span className="task-card-badge">
740
+ <Activity size={10} /> <BgStatusBadge status={metadata.status || 'pending'} />
741
+ </span>
742
+ ) : null}
743
+ </div>
744
+ {task.tags && task.tags.length > 0 && (
745
+ <div className="task-card-tags">
746
+ {task.tags.map((t) => (
747
+ <Tag key={t}>{t}</Tag>
748
+ ))}
749
+ </div>
750
+ )}
751
+ <div className="task-card-footer">
752
+ <span className="task-card-time tabular-nums muted">
753
+ {formatRelative(task.updatedAt || task.createdAt)}
754
+ </span>
755
+ <div className="task-card-actions" onClick={(e) => e.stopPropagation()}>
756
+ {!isArchivedView && (
757
+ <>
758
+ <button
759
+ type="button"
760
+ className="icon-btn"
761
+ aria-label="Move left"
762
+ title="Move left"
763
+ onClick={() => onMove(-1)}
764
+ >
765
+ <ChevronLeft size={14} />
766
+ </button>
767
+ <button
768
+ type="button"
769
+ className="icon-btn"
770
+ aria-label="Edit"
771
+ title="Open detail"
772
+ onClick={onEdit}
773
+ >
774
+ <Sparkles size={14} />
775
+ </button>
776
+ <button
777
+ type="button"
778
+ className="icon-btn"
779
+ aria-label="Assign me"
780
+ title="Assign to me"
781
+ onClick={onAssignMe}
782
+ >
783
+ <TagIcon size={14} />
784
+ </button>
785
+ {hasChatSession && (
786
+ <button
787
+ type="button"
788
+ className="icon-btn"
789
+ aria-label="Open chat"
790
+ title="Open in chat"
791
+ onClick={() => onOpenChat(task.id)}
792
+ >
793
+ <MessageSquare size={14} />
794
+ </button>
795
+ )}
796
+ {primaryArtifactId && (
797
+ <button
798
+ type="button"
799
+ className="icon-btn"
800
+ aria-label="Open artifact"
801
+ title="Open artifact"
802
+ onClick={() => onOpenArtifact(primaryArtifactId)}
803
+ >
804
+ <FileText size={14} />
805
+ </button>
806
+ )}
807
+ {isArchivedView ? (
808
+ <button
809
+ type="button"
810
+ className="icon-btn"
811
+ aria-label="Restore"
812
+ title="Restore"
813
+ onClick={onUnarchive}
814
+ >
815
+ <ArchiveRestore size={14} />
816
+ </button>
817
+ ) : (
818
+ <button
819
+ type="button"
820
+ className="icon-btn"
821
+ aria-label="Archive"
822
+ title="Archive"
823
+ onClick={onArchive}
824
+ >
825
+ <Archive size={14} />
826
+ </button>
827
+ )}
828
+ {metadata.bgInstanceId && (
829
+ <button
830
+ type="button"
831
+ className="icon-btn icon-btn-danger"
832
+ aria-label="Kill background instance"
833
+ title="Kill background instance"
834
+ onClick={() => {
835
+ openKillConfirmDialog(modal, toast, metadata.bgInstanceId!, task.title, () => {
836
+ // Optimistically mark task as blocked after kill.
837
+ const event = new CustomEvent('bizar:taskKilled', {
838
+ detail: { taskId: task.id, bgInstanceId: metadata.bgInstanceId },
839
+ });
840
+ window.dispatchEvent(event);
841
+ });
842
+ }}
843
+ >
844
+ <Activity size={14} />
845
+ </button>
846
+ )}
847
+ <button
848
+ type="button"
849
+ className="icon-btn icon-btn-danger"
850
+ aria-label="Delete"
851
+ title="Delete"
852
+ onClick={onDelete}
853
+ >
854
+ <Trash2 size={14} />
855
+ </button>
856
+ <button
857
+ type="button"
858
+ className="icon-btn"
859
+ aria-label="Move right"
860
+ title="Move right"
861
+ onClick={() => onMove(1)}
862
+ >
863
+ <ChevronRight size={14} />
864
+ </button>
865
+ </>
866
+ )}
867
+ {isArchivedView && (
868
+ <>
869
+ <button
870
+ type="button"
871
+ className="icon-btn"
872
+ aria-label="Restore"
873
+ title="Restore"
874
+ onClick={onUnarchive}
875
+ >
876
+ <ArchiveRestore size={14} />
877
+ </button>
878
+ <button
879
+ type="button"
880
+ className="icon-btn icon-btn-danger"
881
+ aria-label="Delete"
882
+ title="Delete"
883
+ onClick={onDelete}
884
+ >
885
+ <Trash2 size={14} />
886
+ </button>
887
+ </>
888
+ )}
889
+ </div>
890
+ </div>
891
+ </div>
892
+ );
893
+ }
894
+
895
+ function formatDuration(seconds: number): string {
896
+ if (!seconds) return '0m';
897
+ const h = Math.floor(seconds / 3600);
898
+ const m = Math.floor((seconds % 3600) / 60);
899
+ if (h > 0) return `${h}h ${m}m`;
900
+ return `${m}m`;
901
+ }
902
+
903
+ function openTaskModal(
904
+ modal: ReturnType<typeof useModal>,
905
+ toast: ReturnType<typeof useToast>,
906
+ task: Task | null,
907
+ initialStatus: string,
908
+ setTasks: (updater: (cur: Task[]) => Task[]) => void,
909
+ reload: () => Promise<void>,
910
+ refreshSnapshot: () => Promise<void>,
911
+ agents: Agent[] = [],
912
+ ) {
913
+ let titleEl: HTMLInputElement | null = null;
914
+ let descEl: HTMLTextAreaElement | null = null;
915
+ let tagsEl: HTMLInputElement | null = null;
916
+ let priorityEl: HTMLDivElement | null = null;
917
+ let statusEl: HTMLSelectElement | null = null;
918
+ let assigneeEl: HTMLInputElement | null = null;
919
+ let recurEl: HTMLSelectElement | null = null;
920
+ let cronEl: HTMLInputElement | null = null;
921
+ let dueEl: HTMLInputElement | null = null;
922
+
923
+ const submit = async () => {
924
+ const title = titleEl?.value.trim() || '';
925
+ if (!title) {
926
+ toast.warning('Title is required.');
927
+ titleEl?.focus();
928
+ return;
929
+ }
930
+ const description = descEl?.value.trim() || '';
931
+ const priority =
932
+ (priorityEl?.querySelector<HTMLInputElement>(
933
+ 'input[name="task-priority"]:checked',
934
+ )?.value as Priority) || 'normal';
935
+ const tagsRaw = tagsEl?.value.trim() || '';
936
+ const tags = tagsRaw
937
+ ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean)
938
+ : [];
939
+ const status = statusEl?.value || initialStatus;
940
+ const assignee = (assigneeEl?.value || '').trim() || null;
941
+ const recurFreq = recurEl?.value || '';
942
+ const cron = cronEl?.value.trim() || '';
943
+ const recurring = recurFreq === 'custom' && cron
944
+ ? { freq: 'custom', cron }
945
+ : recurFreq && recurFreq !== 'none'
946
+ ? { freq: recurFreq }
947
+ : null;
948
+ const dueDate = dueEl?.value || null;
949
+
950
+ try {
951
+ if (task) {
952
+ const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, {
953
+ title,
954
+ description,
955
+ tags,
956
+ priority,
957
+ status,
958
+ assignee,
959
+ recurring,
960
+ dueDate,
961
+ });
962
+ // v3.3.1 — close modal first, bump safe window, then state.
963
+ modal.close();
964
+ if (typeof window !== 'undefined') {
965
+ window.dispatchEvent(new MouseEvent('mousedown'));
966
+ }
967
+ setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
968
+ toast.success('Task updated.', 1500);
969
+ } else {
970
+ const created = await api.post<Task>('/tasks', {
971
+ title,
972
+ description,
973
+ status,
974
+ tags,
975
+ priority,
976
+ assignee,
977
+ recurring,
978
+ dueDate,
979
+ });
980
+ // v3.3.1 — close modal first, bump safe window, then state.
981
+ modal.close();
982
+ if (typeof window !== 'undefined') {
983
+ window.dispatchEvent(new MouseEvent('mousedown'));
984
+ }
985
+ setTasks((cur) => [created, ...cur]);
986
+ toast.success('Task created.', 1500);
987
+ }
988
+ await refreshSnapshot();
989
+ } catch (err) {
990
+ toast.error(`Save failed: ${(err as Error).message}`);
991
+ }
992
+ };
993
+
994
+ modal.open({
995
+ title: task ? 'Edit task' : 'New task',
996
+ width: 560,
997
+ children: (
998
+ <div className="task-form">
999
+ <label className="field-label" htmlFor="task-title">Title *</label>
1000
+ <input
1001
+ ref={(el) => { titleEl = el; }}
1002
+ id="task-title"
1003
+ className="input"
1004
+ type="text"
1005
+ maxLength={200}
1006
+ placeholder="What needs to be done?"
1007
+ defaultValue={task?.title || ''}
1008
+ autoFocus
1009
+ />
1010
+ <label className="field-label" htmlFor="task-desc">Description (markdown)</label>
1011
+ <textarea
1012
+ ref={(el) => { descEl = el; }}
1013
+ id="task-desc"
1014
+ className="textarea"
1015
+ rows={3}
1016
+ placeholder="Markdown supported…"
1017
+ defaultValue={task?.description || ''}
1018
+ />
1019
+ <div className="task-form-row">
1020
+ <div className="task-form-field">
1021
+ <label className="field-label" htmlFor="task-status">Status</label>
1022
+ <select
1023
+ ref={(el) => { statusEl = el; }}
1024
+ id="task-status"
1025
+ className="select"
1026
+ defaultValue={task?.status || initialStatus}
1027
+ >
1028
+ {COLUMNS.map((c) => (
1029
+ <option key={c.id} value={c.id}>{c.label}</option>
1030
+ ))}
1031
+ </select>
1032
+ </div>
1033
+ <div className="task-form-field">
1034
+ <label className="field-label">Priority</label>
1035
+ <div className="radio-row" ref={(el) => { priorityEl = el; }}>
1036
+ {PRIORITIES.map((p) => (
1037
+ <label key={p} className="radio-label">
1038
+ <input
1039
+ type="radio"
1040
+ name="task-priority"
1041
+ value={p}
1042
+ defaultChecked={(task?.priority || 'normal') === p}
1043
+ />
1044
+ <span className="capitalize">{p}</span>
1045
+ </label>
1046
+ ))}
1047
+ </div>
1048
+ </div>
1049
+ </div>
1050
+ <div className="task-form-row">
1051
+ <div className="task-form-field">
1052
+ <label className="field-label" htmlFor="task-assignee">Assignee</label>
1053
+ <input
1054
+ ref={(el) => { assigneeEl = el; }}
1055
+ id="task-assignee"
1056
+ className="input"
1057
+ type="text"
1058
+ placeholder="odin, thor, me, …"
1059
+ list="task-assignee-suggestions"
1060
+ defaultValue={task?.assignee || ''}
1061
+ />
1062
+ <datalist id="task-assignee-suggestions">
1063
+ {agents.map((a) => (
1064
+ <option key={a.name} value={a.name}>@{a.name}</option>
1065
+ ))}
1066
+ </datalist>
1067
+ </div>
1068
+ <div className="task-form-field">
1069
+ <label className="field-label" htmlFor="task-due">Due date</label>
1070
+ <input
1071
+ ref={(el) => { dueEl = el; }}
1072
+ id="task-due"
1073
+ className="input"
1074
+ type="date"
1075
+ defaultValue={task?.dueDate?.slice(0, 10) || ''}
1076
+ />
1077
+ </div>
1078
+ </div>
1079
+ <label className="field-label" htmlFor="task-tags">Tags</label>
1080
+ <input
1081
+ ref={(el) => { tagsEl = el; }}
1082
+ id="task-tags"
1083
+ className="input"
1084
+ type="text"
1085
+ placeholder="comma-separated, e.g. bug, frontend"
1086
+ defaultValue={(task?.tags || []).join(', ')}
1087
+ />
1088
+ <div className="task-form-row">
1089
+ <div className="task-form-field">
1090
+ <label className="field-label" htmlFor="task-recur">Recurring</label>
1091
+ <select
1092
+ ref={(el) => { recurEl = el; }}
1093
+ id="task-recur"
1094
+ className="select"
1095
+ defaultValue={task?.recurring?.freq || 'none'}
1096
+ >
1097
+ <option value="none">None</option>
1098
+ <option value="daily">Daily</option>
1099
+ <option value="weekly">Weekly</option>
1100
+ <option value="monthly">Monthly</option>
1101
+ <option value="custom">Custom (cron)</option>
1102
+ </select>
1103
+ </div>
1104
+ <div className="task-form-field">
1105
+ <label className="field-label" htmlFor="task-cron">Cron expression</label>
1106
+ <input
1107
+ ref={(el) => { cronEl = el; }}
1108
+ id="task-cron"
1109
+ className="input"
1110
+ type="text"
1111
+ placeholder="0 9 * * *"
1112
+ defaultValue={task?.recurring?.cron || ''}
1113
+ />
1114
+ </div>
1115
+ </div>
1116
+ </div>
1117
+ ),
1118
+ footer: (
1119
+ <div className="modal-footer-actions">
1120
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
1121
+ <Button variant="primary" onClick={submit}>{task ? 'Save' : 'Create'}</Button>
1122
+ </div>
1123
+ ),
1124
+ });
1125
+ }
1126
+
1127
+ function openTaskDetail(
1128
+ modal: ReturnType<typeof useModal>,
1129
+ toast: ReturnType<typeof useToast>,
1130
+ task: Task,
1131
+ setTasks: (updater: (cur: Task[]) => Task[]) => void,
1132
+ reload: () => Promise<void>,
1133
+ refreshSnapshot: () => Promise<void>,
1134
+ agents: Agent[] = [],
1135
+ tick: number = 0,
1136
+ ) {
1137
+ let commentEl: HTMLTextAreaElement | null = null;
1138
+ let depEl: HTMLInputElement | null = null;
1139
+ let agentEl: HTMLSelectElement | null = null;
1140
+
1141
+ const refresh = async () => {
1142
+ try {
1143
+ const r = await api.get<Task[]>(`/tasks?archived=${task.archived ? 'true' : 'false'}`);
1144
+ const found = r.find((t) => t.id === task.id);
1145
+ if (found) {
1146
+ setTasks((cur) => cur.map((x) => (x.id === found.id ? found : x)));
1147
+ }
1148
+ } catch { /* ignore */ }
1149
+ };
1150
+
1151
+ const onComment = async () => {
1152
+ const text = (commentEl?.value || '').trim();
1153
+ if (!text) return;
1154
+ try {
1155
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/comments`, { text });
1156
+ setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
1157
+ if (commentEl) commentEl.value = '';
1158
+ toast.success('Comment added.', 1200);
1159
+ } catch (err) {
1160
+ toast.error(`Comment failed: ${(err as Error).message}`);
1161
+ }
1162
+ };
1163
+
1164
+ const onStartTimer = async () => {
1165
+ try {
1166
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer/start`);
1167
+ setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
1168
+ toast.success('Timer started.', 1200);
1169
+ } catch (err) {
1170
+ toast.error(`Start failed: ${(err as Error).message}`);
1171
+ }
1172
+ };
1173
+
1174
+ const onStopTimer = async () => {
1175
+ try {
1176
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/timer/stop`);
1177
+ setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
1178
+ toast.success('Timer stopped.', 1200);
1179
+ } catch (err) {
1180
+ toast.error(`Stop failed: ${(err as Error).message}`);
1181
+ }
1182
+ };
1183
+
1184
+ const onAddDep = async () => {
1185
+ const id = (depEl?.value || '').trim();
1186
+ if (!id) return;
1187
+ const next = [...(task.dependencies || []), id];
1188
+ try {
1189
+ const updated = await api.put<Task>(`/tasks/${encodeURIComponent(task.id)}`, { dependencies: next });
1190
+ setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
1191
+ if (depEl) depEl.value = '';
1192
+ toast.success('Dependency added.', 1200);
1193
+ } catch (err) {
1194
+ toast.error(`Failed: ${(err as Error).message}`);
1195
+ }
1196
+ };
1197
+
1198
+ const onMarkWorkedBy = async (status: 'doing' | 'done' | 'idle', complete: boolean) => {
1199
+ const agent = agentEl?.value || '';
1200
+ if (!agent) {
1201
+ toast.warning('Pick an agent first.');
1202
+ return;
1203
+ }
1204
+ try {
1205
+ const updated = await api.post<Task>(`/tasks/${encodeURIComponent(task.id)}/work`, {
1206
+ agent,
1207
+ status,
1208
+ complete,
1209
+ });
1210
+ setTasks((cur) => cur.map((x) => (x.id === task.id ? updated : x)));
1211
+ toast.success(`${status} by @${agent}.`, 1200);
1212
+ } catch (err) {
1213
+ toast.error(`Failed: ${(err as Error).message}`);
1214
+ }
1215
+ };
1216
+
1217
+ const timerSeconds = task._timerStart
1218
+ ? Math.floor((Date.now() - task._timerStart) / 1000) + (task.timeSpent || 0)
1219
+ : (task.timeSpent || 0);
1220
+
1221
+ modal.open({
1222
+ title: `Task — ${task.title}`,
1223
+ width: 640,
1224
+ children: (
1225
+ <div className="task-detail">
1226
+ <div className="task-detail-meta">
1227
+ <span><strong>Status:</strong> {task.status}</span>
1228
+ <span><strong>Priority:</strong> {task.priority}</span>
1229
+ {task.assignee && <span><strong>Assignee:</strong> @{task.assignee}</span>}
1230
+ {task.workedBy && <span><strong>Working:</strong> <span className="is-working" style={{ display: 'inline-block', padding: '0 6px' }}>@{task.workedBy}</span></span>}
1231
+ {task.dueDate && <span><strong>Due:</strong> {task.dueDate.slice(0, 10)}</span>}
1232
+ <span><strong>Time:</strong> {formatDuration(timerSeconds)}</span>
1233
+ </div>
1234
+ {task.description && (
1235
+ <Card>
1236
+ <CardTitle>Description</CardTitle>
1237
+ <div className="task-detail-desc">{task.description}</div>
1238
+ </Card>
1239
+ )}
1240
+ <Card>
1241
+ <CardTitle><Bot size={14} /> Mark as worked on by</CardTitle>
1242
+ <div className="task-detail-row">
1243
+ <select ref={(el) => { agentEl = el; }} className="select" defaultValue={task.workedBy || ''}>
1244
+ <option value="">(pick an agent)</option>
1245
+ {agents.map((a) => (
1246
+ <option key={a.name} value={a.name}>@{a.name}</option>
1247
+ ))}
1248
+ </select>
1249
+ <Button variant="primary" size="sm" onClick={() => onMarkWorkedBy('doing', false)}>
1250
+ <PlayCircle size={12} /> Start working
1251
+ </Button>
1252
+ <Button variant="success" size="sm" onClick={() => onMarkWorkedBy('done', true)}>
1253
+ <CheckSquare size={12} /> Complete
1254
+ </Button>
1255
+ <Button variant="ghost" size="sm" onClick={() => onMarkWorkedBy('idle', false)}>
1256
+ <PauseCircle size={12} /> Stop
1257
+ </Button>
1258
+ </div>
1259
+ </Card>
1260
+ <Card>
1261
+ <CardTitle><Clock size={14} /> Time tracking</CardTitle>
1262
+ <div className="task-detail-row">
1263
+ {!task._timerStart ? (
1264
+ <Button variant="secondary" size="sm" onClick={onStartTimer}>
1265
+ <PlayCircle size={12} /> Start
1266
+ </Button>
1267
+ ) : (
1268
+ <Button variant="secondary" size="sm" onClick={onStopTimer} className="is-working">
1269
+ <PauseCircle size={12} /> Stop
1270
+ </Button>
1271
+ )}
1272
+ <span className={cn('muted', task._timerStart ? 'is-working' : null)}>
1273
+ {formatDuration(timerSeconds)}
1274
+ {task._timerStart && ' (running)'}
1275
+ </span>
1276
+ </div>
1277
+ </Card>
1278
+ <Card>
1279
+ <CardTitle><Link2 size={14} /> Dependencies</CardTitle>
1280
+ <div className="task-detail-row">
1281
+ <input
1282
+ ref={(el) => { depEl = el; }}
1283
+ className="input"
1284
+ type="text"
1285
+ placeholder="task id (e.g. tsk_abc12345)"
1286
+ />
1287
+ <Button variant="secondary" size="sm" onClick={onAddDep}>Add</Button>
1288
+ </div>
1289
+ <ul>
1290
+ {(task.dependencies || []).map((d) => (
1291
+ <li key={d}><code>{d}</code></li>
1292
+ ))}
1293
+ {(task.dependencies || []).length === 0 && <li className="muted">No dependencies.</li>}
1294
+ </ul>
1295
+ </Card>
1296
+ {task.recurring && (
1297
+ <Card>
1298
+ <CardTitle><Calendar size={14} /> Recurring</CardTitle>
1299
+ <div className="muted">Frequency: <code>{task.recurring.freq || 'custom'}</code> {task.recurring.cron && <code>{task.recurring.cron}</code>}</div>
1300
+ {task.recurring.lastGenerated && (
1301
+ <div className="muted text-sm">Last spawned: {formatRelative(task.recurring.lastGenerated)}</div>
1302
+ )}
1303
+ </Card>
1304
+ )}
1305
+ <Card>
1306
+ <CardTitle><MessageSquare size={14} /> Comments</CardTitle>
1307
+ <textarea
1308
+ ref={(el) => { commentEl = el; }}
1309
+ className="textarea"
1310
+ rows={3}
1311
+ placeholder="Add a comment…"
1312
+ />
1313
+ <Button variant="primary" size="sm" onClick={onComment}>Post</Button>
1314
+ <ul className="task-comments">
1315
+ {(task.comments || []).map((c) => (
1316
+ <li key={c.id}>
1317
+ <div className="muted tabular-nums">{formatRelative(c.createdAt)}</div>
1318
+ <div>{c.text}</div>
1319
+ </li>
1320
+ ))}
1321
+ {(task.comments || []).length === 0 && <li className="muted">No comments.</li>}
1322
+ </ul>
1323
+ </Card>
1324
+ <Card>
1325
+ <CardTitle><Activity size={14} /> Activity</CardTitle>
1326
+ <ul className="task-activity">
1327
+ {(task.activity || []).slice().reverse().slice(0, 20).map((a) => (
1328
+ <li key={a.id}>
1329
+ <span className="muted tabular-nums">{formatRelative(a.ts)}</span>{' '}
1330
+ <span className="tag">{a.type}</span>
1331
+ {a.data && typeof a.data === 'object' ? (
1332
+ <code className="muted"> {JSON.stringify(a.data)}</code>
1333
+ ) : null}
1334
+ </li>
1335
+ ))}
1336
+ {(task.activity || []).length === 0 && <li className="muted">No activity yet.</li>}
1337
+ </ul>
1338
+ </Card>
1339
+ </div>
1340
+ ),
1341
+ footer: (
1342
+ <div className="modal-footer-actions">
1343
+ <Button variant="ghost" onClick={() => modal.close()}>Close</Button>
1344
+ <Button variant="primary" onClick={() => { modal.close(); openTaskModal(modal, toast, task, task.status, setTasks, reload, refreshSnapshot, agents); }}>
1345
+ <Sparkles size={12} /> Edit basics
1346
+ </Button>
1347
+ </div>
1348
+ ),
1349
+ });
1350
+ }
1351
+
1352
+ // v3.2.0 — Submit a task to Odin. Odin splits it into subtasks and
1353
+ // assigns each to the best-fit agent. Falls back to a regular task
1354
+ // create if the delegator route is unavailable (older dashboards).
1355
+ function openSubmitTaskModal(
1356
+ modal: ReturnType<typeof useModal>,
1357
+ toast: ReturnType<typeof useToast>,
1358
+ setTasks: (updater: (cur: Task[]) => Task[]) => void,
1359
+ reload: () => Promise<void>,
1360
+ refreshSnapshot: () => Promise<void>,
1361
+ ) {
1362
+ let titleEl: HTMLInputElement | null = null;
1363
+ let descEl: HTMLTextAreaElement | null = null;
1364
+ let priorityEl: HTMLSelectElement | null = null;
1365
+ let tagsEl: HTMLInputElement | null = null;
1366
+
1367
+ // v3.3.0 — Wraps the submit click so we can preventDefault /
1368
+ // stopPropagation. Without this, a click on a button could bubble
1369
+ // up through the modal portal after the modal closes and either
1370
+ // trigger a sibling button or, more importantly, allow a
1371
+ // subsequent digit-key event to fire setActiveTab("overview")
1372
+ // because the just-closed modal had removed the user's focused
1373
+ // form control.
1374
+ const onSubmit = async (e?: React.SyntheticEvent) => {
1375
+ if (e && typeof e.preventDefault === 'function') e.preventDefault();
1376
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
1377
+ const description = descEl?.value || '';
1378
+ let title = (titleEl?.value || '').trim();
1379
+ // v3.7.1 — Title is now optional. Auto-generate from the description
1380
+ // when the user left it blank. Reject only if BOTH fields are empty.
1381
+ if (!title && description.trim()) {
1382
+ title = autoTitleFromContent(description);
1383
+ if (titleEl) titleEl.value = title;
1384
+ }
1385
+ if (!title) {
1386
+ toast.warning('Add a description so Odin knows what to do.');
1387
+ return;
1388
+ }
1389
+ const priority = priorityEl?.value || 'normal';
1390
+ const tags = (tagsEl?.value || '')
1391
+ .split(',')
1392
+ .map((t: string) => t.trim())
1393
+ .filter(Boolean);
1394
+
1395
+ try {
1396
+ const result = await api.post<{ main: Task; subtasks: Task[] }>('/tasks/submit', {
1397
+ title,
1398
+ description,
1399
+ priority,
1400
+ tags,
1401
+ });
1402
+ const count = (result.subtasks || []).length;
1403
+ toast.success(
1404
+ count > 1
1405
+ ? `Odin split it into ${count} subtasks.`
1406
+ : 'Task submitted to Odin.',
1407
+ );
1408
+ // v3.3.1 — CRITICAL: close modal FIRST, then bump safe window,
1409
+ // then update state. This way the portal unmount + React re-render
1410
+ // can't trigger any key handlers in the brief window before state
1411
+ // updates. We manually dispatch a synthetic mousedown to extend
1412
+ // the safe window so any stray keyboard events during the state-
1413
+ // update tick also can't trigger tab switches.
1414
+ modal.close();
1415
+ if (typeof window !== 'undefined') {
1416
+ window.dispatchEvent(new MouseEvent('mousedown'));
1417
+ }
1418
+ if (setTasks) setTasks((cur: Task[]) => [result.main, ...(result.subtasks || []), ...cur]);
1419
+ if (reload) await reload();
1420
+ if (refreshSnapshot) await refreshSnapshot();
1421
+ } catch (err) {
1422
+ toast.error(`Submit failed: ${(err as Error).message}`);
1423
+ }
1424
+ };
1425
+
1426
+ // v3.3.0 — Submit on Enter from the title input. Without an
1427
+ // explicit onKeyDown, the implicit form-submit behavior can fire
1428
+ // when a user presses Enter. Even though we don't wrap inputs in
1429
+ // <form>, this is the safest pattern.
1430
+ const onTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
1431
+ if (e.key === 'Enter' && !e.shiftKey) {
1432
+ e.preventDefault();
1433
+ onSubmit(e);
1434
+ }
1435
+ };
1436
+
1437
+ modal.open({
1438
+ title: 'Submit Task to Odin',
1439
+ width: 560,
1440
+ children: (
1441
+ <div className="submit-task-form">
1442
+ <label htmlFor="submit-task-title">
1443
+ Title <span className="field-hint">(optional — auto-generated from description)</span>
1444
+ <input
1445
+ id="submit-task-title"
1446
+ ref={(el) => { titleEl = el; }}
1447
+ className="input"
1448
+ type="text"
1449
+ placeholder="Leave blank to derive from description"
1450
+ autoFocus
1451
+ onKeyDown={onTitleKeyDown}
1452
+ />
1453
+ <span className="field-hint">
1454
+ Odin will analyze this and split it into subtasks assigned to the best agent.
1455
+ </span>
1456
+ </label>
1457
+ <label htmlFor="submit-task-desc">
1458
+ Description
1459
+ <span style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
1460
+ <textarea
1461
+ id="submit-task-desc"
1462
+ ref={(el) => { descEl = el; }}
1463
+ className="textarea"
1464
+ rows={5}
1465
+ placeholder="Provide more detail (markdown ok)…"
1466
+ style={{ flex: 1 }}
1467
+ />
1468
+ <button
1469
+ type="button"
1470
+ className="btn btn-ghost btn-icon"
1471
+ style={{ marginTop: 0, flexShrink: 0 }}
1472
+ title="Enhance prompt with AI"
1473
+ onClick={async () => {
1474
+ if (!descEl?.value?.trim()) return;
1475
+ const enhanced = await enhancePrompt(descEl.value);
1476
+ if (enhanced !== descEl.value) descEl.value = enhanced;
1477
+ }}
1478
+ >
1479
+ <Sparkles size={14} />
1480
+ </button>
1481
+ </span>
1482
+ </label>
1483
+ <div className="task-form-row">
1484
+ <label htmlFor="submit-task-priority" className="task-form-field">
1485
+ Priority
1486
+ <select
1487
+ id="submit-task-priority"
1488
+ ref={(el) => { priorityEl = el; }}
1489
+ className="select"
1490
+ defaultValue="normal"
1491
+ >
1492
+ {PRIORITIES.map((p) => (
1493
+ <option key={p} value={p}>{p}</option>
1494
+ ))}
1495
+ </select>
1496
+ </label>
1497
+ <label htmlFor="submit-task-tags" className="task-form-field" style={{ flex: 2 }}>
1498
+ Tags <span className="field-hint">(comma-separated)</span>
1499
+ <input
1500
+ id="submit-task-tags"
1501
+ ref={(el) => { tagsEl = el; }}
1502
+ className="input"
1503
+ type="text"
1504
+ placeholder="backend, auth"
1505
+ />
1506
+ </label>
1507
+ </div>
1508
+ </div>
1509
+ ),
1510
+ footer: (
1511
+ <div className="modal-footer-actions">
1512
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
1513
+ <Button
1514
+ variant="primary"
1515
+ type="button"
1516
+ onClick={(e) => onSubmit(e)}
1517
+ >
1518
+ <Send size={14} /> Submit to Odin
1519
+ </Button>
1520
+ </div>
1521
+ ),
1522
+ });
1523
+ }