@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,1480 @@
1
+ // src/views/Artifacts.tsx — v3.1.0 artifact editor with fullscreen canvas + floating controls.
2
+ import React from 'react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { GlyphRenderer } from './glyphs/GlyphRenderer';
5
+ import {
6
+ ArrowLeft,
7
+ Map as MapIcon,
8
+ Plus,
9
+ RefreshCw,
10
+ Settings as SettingsIcon,
11
+ MessageCircle,
12
+ MessageSquare,
13
+ Maximize2,
14
+ Minimize2,
15
+ X,
16
+ Trash2,
17
+ Pencil,
18
+ Link2,
19
+ Send,
20
+ Check,
21
+ Circle,
22
+ StopCircle,
23
+ AlertCircle,
24
+ HelpCircle,
25
+ CheckSquare,
26
+ StickyNote,
27
+ ChevronRight,
28
+ Search,
29
+ Activity,
30
+ Filter,
31
+ Archive,
32
+ ArchiveRestore,
33
+ Copy,
34
+ ArrowUp,
35
+ ArrowDown,
36
+ Layers,
37
+ } from 'lucide-react';
38
+ import { Button } from '../components/Button';
39
+ import { Card, CardTitle, CardMeta } from '../components/Card';
40
+ import { EmptyState } from '../components/EmptyState';
41
+ import { Spinner } from '../components/Spinner';
42
+ import { StatusBadge } from '../components/StatusBadge';
43
+ import { useToast } from '../components/Toast';
44
+ import { useModal } from '../components/Modal';
45
+ import { CanvasContextMenu, type ContextMenuState } from '../components/CanvasContextMenu';
46
+ import { api } from '../lib/api';
47
+ import { cn, formatRelative, truncate } from '../lib/utils';
48
+ import type {
49
+ Canvas,
50
+ CanvasComment,
51
+ CanvasConnection,
52
+ CanvasElement,
53
+ Artifact,
54
+ Settings,
55
+ Snapshot,
56
+ } from '../lib/types';
57
+
58
+ type Props = {
59
+ snapshot: Snapshot;
60
+ settings: Settings;
61
+ activeTab: string;
62
+ setActiveTab: (id: string) => void;
63
+ refreshSnapshot: () => Promise<void>;
64
+ };
65
+
66
+ const ELEMENT_TYPES = [
67
+ { id: 'task', label: 'Task', color: 'var(--info)', Icon: CheckSquare },
68
+ { id: 'note', label: 'Note', color: 'var(--text-dim)', Icon: StickyNote },
69
+ { id: 'decision', label: 'Decision', color: 'var(--accent)', Icon: Check },
70
+ { id: 'question', label: 'Question', color: 'var(--warning)', Icon: HelpCircle },
71
+ ];
72
+
73
+ const PLAN_STATUSES = ['draft', 'approved', 'in-progress', 'done', 'rejected', 'archived'];
74
+
75
+ function planStatusKind(status: string): 'success' | 'info' | 'error' | 'accent' | 'neutral' | 'warning' {
76
+ switch (status) {
77
+ case 'approved':
78
+ case 'done':
79
+ return 'success';
80
+ case 'in-progress':
81
+ case 'doing':
82
+ return 'info';
83
+ case 'rejected':
84
+ return 'error';
85
+ case 'archived':
86
+ return 'warning';
87
+ case 'draft':
88
+ default:
89
+ return 'neutral';
90
+ }
91
+ }
92
+
93
+ function elementColor(type: string): string {
94
+ return ELEMENT_TYPES.find((t) => t.id === type)?.color || 'var(--text-dim)';
95
+ }
96
+
97
+ function ElementIcon({ type, size = 12 }: { type: string; size?: number }) {
98
+ const def = ELEMENT_TYPES.find((t) => t.id === type) || ELEMENT_TYPES[1];
99
+ const Icon = def.Icon;
100
+ return <Icon size={size} style={{ color: def.color }} />;
101
+ }
102
+
103
+ // ── List view ──────────────────────────────────────────────────────────
104
+
105
+ export function Artifacts({ snapshot, refreshSnapshot }: Props) {
106
+ const toast = useToast();
107
+ const [artifacts, setPlans] = useState<Artifact[]>(snapshot.artifacts || []);
108
+ const [loading, setLoading] = useState(!snapshot.artifacts);
109
+ const [activeSlug, setActiveSlug] = useState<string | null>(null);
110
+ const [filter, setFilter] = useState('');
111
+ const [showArchived, setShowArchived] = useState(false);
112
+
113
+ useEffect(() => {
114
+ if (snapshot.artifacts) {
115
+ setPlans(snapshot.artifacts);
116
+ setLoading(false);
117
+ }
118
+ }, [snapshot.artifacts]);
119
+
120
+ const reload = async () => {
121
+ try {
122
+ const d = await api.get<{ artifacts: Artifact[] }>('/artifacts');
123
+ setPlans(d.artifacts || []);
124
+ setLoading(false);
125
+ } catch (err) {
126
+ toast.error(`Artifacts load failed: ${(err as Error).message}`);
127
+ setLoading(false);
128
+ }
129
+ };
130
+
131
+ const filtered = useMemo(() => {
132
+ let out = artifacts;
133
+ if (filter) {
134
+ const q = filter.toLowerCase();
135
+ out = out.filter(
136
+ (p) =>
137
+ (p.slug || '').toLowerCase().includes(q) ||
138
+ (p.title || '').toLowerCase().includes(q),
139
+ );
140
+ }
141
+ if (!showArchived) {
142
+ out = out.filter((p) => p.status !== 'archived');
143
+ }
144
+ return out;
145
+ }, [artifacts, filter, showArchived]);
146
+
147
+ const onCreate = async (slug: string, title?: string) => {
148
+ try {
149
+ const artifact = await api.post<{ slug: string }>('/artifacts', { slug, title });
150
+ toast.success(`Artifact "${artifact.slug}" created.`);
151
+ setActiveSlug(artifact.slug);
152
+ await reload();
153
+ } catch (err) {
154
+ toast.error(`Create failed: ${(err as Error).message}`);
155
+ }
156
+ };
157
+
158
+ const onDelete = async (slug: string) => {
159
+ if (!confirm(`Delete artifact "${slug}"? This removes the directory permanently.`)) return;
160
+ try {
161
+ await api.del(`/artifacts/${encodeURIComponent(slug)}`);
162
+ toast.success('Artifact deleted.');
163
+ if (activeSlug === slug) setActiveSlug(null);
164
+ await reload();
165
+ } catch (err) {
166
+ toast.error(`Delete failed: ${(err as Error).message}`);
167
+ }
168
+ };
169
+
170
+ if (activeSlug) {
171
+ // v3.21.0 — Use the new GlyphRenderer (agent-native-style MDX renderer)
172
+ // instead of the hand-rolled PlanEditor canvas. Toggle available via the
173
+ // "Use MDX" button in the header (TODO: surface this once stable).
174
+ return (
175
+ <div className="artifact-glyph-overlay">
176
+ <div className="artifact-glyph-toolbar">
177
+ <Button variant="ghost" onClick={() => { setActiveSlug(null); reload(); }}>
178
+ <ArrowLeft size={14} /> Back to artifacts
179
+ </Button>
180
+ </div>
181
+ <GlyphRenderer
182
+ slug={activeSlug}
183
+ onClose={() => { setActiveSlug(null); reload(); }}
184
+ onCommentAdded={() => reload()}
185
+ />
186
+ </div>
187
+ );
188
+ }
189
+
190
+ return (
191
+ <div className="view view-artifacts">
192
+ <header className="view-header">
193
+ <div className="view-header-text">
194
+ <h2 className="view-title">
195
+ <MapIcon size={18} /> Glyphs ({artifacts.length})
196
+ </h2>
197
+ <p className="view-subtitle">
198
+ Visual artifacts with elements, connections, and threaded comments.
199
+ </p>
200
+ </div>
201
+ <div className="view-actions">
202
+ <div className="search-input">
203
+ <Search size={14} />
204
+ <input
205
+ className="input"
206
+ type="text"
207
+ placeholder="Search…"
208
+ value={filter}
209
+ onChange={(e) => setFilter(e.target.value)}
210
+ />
211
+ </div>
212
+ <Button
213
+ variant="ghost"
214
+ size="sm"
215
+ onClick={() => setShowArchived((v) => !v)}
216
+ title={showArchived ? 'Hide archived' : 'Show archived'}
217
+ >
218
+ {showArchived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
219
+ {showArchived ? 'Hide archived' : 'Show archived'}
220
+ </Button>
221
+ <Button variant="secondary" size="sm" onClick={reload}>
222
+ <RefreshCw size={14} /> Refresh
223
+ </Button>
224
+ </div>
225
+ </header>
226
+
227
+ <NewPlanCard onCreate={onCreate} />
228
+
229
+ {loading ? (
230
+ <div className="view-loading">
231
+ <Spinner size="lg" />
232
+ </div>
233
+ ) : filtered.length === 0 ? (
234
+ <EmptyState
235
+ icon={<MapIcon size={32} />}
236
+ title={showArchived ? 'No artifacts' : 'No artifacts yet'}
237
+ message={
238
+ showArchived
239
+ ? 'No artifacts match your filter (try Show archived off).'
240
+ : 'Create one above to get started.'
241
+ }
242
+ />
243
+ ) : (
244
+ <div className="artifacts-grid">
245
+ {filtered.map((p) => (
246
+ <PlanCard
247
+ key={p.slug}
248
+ artifact={p}
249
+ onOpen={() => setActiveSlug(p.slug)}
250
+ onDelete={() => onDelete(p.slug)}
251
+ />
252
+ ))}
253
+ </div>
254
+ )}
255
+ </div>
256
+ );
257
+ }
258
+
259
+ function NewPlanCard({ onCreate }: { onCreate: (slug: string, title?: string) => void }) {
260
+ const [slug, setSlug] = useState('');
261
+ const [title, setTitle] = useState('');
262
+ return (
263
+ <Card className="new-artifact">
264
+ <CardTitle>
265
+ <Plus size={14} /> New artifact
266
+ </CardTitle>
267
+ <CardMeta>Slug must be lowercase, may contain hyphens, 1–64 chars.</CardMeta>
268
+ <form
269
+ className="new-artifact-form"
270
+ onSubmit={(e) => {
271
+ e.preventDefault();
272
+ if (!slug.trim()) return;
273
+ onCreate(slug.trim(), title.trim() || undefined);
274
+ setSlug('');
275
+ setTitle('');
276
+ }}
277
+ >
278
+ <input
279
+ className="input"
280
+ type="text"
281
+ placeholder="slug (e.g. dashboard-v3.1)"
282
+ pattern="[a-z0-9][a-z0-9-]{0,63}"
283
+ required
284
+ value={slug}
285
+ onChange={(e) => setSlug(e.target.value)}
286
+ />
287
+ <input
288
+ className="input"
289
+ type="text"
290
+ placeholder="Title (optional)"
291
+ value={title}
292
+ onChange={(e) => setTitle(e.target.value)}
293
+ />
294
+ <Button variant="primary" type="submit">
295
+ Create
296
+ </Button>
297
+ </form>
298
+ </Card>
299
+ );
300
+ }
301
+
302
+ function PlanCard({
303
+ artifact,
304
+ onOpen,
305
+ onDelete,
306
+ }: {
307
+ artifact: Artifact;
308
+ onOpen: () => void;
309
+ onDelete: () => void;
310
+ }) {
311
+ const kind = planStatusKind(artifact.status || 'draft');
312
+ return (
313
+ <Card
314
+ variant="elevated"
315
+ interactive
316
+ className="artifact-card"
317
+ onClick={onOpen}
318
+ >
319
+ <div className="artifact-card-head">
320
+ <div className="artifact-card-title">{artifact.title || artifact.slug}</div>
321
+ <StatusBadge kind={kind}>{artifact.status || 'draft'}</StatusBadge>
322
+ </div>
323
+ <div className="artifact-card-slug mono">{artifact.slug} · {artifact.source}</div>
324
+ <div className="artifact-card-meta">
325
+ {artifact.elementCount != null && <span>{artifact.elementCount} elements</span>}
326
+ {artifact.commentCount != null && <span> · {artifact.commentCount} comments</span>}
327
+ <span> · edited {formatRelative(artifact.mtime)}</span>
328
+ </div>
329
+ <div className="artifact-card-actions">
330
+ <Button variant="primary" size="sm" onClick={(e) => { e.stopPropagation(); onOpen(); }}>
331
+ Open
332
+ <ChevronRight size={12} />
333
+ </Button>
334
+ <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onDelete(); }}>
335
+ <Trash2 size={12} /> Delete
336
+ </Button>
337
+ </div>
338
+ </Card>
339
+ );
340
+ }
341
+
342
+ // ── Artifact editor (fullscreen) ──────────────────────────────────────────
343
+
344
+ function PlanEditor({
345
+ slug,
346
+ onBack,
347
+ onDelete,
348
+ }: {
349
+ slug: string;
350
+ onBack: () => void;
351
+ onDelete: () => void;
352
+ }) {
353
+ const toast = useToast();
354
+ const modal = useModal();
355
+ const [canvas, setCanvas] = useState<Canvas | null>(null);
356
+ const [meta, setMeta] = useState<{ title: string; status: string; tags: string[]; description?: string } | null>(null);
357
+ const [loading, setLoading] = useState(true);
358
+ const [selectedElId, setSelectedElId] = useState<string | null>(null);
359
+ const [fullscreen, setFullscreen] = useState(false);
360
+ const [showComments, setShowComments] = useState(true);
361
+ const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
362
+ const [contextMenuWorldPos, setContextMenuWorldPos] = useState<{ x: number; y: number } | null>(null);
363
+ // v3.7.0 — Element-specific right-click context menu
364
+ const [elementContextMenu, setElementContextMenu] = useState<ContextMenuState>(null);
365
+
366
+ const moveElementLocally = (id: string, x: number, y: number) => {
367
+ setCanvas((cur) => {
368
+ if (!cur) return cur;
369
+ return {
370
+ ...cur,
371
+ elements: cur.elements.map((el) => (el.id === id ? { ...el, x, y } : el)),
372
+ };
373
+ });
374
+ };
375
+
376
+ const reload = async () => {
377
+ setLoading(true);
378
+ try {
379
+ const artifact = await api.get<{ meta: { title: string; status: string; tags: string[]; description?: string }; canvas: Canvas }>(
380
+ `/artifacts/${encodeURIComponent(slug)}`,
381
+ );
382
+ setCanvas(artifact.canvas);
383
+ setMeta(artifact.meta);
384
+ } catch (err) {
385
+ toast.error(`Could not load artifact: ${(err as Error).message}`);
386
+ } finally {
387
+ setLoading(false);
388
+ }
389
+ };
390
+
391
+ useEffect(() => {
392
+ setSelectedElId(null);
393
+ reload();
394
+ // eslint-disable-next-line react-hooks/exhaustive-deps
395
+ }, [slug]);
396
+
397
+ // Issue 3 fix — wire fullscreen state to browser Fullscreen API
398
+ useEffect(() => {
399
+ if (!fullscreen) {
400
+ if (document.fullscreenElement) {
401
+ document.exitFullscreen().catch(() => {});
402
+ }
403
+ return;
404
+ }
405
+ // Sync state when user exits via ESC key — before entering, so we don't
406
+ // double-set on the way in
407
+ const onFullscreenChange = () => {
408
+ if (!document.fullscreenElement) setFullscreen(false);
409
+ };
410
+ document.addEventListener('fullscreenchange', onFullscreenChange);
411
+ // Attempt to enter fullscreen (fails silently if unsupported or blocked)
412
+ document.documentElement.requestFullscreen().catch(() => {});
413
+ return () => {
414
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
415
+ if (document.fullscreenElement) {
416
+ document.exitFullscreen().catch(() => {});
417
+ }
418
+ };
419
+ // eslint-disable-next-line react-hooks/exhaustive-deps
420
+ }, [fullscreen]);
421
+
422
+ const totalComments = canvas?.comments.length ?? 0;
423
+
424
+ const addElement = async (body: { type: string; title: string; content: string; x: number; y: number }, position?: { x: number; y: number }) => {
425
+ try {
426
+ const payload = position ? { ...body, x: position.x, y: position.y } : body;
427
+ await api.post(`/artifacts/${encodeURIComponent(slug)}/elements`, payload);
428
+ await reload();
429
+ toast.success('Element added.');
430
+ } catch (err) {
431
+ toast.error(`Add element failed: ${(err as Error).message}`);
432
+ }
433
+ };
434
+
435
+ const updateElement = async (id: string, body: Partial<CanvasElement>) => {
436
+ try {
437
+ await api.put(`/artifacts/${encodeURIComponent(slug)}/elements/${encodeURIComponent(id)}`, body);
438
+ await reload();
439
+ } catch (err) {
440
+ toast.error(`Update failed: ${(err as Error).message}`);
441
+ }
442
+ };
443
+
444
+ const deleteElement = async (id: string) => {
445
+ try {
446
+ await api.del(`/artifacts/${encodeURIComponent(slug)}/elements/${encodeURIComponent(id)}`);
447
+ if (selectedElId === id) setSelectedElId(null);
448
+ await reload();
449
+ toast.success('Element deleted.');
450
+ } catch (err) {
451
+ toast.error(`Delete failed: ${(err as Error).message}`);
452
+ }
453
+ };
454
+
455
+ // v3.7.0 — Duplicate an element with a small position offset
456
+ const duplicateElement = async (id: string) => {
457
+ const el = canvas?.elements.find((e) => e.id === id);
458
+ if (!el) return;
459
+ try {
460
+ await api.post(`/artifacts/${encodeURIComponent(slug)}/elements`, {
461
+ type: el.type,
462
+ title: el.title ? `${el.title} (copy)` : 'Untitled',
463
+ content: el.content || '',
464
+ x: (el.x || 0) + 24,
465
+ y: (el.y || 0) + 24,
466
+ });
467
+ await reload();
468
+ toast.success('Element duplicated.');
469
+ } catch (err) {
470
+ toast.error(`Duplicate failed: ${(err as Error).message}`);
471
+ }
472
+ };
473
+
474
+ // v3.7.0 — Change element type
475
+ const changeElementType = async (id: string, newType: string) => {
476
+ try {
477
+ await api.put(`/artifacts/${encodeURIComponent(slug)}/elements/${encodeURIComponent(id)}`, { type: newType });
478
+ await reload();
479
+ toast.success(`Type changed to ${newType}.`);
480
+ } catch (err) {
481
+ toast.error(`Change type failed: ${(err as Error).message}`);
482
+ }
483
+ };
484
+
485
+ const addConnection = async (from: string, to: string, label?: string) => {
486
+ try {
487
+ await api.post(`/artifacts/${encodeURIComponent(slug)}/connections`, { from, to, label });
488
+ await reload();
489
+ toast.success('Connection added.');
490
+ } catch (err) {
491
+ toast.error(`Connection failed: ${(err as Error).message}`);
492
+ }
493
+ };
494
+
495
+ const deleteConnection = async (id: string) => {
496
+ try {
497
+ await api.del(`/artifacts/${encodeURIComponent(slug)}/connections/${encodeURIComponent(id)}`);
498
+ await reload();
499
+ } catch (err) {
500
+ toast.error(`Delete failed: ${(err as Error).message}`);
501
+ }
502
+ };
503
+
504
+ const addComment = async (text: string, elementId: string | null, worldPos?: { x: number; y: number }) => {
505
+ try {
506
+ const path = elementId
507
+ ? `/artifacts/${encodeURIComponent(slug)}/elements/${encodeURIComponent(elementId)}/comments`
508
+ : `/artifacts/${encodeURIComponent(slug)}/comments`;
509
+ const body: { text: string; elementId: string | null; x?: number; y?: number } = { text, elementId };
510
+ if (worldPos) { body.x = worldPos.x; body.y = worldPos.y; }
511
+ await api.post(path, body);
512
+ await reload();
513
+ toast.success('Comment added.');
514
+ } catch (err) {
515
+ toast.error(`Comment failed: ${(err as Error).message}`);
516
+ }
517
+ };
518
+
519
+ const deleteComment = async (cid: string) => {
520
+ try {
521
+ await api.del(`/artifacts/${encodeURIComponent(slug)}/comments/${encodeURIComponent(cid)}`);
522
+ await reload();
523
+ } catch (err) {
524
+ toast.error(`Delete failed: ${(err as Error).message}`);
525
+ }
526
+ };
527
+
528
+ const updatePositions = async (positions: { id: string; x: number; y: number }[]) => {
529
+ try {
530
+ await api.put(`/artifacts/${encodeURIComponent(slug)}/position`, { positions });
531
+ } catch (err) {
532
+ // non-fatal
533
+ console.warn('Position update failed', err);
534
+ }
535
+ };
536
+
537
+ const onConfigure = () => {
538
+ if (!meta) return;
539
+ let titleEl: HTMLInputElement | null = null;
540
+ let descEl: HTMLTextAreaElement | null = null;
541
+ let tagsEl: HTMLInputElement | null = null;
542
+ let statusEl: HTMLSelectElement | null = null;
543
+ modal.open({
544
+ title: 'Configure artifact',
545
+ width: 560,
546
+ children: (
547
+ <div className="artifact-config-form">
548
+ <label className="field-label">Title</label>
549
+ <input ref={(el) => { titleEl = el; }} className="input" type="text" defaultValue={meta.title} />
550
+ <label className="field-label">Description (markdown)</label>
551
+ <textarea ref={(el) => { descEl = el; }} className="textarea" rows={3} defaultValue={meta.description || ''} />
552
+ <label className="field-label">Tags (comma-separated)</label>
553
+ <input ref={(el) => { tagsEl = el; }} className="input" type="text" defaultValue={(meta.tags || []).join(', ')} />
554
+ <label className="field-label">Status</label>
555
+ <select ref={(el) => { statusEl = el; }} className="select" defaultValue={meta.status}>
556
+ {PLAN_STATUSES.map((s) => (
557
+ <option key={s} value={s}>{s}</option>
558
+ ))}
559
+ </select>
560
+ </div>
561
+ ),
562
+ footer: (
563
+ <div className="modal-footer-actions">
564
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
565
+ <Button
566
+ variant="primary"
567
+ onClick={async () => {
568
+ try {
569
+ const tags = (tagsEl?.value || '').split(',').map((t) => t.trim()).filter(Boolean);
570
+ await api.put(`/artifacts/${encodeURIComponent(slug)}`, {
571
+ title: (titleEl?.value || '').trim() || meta.title,
572
+ description: descEl?.value || '',
573
+ tags,
574
+ status: statusEl?.value || meta.status,
575
+ });
576
+ await reload();
577
+ toast.success('Artifact updated.');
578
+ modal.close();
579
+ } catch (err) {
580
+ toast.error(`Save failed: ${(err as Error).message}`);
581
+ }
582
+ }}
583
+ >
584
+ Save
585
+ </Button>
586
+ </div>
587
+ ),
588
+ });
589
+ };
590
+
591
+ const onAddElement = (worldPos?: { x: number; y: number }) => {
592
+ let typeEl: HTMLSelectElement | null = null;
593
+ let titleEl: HTMLInputElement | null = null;
594
+ let contentEl: HTMLTextAreaElement | null = null;
595
+ modal.open({
596
+ title: 'Add element',
597
+ width: 520,
598
+ children: (
599
+ <div className="artifact-element-form">
600
+ <label className="field-label">Type</label>
601
+ <select ref={(el) => { typeEl = el; }} className="select" defaultValue="task">
602
+ {ELEMENT_TYPES.map((t) => (
603
+ <option key={t.id} value={t.id}>{t.label}</option>
604
+ ))}
605
+ </select>
606
+ <label className="field-label">Title</label>
607
+ <input ref={(el) => { titleEl = el; }} className="input" type="text" placeholder="Element title" autoFocus />
608
+ <label className="field-label">Content (markdown)</label>
609
+ <textarea ref={(el) => { contentEl = el; }} className="textarea" rows={4} placeholder="Description…" />
610
+ </div>
611
+ ),
612
+ footer: (
613
+ <div className="modal-footer-actions">
614
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
615
+ <Button
616
+ variant="primary"
617
+ onClick={async (e) => {
618
+ e.preventDefault();
619
+ e.stopPropagation();
620
+ if (worldPos) {
621
+ await addElement({
622
+ type: typeEl?.value || 'note',
623
+ title: (titleEl?.value || 'Untitled').trim(),
624
+ content: contentEl?.value || '',
625
+ x: worldPos.x,
626
+ y: worldPos.y,
627
+ });
628
+ } else {
629
+ const maxX = (canvas?.elements || []).reduce((acc, el) => Math.max(acc, el.x || 0), 0);
630
+ const maxY = (canvas?.elements || []).reduce((acc, el) => Math.max(acc, el.y || 0), 0);
631
+ await addElement({
632
+ type: typeEl?.value || 'note',
633
+ title: (titleEl?.value || 'Untitled').trim(),
634
+ content: contentEl?.value || '',
635
+ x: 80 + Math.min(maxX + 40, 200),
636
+ y: 80 + Math.min(maxY + 40, 200),
637
+ });
638
+ }
639
+ modal.close();
640
+ }}
641
+ >
642
+ <Plus size={12} /> Add
643
+ </Button>
644
+ </div>
645
+ ),
646
+ });
647
+ };
648
+
649
+ const onCanvasComment = (worldPos?: { x: number; y: number }) => {
650
+ let textEl: HTMLTextAreaElement | null = null;
651
+ modal.open({
652
+ title: 'Add canvas comment',
653
+ width: 480,
654
+ children: (
655
+ <div>
656
+ <label className="field-label">Comment</label>
657
+ <textarea ref={(el) => { textEl = el; }} className="textarea" rows={4} placeholder="Comment for the whole artifact…" autoFocus />
658
+ </div>
659
+ ),
660
+ footer: (
661
+ <div className="modal-footer-actions">
662
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
663
+ <Button
664
+ variant="primary"
665
+ onClick={async (e) => {
666
+ e.preventDefault();
667
+ e.stopPropagation();
668
+ const text = (textEl?.value || '').trim();
669
+ if (!text) return;
670
+ await addComment(text, null, worldPos);
671
+ modal.close();
672
+ }}
673
+ >
674
+ <Send size={12} /> Post
675
+ </Button>
676
+ </div>
677
+ ),
678
+ });
679
+ };
680
+
681
+ if (loading || !canvas || !meta) {
682
+ return (
683
+ <div className="view view-artifacts view-artifacts-fullscreen">
684
+ <PlanEditorHeader
685
+ slug={slug}
686
+ meta={meta || { title: slug, status: 'draft', tags: [] }}
687
+ counts={{ elements: 0, comments: 0 }}
688
+ onBack={onBack}
689
+ onConfigure={() => undefined}
690
+ onAddElement={() => undefined}
691
+ onCanvasComment={() => undefined}
692
+ onRefresh={reload}
693
+ onDelete={onDelete}
694
+ fullscreen={fullscreen}
695
+ setFullscreen={setFullscreen}
696
+ />
697
+ <div className="view-loading" style={{ flex: 1 }}>
698
+ <Spinner size="lg" />
699
+ <p>Loading canvas…</p>
700
+ </div>
701
+ </div>
702
+ );
703
+ }
704
+
705
+ const visibleComments = selectedElId
706
+ ? canvas.comments.filter((c) => c.elementId === selectedElId)
707
+ : canvas.comments;
708
+
709
+ return (
710
+ <div className={cn('view view-artifacts view-artifacts-fullscreen', !fullscreen && 'view-artifacts-embedded')}>
711
+ {!fullscreen && (
712
+ <PlanEditorHeader
713
+ slug={slug}
714
+ meta={meta}
715
+ counts={{ elements: canvas.elements.length, comments: totalComments }}
716
+ onBack={onBack}
717
+ onConfigure={onConfigure}
718
+ onAddElement={onAddElement}
719
+ onCanvasComment={onCanvasComment}
720
+ onRefresh={reload}
721
+ onDelete={onDelete}
722
+ fullscreen={fullscreen}
723
+ setFullscreen={setFullscreen}
724
+ />
725
+ )}
726
+
727
+ <div className="artifacts-body">
728
+ {/* Canvas wrapper with floating controls */}
729
+ <div className="artifact-canvas-wrapper">
730
+ {fullscreen && (
731
+ <div className="artifact-canvas-floating-controls">
732
+ <Button variant="ghost" size="sm" onClick={onBack} title="Back to artifacts list">
733
+ <ArrowLeft size={14} /> Back
734
+ </Button>
735
+ <h3>{meta?.title || slug}</h3>
736
+ {meta && <StatusBadge kind={planStatusKind(meta.status)}>{meta.status}</StatusBadge>}
737
+ <Button variant="secondary" size="sm" onClick={() => onAddElement()}>
738
+ <Plus size={14} /> Element
739
+ </Button>
740
+ <Button variant="secondary" size="sm" onClick={() => onCanvasComment()}>
741
+ <MessageCircle size={14} /> Comment
742
+ </Button>
743
+ <Button variant="ghost" size="sm" onClick={onConfigure} title="Configure artifact" aria-label="Configure artifact">
744
+ <SettingsIcon size={14} />
745
+ </Button>
746
+ </div>
747
+ )}
748
+
749
+ <CanvasViewport
750
+ canvas={canvas}
751
+ selectedElId={selectedElId}
752
+ onSelect={setSelectedElId}
753
+ onMoveElement={moveElementLocally}
754
+ onMoveEnd={updatePositions}
755
+ onDeleteElement={deleteElement}
756
+ onConnect={(from, to) => addConnection(from, to)}
757
+ onDeleteConnection={deleteConnection}
758
+ onEditElement={(el) => editElementInline(modal, slug, el, updateElement, toast)}
759
+ onContextMenu={(e, worldPos) => {
760
+ e.preventDefault();
761
+ setElementContextMenu(null);
762
+ setContextMenuWorldPos(worldPos);
763
+ setContextMenu({
764
+ x: e.clientX,
765
+ y: e.clientY,
766
+ items: [
767
+ { label: 'Add element', icon: Plus, onClick: () => onAddElement(worldPos) },
768
+ { label: 'Add comment', icon: MessageSquare, onClick: () => onCanvasComment(worldPos) },
769
+ { type: 'separator' },
770
+ { label: 'Configure artifact', icon: SettingsIcon, onClick: onConfigure },
771
+ { label: 'Delete artifact', icon: Trash2, onClick: onDelete },
772
+ ],
773
+ });
774
+ }}
775
+ onElementContextMenu={(e, elementId, _worldPos) => {
776
+ const el = canvas?.elements.find((x) => x.id === elementId);
777
+ if (!el) return;
778
+ setContextMenu(null);
779
+ setElementContextMenu({
780
+ x: e.clientX,
781
+ y: e.clientY,
782
+ items: [
783
+ { label: 'Edit', icon: Pencil, onClick: () => { editElementInline(modal, slug, el, updateElement, toast); } },
784
+ { label: 'Duplicate', icon: Copy, onClick: () => duplicateElement(elementId) },
785
+ { type: 'separator' },
786
+ { label: 'Change type', icon: Layers, onClick: () => {
787
+ // Cycle through types: task -> note -> decision -> question -> task
788
+ const types = ['task', 'note', 'decision', 'question'];
789
+ const currentIdx = types.indexOf(el.type);
790
+ const nextType = types[(currentIdx + 1) % types.length];
791
+ changeElementType(elementId, nextType);
792
+ }},
793
+ { type: 'separator' },
794
+ { label: `Delete`, icon: Trash2, onClick: () => { if (confirm(`Delete "${el.title || el.type}"?`)) deleteElement(elementId); } },
795
+ ],
796
+ });
797
+ }}
798
+ />
799
+
800
+ {/* Canvas-level context menu */}
801
+ <CanvasContextMenu menu={contextMenu} onClose={() => setContextMenu(null)} />
802
+ {/* Element-level context menu */}
803
+ <CanvasContextMenu menu={elementContextMenu} onClose={() => setElementContextMenu(null)} />
804
+ </div>
805
+
806
+ {showComments && (
807
+ <CommentsPanel
808
+ slug={slug}
809
+ comments={visibleComments}
810
+ selectedElId={selectedElId}
811
+ onAdd={(text) => addComment(text, selectedElId)}
812
+ onDelete={deleteComment}
813
+ onClose={() => {
814
+ setShowComments(false);
815
+ setSelectedElId(null);
816
+ }}
817
+ />
818
+ )}
819
+ </div>
820
+ </div>
821
+ );
822
+ }
823
+
824
+ function PlanEditorHeader({
825
+ slug,
826
+ meta,
827
+ counts,
828
+ onBack,
829
+ onConfigure,
830
+ onAddElement,
831
+ onCanvasComment,
832
+ onRefresh,
833
+ onDelete,
834
+ fullscreen,
835
+ setFullscreen,
836
+ }: {
837
+ slug: string;
838
+ meta: { title: string; status: string; tags: string[] };
839
+ counts: { elements: number; comments: number };
840
+ onBack: () => void;
841
+ onConfigure: () => void;
842
+ onAddElement: () => void;
843
+ onCanvasComment: () => void;
844
+ onRefresh: () => void;
845
+ onDelete: () => void;
846
+ fullscreen: boolean;
847
+ setFullscreen: (v: boolean) => void;
848
+ }) {
849
+ const kind = planStatusKind(meta.status);
850
+ return (
851
+ <header className="artifacts-editor-bar">
852
+ <div className="artifacts-editor-bar-left">
853
+ <Button variant="ghost" size="sm" onClick={onBack} title="Back to artifacts list">
854
+ <ArrowLeft size={14} /> Back
855
+ </Button>
856
+ <div className="artifacts-editor-meta">
857
+ <span className="artifacts-editor-title">{meta.title || slug}</span>
858
+ <span className="artifacts-editor-slug mono">{slug}</span>
859
+ <StatusBadge kind={kind}>{meta.status}</StatusBadge>
860
+ </div>
861
+ </div>
862
+ <div className="artifacts-editor-bar-right">
863
+ <span className="muted text-sm">
864
+ {counts.elements} element{counts.elements === 1 ? '' : 's'} · {counts.comments} comment{counts.comments === 1 ? '' : 's'}
865
+ </span>
866
+ <Button variant="secondary" size="sm" onClick={onAddElement}>
867
+ <Plus size={14} /> Element
868
+ </Button>
869
+ <Button variant="secondary" size="sm" onClick={onCanvasComment}>
870
+ <MessageCircle size={14} /> Comment
871
+ </Button>
872
+ <Button variant="ghost" size="sm" onClick={onConfigure} title="Configure artifact" aria-label="Configure artifact">
873
+ <SettingsIcon size={14} />
874
+ </Button>
875
+ <Button variant="ghost" size="sm" onClick={onRefresh} title="Refresh canvas" aria-label="Refresh canvas">
876
+ <RefreshCw size={14} />
877
+ </Button>
878
+ <Button
879
+ variant="ghost"
880
+ size="sm"
881
+ onClick={() => setFullscreen(!fullscreen)}
882
+ title={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
883
+ aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
884
+ >
885
+ {fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
886
+ </Button>
887
+ <Button variant="ghost" size="sm" onClick={onDelete} title="Delete artifact" aria-label="Delete artifact">
888
+ <Trash2 size={14} />
889
+ </Button>
890
+ </div>
891
+ </header>
892
+ );
893
+ }
894
+
895
+ function editElementInline(
896
+ modal: ReturnType<typeof useModal>,
897
+ slug: string,
898
+ el: CanvasElement,
899
+ updateElement: (id: string, body: Partial<CanvasElement>) => Promise<void>,
900
+ toast: ReturnType<typeof useToast>,
901
+ ) {
902
+ let typeEl: HTMLSelectElement | null = null;
903
+ let titleEl: HTMLInputElement | null = null;
904
+ let contentEl: HTMLTextAreaElement | null = null;
905
+ let statusEl: HTMLInputElement | null = null;
906
+ modal.open({
907
+ title: `Edit ${el.title || el.type}`,
908
+ width: 520,
909
+ children: (
910
+ <div className="artifact-element-form">
911
+ <label className="field-label">Type</label>
912
+ <select ref={(el2) => { typeEl = el2; }} className="select" defaultValue={el.type}>
913
+ {ELEMENT_TYPES.map((t) => (
914
+ <option key={t.id} value={t.id}>{t.label}</option>
915
+ ))}
916
+ </select>
917
+ <label className="field-label">Title</label>
918
+ <input ref={(el2) => { titleEl = el2; }} className="input" type="text" defaultValue={el.title} />
919
+ <label className="field-label">Status</label>
920
+ <input ref={(el2) => { statusEl = el2; }} className="input" type="text" defaultValue={el.status} placeholder="open / done / blocked …" />
921
+ <label className="field-label">Content (markdown)</label>
922
+ <textarea ref={(el2) => { contentEl = el2; }} className="textarea" rows={5} defaultValue={el.content} />
923
+ </div>
924
+ ),
925
+ footer: (
926
+ <div className="modal-footer-actions">
927
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
928
+ <Button
929
+ variant="primary"
930
+ onClick={async () => {
931
+ try {
932
+ await updateElement(el.id, {
933
+ type: typeEl?.value || el.type,
934
+ title: titleEl?.value ?? el.title,
935
+ content: contentEl?.value ?? el.content,
936
+ status: statusEl?.value || el.status,
937
+ });
938
+ toast.success('Element saved.');
939
+ modal.close();
940
+ } catch (err) {
941
+ toast.error(`Save failed: ${(err as Error).message}`);
942
+ }
943
+ }}
944
+ >
945
+ Save
946
+ </Button>
947
+ </div>
948
+ ),
949
+ });
950
+ }
951
+
952
+ // ── Canvas viewport ───────────────────────────────────────────────────
953
+
954
+ type ViewportProps = {
955
+ canvas: Canvas;
956
+ selectedElId: string | null;
957
+ onSelect: (id: string | null) => void;
958
+ onMoveElement: (id: string, x: number, y: number) => void;
959
+ onMoveEnd: (positions: { id: string; x: number; y: number }[]) => void;
960
+ onDeleteElement: (id: string) => void;
961
+ onConnect: (from: string, to: string) => void;
962
+ onDeleteConnection: (id: string) => void;
963
+ onEditElement: (el: CanvasElement) => void;
964
+ onContextMenu?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
965
+ // v3.7.0 — Element-specific context menu (right-click on an element)
966
+ onElementContextMenu?: (e: React.MouseEvent, elementId: string, worldPos: { x: number; y: number }) => void;
967
+ fitToView?: () => void;
968
+ };
969
+
970
+ function CanvasViewport({
971
+ canvas,
972
+ selectedElId,
973
+ onSelect,
974
+ onMoveElement,
975
+ onMoveEnd,
976
+ onDeleteElement,
977
+ onConnect,
978
+ onDeleteConnection,
979
+ onEditElement,
980
+ onContextMenu,
981
+ onElementContextMenu,
982
+ }: ViewportProps) {
983
+ const rootRef = useRef<HTMLDivElement>(null);
984
+ const innerRef = useRef<HTMLDivElement>(null);
985
+ const elementsRef = useRef(canvas.elements);
986
+ const stateRef = useRef({
987
+ panning: false,
988
+ startX: 0,
989
+ startY: 0,
990
+ ox: canvas.viewport.x || 0,
991
+ oy: canvas.viewport.y || 0,
992
+ scale: canvas.viewport.zoom || 1,
993
+ dragging: null as null | { id: string; offsetX: number; offsetY: number; moved: boolean; origX: number; origY: number },
994
+ connecting: null as null | { fromId: string; x: number; y: number },
995
+ });
996
+
997
+ useEffect(() => {
998
+ elementsRef.current = canvas.elements;
999
+ }, [canvas.elements]);
1000
+
1001
+ useEffect(() => {
1002
+ const root = rootRef.current;
1003
+ const inner = innerRef.current;
1004
+ if (!root || !inner) return undefined;
1005
+ const apply = () => {
1006
+ const s = stateRef.current;
1007
+ inner.style.transform = `translate(${s.ox}px, ${s.oy}px) scale(${s.scale})`;
1008
+ };
1009
+ apply();
1010
+
1011
+ const screenToWorld = (clientX: number, clientY: number) => {
1012
+ const rect = inner.getBoundingClientRect();
1013
+ const s = stateRef.current;
1014
+ // rect.left/rect.top is the *post-transform* top-left; account for scale
1015
+ const x = (clientX - rect.left) / s.scale;
1016
+ const y = (clientY - rect.top) / s.scale;
1017
+ return { x, y };
1018
+ };
1019
+
1020
+ const onMouseDown = (e: MouseEvent) => {
1021
+ if (e.button === 2) return;
1022
+ const t = e.target as HTMLElement;
1023
+ if (
1024
+ t === root ||
1025
+ t.classList.contains('canvas-grid-bg') ||
1026
+ t.classList.contains('canvas-inner')
1027
+ ) {
1028
+ stateRef.current.panning = true;
1029
+ stateRef.current.startX = e.clientX - stateRef.current.ox;
1030
+ stateRef.current.startY = e.clientY - stateRef.current.oy;
1031
+ root.style.cursor = 'grabbing';
1032
+ return;
1033
+ }
1034
+ const elCard = t.closest('.canvas-element') as HTMLElement | null;
1035
+ if (elCard) {
1036
+ const id = elCard.dataset.elementId;
1037
+ if (!id) return;
1038
+ const headerHit = !!t.closest('.canvas-element-head');
1039
+ const handleHit = !!t.closest('.canvas-element-handle');
1040
+ if (headerHit || handleHit) {
1041
+ const world = screenToWorld(e.clientX, e.clientY);
1042
+ const el = elementsRef.current.find((x) => x.id === id);
1043
+ if (!el) return;
1044
+ stateRef.current.dragging = {
1045
+ id,
1046
+ offsetX: world.x - (el.x || 0),
1047
+ offsetY: world.y - (el.y || 0),
1048
+ moved: false,
1049
+ origX: el.x || 0,
1050
+ origY: el.y || 0,
1051
+ };
1052
+ onSelect(id);
1053
+ e.preventDefault();
1054
+ }
1055
+ }
1056
+ };
1057
+ const onMouseMove = (e: MouseEvent) => {
1058
+ const s = stateRef.current;
1059
+ if (s.panning) {
1060
+ s.ox = e.clientX - s.startX;
1061
+ s.oy = e.clientY - s.startY;
1062
+ apply();
1063
+ return;
1064
+ }
1065
+ if (s.dragging) {
1066
+ const world = screenToWorld(e.clientX, e.clientY);
1067
+ const newX = Math.max(0, world.x - s.dragging.offsetX);
1068
+ const newY = Math.max(0, world.y - s.dragging.offsetY);
1069
+ if (!s.dragging.moved) {
1070
+ const dx = Math.abs(newX - s.dragging.origX);
1071
+ const dy = Math.abs(newY - s.dragging.origY);
1072
+ if (dx < 2 && dy < 2) return;
1073
+ }
1074
+ s.dragging.moved = true;
1075
+ onMoveElement(s.dragging.id, newX, newY);
1076
+ return;
1077
+ }
1078
+ if (s.connecting) {
1079
+ const inner2 = innerRef.current;
1080
+ if (!inner2) return;
1081
+ const world = screenToWorld(e.clientX, e.clientY);
1082
+ s.connecting.x = world.x;
1083
+ s.connecting.y = world.y;
1084
+ // Trigger a custom event so the connecting SVG re-renders.
1085
+ inner2.dispatchEvent(new CustomEvent('canvas-move', { detail: { x: world.x, y: world.y } }));
1086
+ }
1087
+ };
1088
+ const onMouseUp = () => {
1089
+ const s = stateRef.current;
1090
+ if (s.panning) {
1091
+ s.panning = false;
1092
+ root.style.cursor = '';
1093
+ }
1094
+ if (s.dragging) {
1095
+ if (s.dragging.moved) {
1096
+ const el = elementsRef.current.find((x) => x.id === s.dragging?.id);
1097
+ if (el) {
1098
+ onMoveEnd([{ id: el.id, x: el.x || 0, y: el.y || 0 }]);
1099
+ }
1100
+ }
1101
+ s.dragging = null;
1102
+ }
1103
+ if (s.connecting) {
1104
+ s.connecting = null;
1105
+ innerRef.current?.dispatchEvent(new CustomEvent('canvas-move', { detail: { x: 0, y: 0 } }));
1106
+ }
1107
+ };
1108
+ const onWheel = (e: WheelEvent) => {
1109
+ e.preventDefault();
1110
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
1111
+ const next = Math.min(
1112
+ Math.max(stateRef.current.scale * delta, 0.25),
1113
+ 2.5,
1114
+ );
1115
+ stateRef.current.scale = next;
1116
+ apply();
1117
+ };
1118
+ root.addEventListener('mousedown', onMouseDown);
1119
+ window.addEventListener('mousemove', onMouseMove);
1120
+ window.addEventListener('mouseup', onMouseUp);
1121
+ root.addEventListener('wheel', onWheel, { passive: false });
1122
+ return () => {
1123
+ root.removeEventListener('mousedown', onMouseDown);
1124
+ window.removeEventListener('mousemove', onMouseMove);
1125
+ window.removeEventListener('mouseup', onMouseUp);
1126
+ root.removeEventListener('wheel', onWheel);
1127
+ };
1128
+ }, [onMoveElement, onMoveEnd, onSelect]);
1129
+
1130
+ const fitToView = () => {
1131
+ if (!rootRef.current || canvas.elements.length === 0) {
1132
+ stateRef.current.ox = 0;
1133
+ stateRef.current.oy = 0;
1134
+ stateRef.current.scale = 1;
1135
+ const inner = innerRef.current;
1136
+ if (inner) {
1137
+ inner.style.transform = `translate(0px, 0px) scale(1)`;
1138
+ }
1139
+ return;
1140
+ }
1141
+ const bbox = canvas.elements.reduce(
1142
+ (acc, el) => {
1143
+ const ex = el.x || 0;
1144
+ const ey = el.y || 0;
1145
+ const ex2 = ex + (el.width || 240);
1146
+ const ey2 = ey + (el.height || 120);
1147
+ return {
1148
+ minX: Math.min(acc.minX, ex),
1149
+ minY: Math.min(acc.minY, ey),
1150
+ maxX: Math.max(acc.maxX, ex2),
1151
+ maxY: Math.max(acc.maxY, ey2),
1152
+ };
1153
+ },
1154
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
1155
+ );
1156
+ const pad = 80;
1157
+ const root = rootRef.current;
1158
+ const w = root.clientWidth - pad * 2;
1159
+ const h = root.clientHeight - pad * 2;
1160
+ const bw = bbox.maxX - bbox.minX;
1161
+ const bh = bbox.maxY - bbox.minY;
1162
+ const scale = Math.min(w / Math.max(bw, 1), h / Math.max(bh, 1), 1.5);
1163
+ const ox = -bbox.minX * scale + pad;
1164
+ const oy = -bbox.minY * scale + pad;
1165
+ stateRef.current.ox = ox;
1166
+ stateRef.current.oy = oy;
1167
+ stateRef.current.scale = scale;
1168
+ const inner = innerRef.current;
1169
+ if (inner) {
1170
+ inner.style.transform = `translate(${ox}px, ${oy}px) scale(${scale})`;
1171
+ }
1172
+ };
1173
+
1174
+ return (
1175
+ <div className="artifacts-canvas-wrap">
1176
+ <div className="canvas-toolbar">
1177
+ <button type="button" className="icon-btn" title="Fit to view" aria-label="Fit canvas to view" onClick={fitToView}>
1178
+ <Maximize2 size={14} />
1179
+ </button>
1180
+ <button
1181
+ type="button"
1182
+ className="icon-btn"
1183
+ title="Reset zoom"
1184
+ aria-label="Reset canvas zoom"
1185
+ onClick={() => {
1186
+ stateRef.current.ox = 0;
1187
+ stateRef.current.oy = 0;
1188
+ stateRef.current.scale = 1;
1189
+ const inner = innerRef.current;
1190
+ if (inner) inner.style.transform = 'translate(0px, 0px) scale(1)';
1191
+ }}
1192
+ >
1193
+ <Circle size={14} />
1194
+ </button>
1195
+ <span className="muted text-sm" style={{ marginLeft: 'auto' }}>
1196
+ Pan: drag empty · Zoom: scroll · Drag element header to move · Double-click to edit · Right-click element for actions
1197
+ </span>
1198
+ </div>
1199
+ <div className="canvas-root" ref={rootRef} onContextMenu={(e) => {
1200
+ e.preventDefault();
1201
+ const rect = innerRef.current?.getBoundingClientRect();
1202
+ const s = stateRef.current;
1203
+ if (!rect) return;
1204
+ const worldPos = { x: (e.clientX - rect.left) / s.scale, y: (e.clientY - rect.top) / s.scale };
1205
+ onContextMenu?.(e, worldPos);
1206
+ }}>
1207
+ <div className="canvas-grid-bg" />
1208
+ <div className="canvas-inner" ref={innerRef}>
1209
+ {canvas.elements.length === 0 ? (
1210
+ <div className="canvas-empty">
1211
+ <EmptyState
1212
+ icon={<MapIcon size={32} />}
1213
+ title="No elements yet"
1214
+ message="Add an element from the toolbar above to get started."
1215
+ />
1216
+ </div>
1217
+ ) : (
1218
+ <>
1219
+ <ConnectionsLayer
1220
+ canvas={canvas}
1221
+ onDeleteConnection={onDeleteConnection}
1222
+ />
1223
+ {canvas.elements.map((el) => (
1224
+ <CanvasElementView
1225
+ key={el.id}
1226
+ element={el}
1227
+ selected={selectedElId === el.id}
1228
+ commentCount={canvas.comments.filter((c) => c.elementId === el.id).length}
1229
+ onSelect={() => onSelect(el.id)}
1230
+ onEdit={() => onEditElement(el)}
1231
+ onDelete={() => onDeleteElement(el.id)}
1232
+ onContextMenu={onElementContextMenu ? (e) => {
1233
+ e.preventDefault();
1234
+ e.stopPropagation();
1235
+ const rect = innerRef.current?.getBoundingClientRect();
1236
+ if (!rect) return;
1237
+ const s = stateRef.current;
1238
+ const worldPos = { x: (e.clientX - rect.left) / s.scale, y: (e.clientY - rect.top) / s.scale };
1239
+ onElementContextMenu(e, el.id, worldPos);
1240
+ } : undefined}
1241
+ />
1242
+ ))}
1243
+ </>
1244
+ )}
1245
+ </div>
1246
+ </div>
1247
+ </div>
1248
+ );
1249
+ }
1250
+
1251
+ function CanvasElementView({
1252
+ element,
1253
+ selected,
1254
+ commentCount,
1255
+ onSelect,
1256
+ onEdit,
1257
+ onDelete,
1258
+ onContextMenu,
1259
+ }: {
1260
+ element: CanvasElement;
1261
+ selected: boolean;
1262
+ commentCount: number;
1263
+ onSelect: () => void;
1264
+ onEdit: () => void;
1265
+ onDelete: () => void;
1266
+ onContextMenu?: (e: React.MouseEvent) => void;
1267
+ }) {
1268
+ const x = element.x || 0;
1269
+ const y = element.y || 0;
1270
+ const w = element.width || 240;
1271
+ const h = element.height || 120;
1272
+ const color = elementColor(element.type);
1273
+ return (
1274
+ <div
1275
+ className={cn('canvas-element', selected && 'canvas-element-selected')}
1276
+ data-element-id={element.id}
1277
+ style={{
1278
+ left: x,
1279
+ top: y,
1280
+ width: w,
1281
+ minHeight: h,
1282
+ borderColor: color,
1283
+ }}
1284
+ onClick={(e) => {
1285
+ e.stopPropagation();
1286
+ onSelect();
1287
+ }}
1288
+ onDoubleClick={(e) => {
1289
+ e.stopPropagation();
1290
+ onEdit();
1291
+ }}
1292
+ onContextMenu={(e) => {
1293
+ if (onContextMenu) {
1294
+ e.stopPropagation();
1295
+ onContextMenu(e);
1296
+ } else {
1297
+ e.preventDefault();
1298
+ if (confirm(`Delete element "${element.title || element.type}"?`)) {
1299
+ onDelete();
1300
+ }
1301
+ }
1302
+ }}
1303
+ >
1304
+ <div className="canvas-element-head" style={{ background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
1305
+ <div className="canvas-element-type">
1306
+ <ElementIcon type={element.type} size={12} />
1307
+ <span style={{ color }}>{element.type}</span>
1308
+ </div>
1309
+ <div className="canvas-element-actions">
1310
+ {commentCount > 0 && (
1311
+ <span className="canvas-element-badge" title={`${commentCount} comments`}>
1312
+ <MessageCircle size={10} />
1313
+ {commentCount}
1314
+ </span>
1315
+ )}
1316
+ <button type="button" className="canvas-element-handle" title="Drag to move" aria-label="Drag">
1317
+ ⋮⋮
1318
+ </button>
1319
+ </div>
1320
+ </div>
1321
+ <div className="canvas-element-title">{element.title || 'Untitled'}</div>
1322
+ {element.content && (
1323
+ <div className="canvas-element-content">{truncate(element.content, 240)}</div>
1324
+ )}
1325
+ {element.status && element.status !== 'open' && (
1326
+ <div className="canvas-element-status">
1327
+ <StatusBadge kind={element.status === 'done' ? 'success' : element.status === 'blocked' ? 'error' : 'info'}>
1328
+ {element.status}
1329
+ </StatusBadge>
1330
+ </div>
1331
+ )}
1332
+ </div>
1333
+ );
1334
+ }
1335
+
1336
+ function ConnectionsLayer({
1337
+ canvas,
1338
+ onDeleteConnection,
1339
+ }: {
1340
+ canvas: Canvas;
1341
+ onDeleteConnection: (id: string) => void;
1342
+ }) {
1343
+ if (canvas.connections.length === 0) return null;
1344
+ const els = canvas.elements;
1345
+ // Compute bounding box
1346
+ const bbox = els.reduce(
1347
+ (acc, el) => {
1348
+ const ex = el.x || 0;
1349
+ const ey = el.y || 0;
1350
+ const ex2 = ex + (el.width || 240);
1351
+ const ey2 = ey + (el.height || 120);
1352
+ return {
1353
+ minX: Math.min(acc.minX, ex),
1354
+ minY: Math.min(acc.minY, ey),
1355
+ maxX: Math.max(acc.maxX, ex2),
1356
+ maxY: Math.max(acc.maxY, ey2),
1357
+ };
1358
+ },
1359
+ { minX: 0, minY: 0, maxX: 0, maxY: 0 },
1360
+ );
1361
+ const pad = 80;
1362
+ const w = bbox.maxX - bbox.minX + pad * 2;
1363
+ const h = bbox.maxY - bbox.minY + pad * 2;
1364
+ const ox = -bbox.minX + pad;
1365
+ const oy = -bbox.minY + pad;
1366
+ return (
1367
+ <svg
1368
+ className="canvas-connections"
1369
+ width={w}
1370
+ height={h}
1371
+ style={{ left: bbox.minX - pad, top: bbox.minY - pad }}
1372
+ >
1373
+ <defs>
1374
+ <marker id="artifact-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
1375
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--accent)" />
1376
+ </marker>
1377
+ </defs>
1378
+ {canvas.connections.map((c) => {
1379
+ const from = els.find((e) => e.id === c.from || e.id === c.fromElementId);
1380
+ const to = els.find((e) => e.id === c.to || e.id === c.toElementId);
1381
+ if (!from || !to) return null;
1382
+ const x1 = ox + (from.x || 0) + (from.width || 240) / 2;
1383
+ const y1 = oy + (from.y || 0) + (from.height || 120);
1384
+ const x2 = ox + (to.x || 0) + (to.width || 240) / 2;
1385
+ const y2 = oy + (to.y || 0);
1386
+ const midX = (x1 + x2) / 2;
1387
+ const midY = (y1 + y2) / 2;
1388
+ return (
1389
+ <g key={c.id} className="canvas-connection" onClick={() => onDeleteConnection(c.id)}>
1390
+ <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--accent)" strokeWidth={1.5} markerEnd="url(#artifact-arrow)" />
1391
+ {c.label && (
1392
+ <text x={midX} y={midY} className="canvas-connection-label" textAnchor="middle">
1393
+ {c.label}
1394
+ </text>
1395
+ )}
1396
+ </g>
1397
+ );
1398
+ })}
1399
+ </svg>
1400
+ );
1401
+ }
1402
+
1403
+ // ── Comments panel ────────────────────────────────────────────────────
1404
+
1405
+ function CommentsPanel({
1406
+ slug,
1407
+ comments,
1408
+ selectedElId,
1409
+ onAdd,
1410
+ onDelete,
1411
+ onClose,
1412
+ }: {
1413
+ slug: string;
1414
+ comments: CanvasComment[];
1415
+ selectedElId: string | null;
1416
+ onAdd: (text: string) => void;
1417
+ onDelete: (cid: string) => void;
1418
+ onClose: () => void;
1419
+ }) {
1420
+ const [text, setText] = useState('');
1421
+ return (
1422
+ <aside className="artifacts-comments-panel">
1423
+ <header className="artifacts-comments-panel-head">
1424
+ <h3 className="card-title">
1425
+ <MessageCircle size={14} />
1426
+ {selectedElId ? 'Element comments' : 'Canvas comments'}
1427
+ </h3>
1428
+ <button type="button" className="icon-btn" onClick={onClose} title="Close panel" aria-label="Close">
1429
+ <X size={14} />
1430
+ </button>
1431
+ </header>
1432
+ <div className="artifacts-comments-panel-list">
1433
+ {comments.length === 0 ? (
1434
+ <p className="muted text-sm" style={{ padding: 'var(--space-4)' }}>
1435
+ {selectedElId ? 'No comments on this element yet.' : 'No comments on this artifact yet.'}
1436
+ </p>
1437
+ ) : (
1438
+ comments.map((c) => (
1439
+ <div key={c.id} className="artifact-comment">
1440
+ <div className="artifact-comment-head">
1441
+ <span className="artifact-comment-author mono">{c.author}</span>
1442
+ <span className="artifact-comment-time tabular-nums muted">{formatRelative(c.created)}</span>
1443
+ <button
1444
+ type="button"
1445
+ className="icon-btn icon-btn-danger"
1446
+ aria-label="Delete comment"
1447
+ onClick={() => onDelete(c.id)}
1448
+ title="Delete"
1449
+ >
1450
+ <Trash2 size={10} />
1451
+ </button>
1452
+ </div>
1453
+ <div className="artifact-comment-text">{c.text}</div>
1454
+ </div>
1455
+ ))
1456
+ )}
1457
+ </div>
1458
+ <form
1459
+ className="artifacts-comments-panel-form"
1460
+ onSubmit={(e) => {
1461
+ e.preventDefault();
1462
+ if (!text.trim()) return;
1463
+ onAdd(text.trim());
1464
+ setText('');
1465
+ }}
1466
+ >
1467
+ <textarea
1468
+ className="textarea"
1469
+ rows={2}
1470
+ placeholder={selectedElId ? 'Add a comment to this element…' : 'Add a canvas comment…'}
1471
+ value={text}
1472
+ onChange={(e) => setText(e.target.value)}
1473
+ />
1474
+ <Button variant="primary" size="sm" type="submit" disabled={!text.trim()}>
1475
+ <Send size={12} /> Post
1476
+ </Button>
1477
+ </form>
1478
+ </aside>
1479
+ );
1480
+ }