code-coordinator 0.5.46__py3-none-any.whl

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 (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,658 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>coord — dashboard</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
10
+ .container { max-width: 900px; margin: 0 auto; padding: 12px; }
11
+ h1 { font-size: 1.2em; color: #58a6ff; margin-bottom: 12px; }
12
+ h2 { font-size: 1em; color: #8b949e; margin: 16px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
13
+
14
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 12px; margin-bottom: 8px; }
15
+ .card-header { font-weight: 600; color: #e6edf3; }
16
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; font-weight: 600; }
17
+ .badge-online { background: #238636; color: #fff; }
18
+ .badge-offline { background: #da3633; color: #fff; }
19
+ .badge-busy { background: #d29922; color: #000; }
20
+ .badge-done { background: #238636; color: #fff; }
21
+ .badge-failed { background: #da3633; color: #fff; }
22
+ .badge-running { background: #1f6feb; color: #fff; }
23
+ .meta { color: #8b949e; font-size: 0.85em; }
24
+ a { color: #58a6ff; }
25
+
26
+ .tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
27
+ .tab { padding: 8px 16px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; cursor: pointer; color: #c9d1d9; font-size: 0.9em; min-height: 44px; }
28
+ .tab.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
29
+
30
+ .pane { display: none; }
31
+ .pane.active { display: block; }
32
+
33
+ .chat-container { display: flex; flex-direction: column; height: 50vh; }
34
+ .chat-messages { flex: 1; overflow-y: auto; padding: 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 8px; }
35
+ .chat-msg { margin-bottom: 8px; padding: 8px; border-radius: 6px; white-space: pre-wrap; word-break: break-word; }
36
+ .chat-msg.user { background: #1f6feb; color: #fff; margin-left: 20%; }
37
+ .chat-msg.assistant { background: #161b22; margin-right: 20%; }
38
+ .chat-input { display: flex; gap: 8px; }
39
+ .chat-input input { flex: 1; padding: 10px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 16px; min-height: 44px; }
40
+ .chat-input button { padding: 10px 20px; background: #238636; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-size: 16px; min-height: 44px; }
41
+
42
+ .proposal { border-left: 3px solid #1f6feb; padding-left: 10px; margin-bottom: 12px; padding-bottom: 8px; }
43
+ .btn { padding: 8px 16px; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-size: 14px; min-height: 44px; margin-right: 6px; margin-top: 6px; }
44
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
45
+ .btn-approve { background: #238636; }
46
+ .btn-reject { background: #da3633; }
47
+ .btn-all { background: #1f6feb; }
48
+ .btn-small { padding: 4px 10px; min-height: 32px; font-size: 0.8em; }
49
+
50
+ .briefing-edit { width: 100%; min-height: 60px; margin-top: 6px; padding: 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-family: monospace; font-size: 13px; resize: vertical; }
51
+
52
+ .diff-toggle { cursor: pointer; color: #58a6ff; font-size: 0.85em; margin-top: 4px; }
53
+ .diff-content { display: none; margin-top: 6px; padding: 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; font-family: monospace; font-size: 12px; white-space: pre-wrap; overflow-x: auto; max-height: 400px; overflow-y: auto; }
54
+ .diff-content.open { display: block; }
55
+
56
+ .refresh-btn { float: right; padding: 4px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #8b949e; cursor: pointer; font-size: 0.85em; }
57
+
58
+ .pipeline-bar { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; align-items: center; }
59
+ .pipeline-pill { padding: 2px 8px; border-radius: 10px; font-size: 0.75em; font-weight: 600; }
60
+ .pill-completed { background: #238636; color: #fff; }
61
+ .pill-active { background: #1f6feb; color: #fff; }
62
+ .pill-waiting { background: #21262d; color: #8b949e; border: 1px solid #30363d; }
63
+ .pill-skipped { background: transparent; color: #484f58; font-size: 0.7em; text-decoration: line-through; }
64
+ .pipeline-arrow { color: #484f58; font-size: 0.8em; padding: 0 1px; }
65
+ .pipeline-progress { height: 3px; background: #30363d; border-radius: 2px; margin-top: 4px; overflow: hidden; }
66
+ .pipeline-progress-fill { height: 100%; background: #1f6feb; border-radius: 2px; transition: width 0.3s; }
67
+ .gate-row { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
68
+ .btn-gate { padding: 4px 10px; min-height: 28px; font-size: 0.78em; border-radius: 4px; border: none; color: #fff; cursor: pointer; background: #1f6feb; }
69
+ .btn-gate:hover { opacity: 0.85; }
70
+ .btn-gate:disabled { opacity: 0.45; cursor: not-allowed; }
71
+ .btn-gate-danger { background: #da3633; }
72
+ .btn-gate-green { background: #238636; }
73
+ .btn-gate-warn { background: #d29922; color: #000; }
74
+ .pill-warn { background: #d29922; color: #000; }
75
+
76
+ .dispatch-status { margin-top: 6px; padding: 4px 8px; border-radius: 4px; font-size: 0.82em; font-family: monospace; word-break: break-word; }
77
+ .dispatch-pending { background: #161b22; color: #8b949e; border: 1px solid #30363d; }
78
+ .dispatch-ok { background: #0d2b16; color: #3fb950; border: 1px solid #238636; }
79
+ .dispatch-err { background: #2d0e0e; color: #ff7b72; border: 1px solid #da3633; }
80
+ .dispatch-err-toggle { cursor: pointer; color: #58a6ff; font-size: 0.85em; margin-left: 8px; font-style: italic; }
81
+ .dispatch-err-detail { margin-top: 6px; padding: 6px; background: #0d1117; border: 1px solid #30363d; border-radius: 4px; white-space: pre-wrap; word-break: break-all; font-size: 0.9em; max-height: 150px; overflow-y: auto; display: none; }
82
+ .dispatch-err-detail.open { display: block; }
83
+
84
+ /* ── Toast notifications ─────────────────────────────────────────────── */
85
+ #toast-container { position: fixed; top: 16px; right: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; max-width: 440px; pointer-events: none; }
86
+ .toast { padding: 12px 16px; border-radius: 8px; font-size: 0.88em; font-weight: 500; cursor: pointer; pointer-events: all; border-left: 4px solid; line-height: 1.4; box-shadow: 0 4px 12px rgba(0,0,0,0.4); animation: toast-slide-in 0.2s ease; }
87
+ .toast-done { background: #0d2b16; color: #3fb950; border-color: #238636; }
88
+ .toast-failed { background: #2d0e0e; color: #ff7b72; border-color: #da3633; }
89
+ .toast-stuck { background: #2b1a00; color: #e3b341; border-color: #d29922; }
90
+ .toast-dismiss { float: right; opacity: 0.6; margin-left: 12px; font-size: 1.1em; line-height: 1; }
91
+ .toast-dismiss:hover { opacity: 1; }
92
+ @keyframes toast-slide-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
93
+ .toast.toast-fade-out { animation: toast-fade-out 0.4s ease forwards; }
94
+ @keyframes toast-fade-out { to { opacity: 0; transform: translateX(20px); } }
95
+
96
+ /* ── Possibly-stuck warning on active cards ──────────────────────────── */
97
+ .stuck-banner { margin-top: 8px; padding: 8px 10px; background: #2b1a00; border: 1px solid #d29922; border-radius: 6px; color: #e3b341; font-size: 0.82em; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
98
+ .stuck-banner .stuck-msg { flex: 1; }
99
+ .btn-unstick { padding: 3px 10px; min-height: 26px; font-size: 0.78em; border-radius: 4px; border: none; color: #000; background: #d29922; cursor: pointer; }
100
+ .btn-unstick:hover { background: #e3b341; }
101
+
102
+ /* ── SSE connection status indicator ─────────────────────────────────── */
103
+ /* Bug 6: audio bell toggle button */
104
+ .bell-btn { background: none; border: none; cursor: pointer; font-size: 0.9em; padding: 0 2px; vertical-align: middle; opacity: 0.7; line-height: 1; }
105
+ .bell-btn:hover { opacity: 1; }
106
+ /* Bug 4 fix: `title` is an HTML attribute, not a CSS property — removed. */
107
+ .sse-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #484f58; margin-right: 4px; vertical-align: middle; }
108
+ .sse-dot.connected { background: #238636; }
109
+
110
+ @media (max-width: 600px) {
111
+ .chat-msg.user { margin-left: 10%; }
112
+ .chat-msg.assistant { margin-right: 10%; }
113
+ }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <div id="toast-container"></div>
118
+ <div class="container">
119
+ <h1>coord dashboard <span class="meta" id="round-info"></span> <span class="sse-dot" id="sse-dot" title="Live events disconnected"></span><button class="bell-btn" id="bell-btn" onclick="toggleBell()" title="Toggle audio notifications on failure"></button></h1>
120
+
121
+ <div class="tabs">
122
+ <div class="tab active" data-pane="board">Board</div>
123
+ <div class="tab" data-pane="machines">Machines</div>
124
+ <div class="tab" data-pane="proposals">Proposals</div>
125
+ <div class="tab" data-pane="chat">Chat</div>
126
+ </div>
127
+
128
+ <div class="pane active" id="pane-board">
129
+ <button class="refresh-btn" onclick="loadBoard()">refresh</button>
130
+ <h2>Active</h2>
131
+ <div id="active-list"></div>
132
+ <h2>Completed (recent)</h2>
133
+ <div id="completed-list"></div>
134
+ </div>
135
+
136
+ <div class="pane" id="pane-machines">
137
+ <button class="refresh-btn" onclick="loadMachines()">refresh</button>
138
+ <div id="machines-list"></div>
139
+ </div>
140
+
141
+ <div class="pane" id="pane-proposals">
142
+ <button class="refresh-btn" onclick="loadProposals()">refresh</button>
143
+ <div id="proposals-list"></div>
144
+ </div>
145
+
146
+ <div class="pane" id="pane-chat">
147
+ <div class="chat-container">
148
+ <div class="chat-messages" id="chat-messages"></div>
149
+ <div class="chat-input">
150
+ <input type="text" id="chat-input" placeholder="Ask about the board..." autocomplete="off">
151
+ <button onclick="sendChat()">Send</button>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <script>
158
+ const E = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
159
+
160
+ document.querySelectorAll('.tab').forEach(tab => {
161
+ tab.addEventListener('click', () => {
162
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
163
+ document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
164
+ tab.classList.add('active');
165
+ document.getElementById('pane-' + tab.dataset.pane).classList.add('active');
166
+ });
167
+ });
168
+
169
+ function badge(cls, text) {
170
+ return `<span class="badge badge-${E(cls)}">${E(text)}</span>`;
171
+ }
172
+
173
+ // ── Pipeline ───────────────────────────────────────────────────────────
174
+ let pipelineCache = {}; // assignment_id → PipelineView
175
+ const dispatchStatus = {}; // assignment_id → {phase:'pending'|'ok'|'err', msg, detail?}
176
+
177
+ function renderDispatchStatus(aid) {
178
+ const ds = dispatchStatus[aid];
179
+ if (!ds) return '';
180
+ const clsMap = {pending: 'dispatch-pending', ok: 'dispatch-ok', err: 'dispatch-err'};
181
+ const cls = clsMap[ds.phase] || '';
182
+ let html = `<div class="dispatch-status ${cls}">${E(ds.msg)}`;
183
+ if (ds.detail) {
184
+ const detailId = 'dsdetail-' + aid;
185
+ html += `<span class="dispatch-err-toggle" onclick="toggleDispatchDetail(${JSON.stringify(aid)})">show details</span><div class="dispatch-err-detail" id="${detailId}">${E(ds.detail)}</div>`;
186
+ }
187
+ html += '</div>';
188
+ return html;
189
+ }
190
+
191
+ function toggleDispatchDetail(aid) {
192
+ const el = document.getElementById('dsdetail-' + aid);
193
+ if (el) el.classList.toggle('open');
194
+ }
195
+
196
+ function updateCardPipeline(aid, issueNum) {
197
+ const area = document.querySelector('#card-' + aid + ' .pipeline-area');
198
+ if (!area) return;
199
+ const pv = pipelineCache[aid];
200
+ area.innerHTML = pv ? renderPipelineBar(pv, issueNum) : renderDispatchStatus(aid);
201
+ }
202
+
203
+ async function loadPipeline() {
204
+ try {
205
+ const r = await fetch('/api/pipeline');
206
+ if (r.ok) {
207
+ const data = await r.json();
208
+ pipelineCache = {};
209
+ for (const pv of data) pipelineCache[pv.assignment_id] = pv;
210
+ }
211
+ } catch(e) { /* fail silently — board still renders without pipeline data */ }
212
+ }
213
+
214
+ function renderPipelineBar(pv, issueNum) {
215
+ if (!pv) return '';
216
+ const pills = pv.stages.map(s => {
217
+ let cls = 'pill-waiting';
218
+ // If review findings are pending, show the review pill with a warning colour.
219
+ if (s.name === 'review' && pv.review_findings_pending) cls = 'pill-warn';
220
+ else if (s.status === 'completed') cls = 'pill-completed';
221
+ else if (s.status === 'active') cls = 'pill-active';
222
+ else if (s.status === 'skipped') cls = 'pill-skipped';
223
+ const label = (s.name === 'review' && pv.review_findings_pending)
224
+ ? `⚠ ${E(s.name)}` : E(s.name);
225
+ return `<span class="pipeline-pill ${cls}" title="${pv.review_findings_pending && s.name === 'review' ? 'Review findings not posted to GitHub' : ''}">${label}</span>`;
226
+ }).join('<span class="pipeline-arrow">›</span>');
227
+
228
+ const isPending = dispatchStatus[pv.assignment_id]?.phase === 'pending';
229
+ const gates = pv.available_gates.map(g => {
230
+ let extra = '';
231
+ if (g.action === 'retry' || g.action === 'dispatch_fix') extra = 'btn-gate-danger';
232
+ else if (g.action === 'merge') extra = 'btn-gate-green';
233
+ else if (g.action === 'post_findings') extra = 'btn-gate-warn';
234
+ const dis = isPending ? ' disabled' : '';
235
+ return `<button class="btn-gate ${extra}"${dis} onclick="doPipelineAction(${JSON.stringify(pv.assignment_id)},${JSON.stringify(g.action)},${JSON.stringify(issueNum || null)})">${E(g.label)}</button>`;
236
+ }).join('');
237
+
238
+ const pct = Math.max(0, Math.min(100, pv.progress_pct || 0));
239
+ const progress = `<div class="pipeline-progress"><div class="pipeline-progress-fill" style="width:${pct}%"></div></div>`;
240
+
241
+ return `<div class="pipeline-bar">${pills}</div>${gates ? `<div class="gate-row">${gates}</div>` : ''}${renderDispatchStatus(pv.assignment_id)}${progress}`;
242
+ }
243
+
244
+ async function doPipelineAction(assignmentId, action, issueNum) {
245
+ // Show "Dispatching…" immediately — persistent, not a toast.
246
+ dispatchStatus[assignmentId] = {
247
+ phase: 'pending',
248
+ msg: issueNum ? `Dispatching #${issueNum}…` : 'Dispatching…',
249
+ };
250
+ updateCardPipeline(assignmentId, issueNum);
251
+
252
+ try {
253
+ const r = await fetch('/api/pipeline/action', {
254
+ method: 'POST',
255
+ headers: {'Content-Type': 'application/json'},
256
+ body: JSON.stringify({assignment_id: assignmentId, action}),
257
+ });
258
+ const data = await r.json();
259
+ if (!r.ok || data.ok === false) {
260
+ const errText = data.error || JSON.stringify(data, null, 2);
261
+ const summary = errText.length > 120 ? errText.slice(0, 120) + '…' : errText;
262
+ dispatchStatus[assignmentId] = {
263
+ phase: 'err',
264
+ msg: `✗ Dispatch failed: ${summary}`,
265
+ detail: errText,
266
+ };
267
+ } else {
268
+ let msg = '✓ Dispatched';
269
+ if (issueNum) msg += ` #${issueNum}`;
270
+ if (data.machine_name) msg += ` → ${data.machine_name}`;
271
+ if (data.assignment_id) msg += ` (assignment ${String(data.assignment_id).slice(0, 8)})`;
272
+ if (data.events && data.events.length) {
273
+ const last = data.events[data.events.length - 1];
274
+ if (last.message) msg += ` — ${last.message}`;
275
+ }
276
+ dispatchStatus[assignmentId] = {phase: 'ok', msg};
277
+ }
278
+ } catch(e) {
279
+ dispatchStatus[assignmentId] = {phase: 'err', msg: `✗ Dispatch failed: ${e.message}`};
280
+ }
281
+
282
+ await loadBoard();
283
+ }
284
+
285
+ // ── Toast notifications ────────────────────────────────────────────────
286
+ function showToast(message, kind, autoDismissMs) {
287
+ if (autoDismissMs === undefined) autoDismissMs = kind === 'failed' ? 0 : 8000;
288
+ const container = document.getElementById('toast-container');
289
+ const toast = document.createElement('div');
290
+ toast.className = `toast toast-${kind}`;
291
+ const dismiss = document.createElement('span');
292
+ dismiss.className = 'toast-dismiss';
293
+ dismiss.textContent = '×';
294
+ dismiss.onclick = e => { e.stopPropagation(); _dismissToast(toast); };
295
+ const text = document.createElement('span');
296
+ text.textContent = message;
297
+ toast.appendChild(dismiss);
298
+ toast.appendChild(text);
299
+ toast.addEventListener('click', () => _dismissToast(toast));
300
+ container.appendChild(toast);
301
+ if (autoDismissMs > 0) {
302
+ setTimeout(() => _dismissToast(toast), autoDismissMs);
303
+ }
304
+ }
305
+
306
+ function _dismissToast(toast) {
307
+ if (!toast.parentNode) return;
308
+ toast.classList.add('toast-fade-out');
309
+ setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 400);
310
+ }
311
+
312
+ // ── Audio bell ─────────────────────────────────────────────────────────
313
+ // Bug 6: persist bell preference across page loads via localStorage.
314
+ let bellEnabled = localStorage.getItem('bellEnabled') !== 'false';
315
+ function _updateBellBtn() {
316
+ const btn = document.getElementById('bell-btn');
317
+ if (btn) btn.textContent = bellEnabled ? '🔔' : '🔕';
318
+ }
319
+ function toggleBell() {
320
+ bellEnabled = !bellEnabled;
321
+ localStorage.setItem('bellEnabled', String(bellEnabled));
322
+ _updateBellBtn();
323
+ }
324
+ _updateBellBtn();
325
+ function playBell(freq, duration) {
326
+ if (!bellEnabled) return;
327
+ freq = freq || 440;
328
+ duration = duration || 0.35;
329
+ try {
330
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
331
+ const osc = ctx.createOscillator();
332
+ const gain = ctx.createGain();
333
+ osc.connect(gain);
334
+ gain.connect(ctx.destination);
335
+ osc.type = 'sine';
336
+ osc.frequency.setValueAtTime(freq, ctx.currentTime);
337
+ gain.gain.setValueAtTime(0.25, ctx.currentTime);
338
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
339
+ osc.start(ctx.currentTime);
340
+ osc.stop(ctx.currentTime + duration);
341
+ ctx.close();
342
+ } catch(e) {}
343
+ }
344
+
345
+ // ── Possibly-stuck tracking ────────────────────────────────────────────
346
+ // Maps assignment_id → { repo_name, issue_number, machine_name, dispatched_ago_seconds }
347
+ const possiblyStuck = {};
348
+
349
+ function updatePossiblyStuck(items) {
350
+ // Clear old entries not present in the latest poll
351
+ const fresh = new Set(items.map(x => x.assignment_id));
352
+ for (const aid of Object.keys(possiblyStuck)) {
353
+ if (!fresh.has(aid)) delete possiblyStuck[aid];
354
+ }
355
+ for (const item of items) {
356
+ if (!possiblyStuck[item.assignment_id]) {
357
+ // First time we see it — show a toast too
358
+ const mins = Math.round((item.dispatched_ago_seconds || 0) / 60);
359
+ showToast(
360
+ `⚠ #${item.issue_number} ${item.repo_name} on ${item.machine_name} may be stuck (${mins}m, agent has no record)`,
361
+ 'stuck', 20000
362
+ );
363
+ }
364
+ possiblyStuck[item.assignment_id] = item;
365
+ }
366
+ }
367
+
368
+ function renderStuckBanner(aid, issueNum) {
369
+ const info = possiblyStuck[aid];
370
+ if (!info) return '';
371
+ const mins = info.dispatched_ago_seconds ? Math.round(info.dispatched_ago_seconds / 60) : '?';
372
+ return `<div class="stuck-banner">
373
+ <span class="stuck-msg">⚠ Agent has no record of this assignment (${mins}m). Possibly stuck or orphaned.</span>
374
+ <button class="btn-unstick" onclick="doPipelineAction(${JSON.stringify(aid)},'unstick',${JSON.stringify(issueNum || null)})">Cancel &amp; Unstick</button>
375
+ </div>`;
376
+ }
377
+
378
+ // ── SSE live-events connection ─────────────────────────────────────────
379
+ let _sse = null;
380
+ let _sseReconnectTimer = null;
381
+
382
+ function connectSSE() {
383
+ if (_sse) return;
384
+ _sse = new EventSource('/events');
385
+
386
+ _sse.addEventListener('open', () => {
387
+ const dot = document.getElementById('sse-dot');
388
+ if (dot) { dot.classList.add('connected'); dot.title = 'Live events connected'; }
389
+ if (_sseReconnectTimer) { clearTimeout(_sseReconnectTimer); _sseReconnectTimer = null; }
390
+ });
391
+
392
+ _sse.addEventListener('assignment_completed', e => {
393
+ const d = JSON.parse(e.data || '{}');
394
+ // Bug 3 fix: use != null so a cost of $0 still shows (falsy check hid it).
395
+ // Bug 2 fix: showToast uses textContent — no E() needed (would double-encode).
396
+ const turns = d.stats?.num_turns != null ? `, turns=${d.stats.num_turns}` : '';
397
+ const cost = d.stats?.total_cost_usd != null ? `, cost=$${Number(d.stats.total_cost_usd).toFixed(2)}` : '';
398
+ showToast(`✓ #${d.issue_number} ${d.repo_name} on ${d.machine_name}: DONE${turns}${cost}`, 'done');
399
+ loadBoard();
400
+ });
401
+
402
+ _sse.addEventListener('assignment_failed', e => {
403
+ const d = JSON.parse(e.data || '{}');
404
+ const exit = d.exit_code != null ? `, exit=${d.exit_code}` : '';
405
+ const last = d.stats && d.stats.last_tool ? `, last_tool=${d.stats.last_tool}` : '';
406
+ // Bug 2 fix: showToast uses textContent — no E() needed.
407
+ showToast(`✗ #${d.issue_number} ${d.repo_name} on ${d.machine_name}: FAILED${exit}${last}`, 'failed');
408
+ playBell(220, 0.5); // Low tone for failures
409
+ loadBoard();
410
+ });
411
+
412
+ // Bug 1 fix: cancelled assignments get their own event type so they are not
413
+ // displayed as FAILED on the client.
414
+ _sse.addEventListener('assignment_cancelled', e => {
415
+ const d = JSON.parse(e.data || '{}');
416
+ showToast(`⊘ #${d.issue_number} ${d.repo_name} on ${d.machine_name}: CANCELLED`, 'stuck');
417
+ loadBoard();
418
+ });
419
+
420
+ _sse.addEventListener('board_updated', e => {
421
+ const d = JSON.parse(e.data || '{}');
422
+ if (Array.isArray(d.possibly_stuck)) updatePossiblyStuck(d.possibly_stuck);
423
+ loadBoard();
424
+ });
425
+
426
+ _sse.onerror = () => {
427
+ const dot = document.getElementById('sse-dot');
428
+ if (dot) { dot.classList.remove('connected'); dot.title = 'Live events disconnected — reconnecting'; }
429
+ _sse.close();
430
+ _sse = null;
431
+ _sseReconnectTimer = setTimeout(connectSSE, 5000);
432
+ };
433
+ }
434
+
435
+ // ── Board ──────────────────────────────────────────────────────────────
436
+ async function loadBoard() {
437
+ await loadPipeline();
438
+ const r = await fetch('/api/board');
439
+ const data = await r.json();
440
+ document.getElementById('round-info').textContent = `round ${data.round_number}`;
441
+
442
+ const activeEl = document.getElementById('active-list');
443
+ if (!data.active.length) {
444
+ activeEl.innerHTML = '<div class="meta">No active assignments</div>';
445
+ } else {
446
+ activeEl.innerHTML = data.active.map(a => {
447
+ const isWork = !a.type || a.type === 'work';
448
+ const typeTag = (a.type && a.type !== 'work') ? ` <span class="meta">[${E(a.type)}]</span>` : '';
449
+ const pv = isWork ? pipelineCache[a.assignment_id] : null;
450
+ const cardId = a.assignment_id ? ` id="card-${E(a.assignment_id)}"` : '';
451
+ const pipelineHtml = pv ? `<div class="pipeline-area">${renderPipelineBar(pv, a.issue_number)}</div>` : '';
452
+ const stuckHtml = a.assignment_id ? renderStuckBanner(a.assignment_id, a.issue_number) : '';
453
+ return `
454
+ <div class="card"${cardId}>
455
+ <div class="card-header">${E(a.repo_name)} #${a.issue_number}: ${E(a.issue_title)}${typeTag}</div>
456
+ <div class="meta">${badge('running', a.status)} on ${E(a.machine_name)}</div>
457
+ ${pipelineHtml}
458
+ ${stuckHtml}
459
+ </div>
460
+ `;
461
+ }).join('');
462
+ }
463
+
464
+ const compEl = document.getElementById('completed-list');
465
+ const recent = data.completed.slice(-10).reverse();
466
+ if (!recent.length) {
467
+ compEl.innerHTML = '<div class="meta">No completed assignments yet</div>';
468
+ } else {
469
+ compEl.innerHTML = recent.map(a => {
470
+ const cls = a.status === 'done' ? 'done' : 'failed';
471
+ const branchInfo = a.branch ? ` — branch: ${E(a.branch)}` : '';
472
+ const prLink = a.pr_url ? ` — <a href="${E(a.pr_url)}" target="_blank">PR</a>` : '';
473
+ const diffId = a.assignment_id ? `diff-${E(a.assignment_id)}` : '';
474
+ const diffToggle = a.branch && a.assignment_id
475
+ ? `<div class="diff-toggle" onclick="toggleDiff('${E(a.assignment_id)}')">show diff</div>
476
+ <div class="diff-content" id="${diffId}"></div>`
477
+ : '';
478
+ const isWork = !a.type || a.type === 'work';
479
+ const typeTag = (a.type && a.type !== 'work') ? ` <span class="meta">[${E(a.type)}]</span>` : '';
480
+ const pv = isWork ? pipelineCache[a.assignment_id] : null;
481
+ const cardId = a.assignment_id ? ` id="card-${E(a.assignment_id)}"` : '';
482
+ const pipelineHtml = pv ? `<div class="pipeline-area">${renderPipelineBar(pv, a.issue_number)}</div>` : '';
483
+ return `
484
+ <div class="card"${cardId}>
485
+ <div class="card-header">${E(a.repo_name)} #${a.issue_number}: ${E(a.issue_title)}${typeTag}</div>
486
+ <div class="meta">${badge(cls, a.status)} on ${E(a.machine_name)}${branchInfo}${prLink}</div>
487
+ ${pipelineHtml}
488
+ ${diffToggle}
489
+ </div>
490
+ `;
491
+ }).join('');
492
+ }
493
+ }
494
+
495
+ async function toggleDiff(aid) {
496
+ const el = document.getElementById('diff-' + aid);
497
+ if (!el) return;
498
+ if (el.classList.contains('open')) { el.classList.remove('open'); return; }
499
+ if (el.dataset.loaded) { el.classList.add('open'); return; }
500
+ el.textContent = 'Loading diff...';
501
+ el.classList.add('open');
502
+ try {
503
+ const r = await fetch('/api/diff/' + encodeURIComponent(aid));
504
+ const data = await r.json();
505
+ if (data.error) { el.textContent = 'Error: ' + data.error; }
506
+ else { el.textContent = data.diff || '(empty diff)'; }
507
+ } catch(e) { el.textContent = 'Error: ' + e.message; }
508
+ el.dataset.loaded = '1';
509
+ }
510
+
511
+ // ── Machines ───────────────────────────────────────────────────────────
512
+ async function loadMachines() {
513
+ const r = await fetch('/api/machines');
514
+ const data = await r.json();
515
+ const el = document.getElementById('machines-list');
516
+ el.innerHTML = data.map(m => {
517
+ const state = m.state === 'online' ? 'online' : 'offline';
518
+ const latency = m.latency_ms ? ` (${Math.round(m.latency_ms)}ms)` : '';
519
+ let detail = '';
520
+ if (m.assignments && m.assignments.active && m.assignments.active.length) {
521
+ const a = m.assignments.active[0];
522
+ const spec = a.spec || {};
523
+ detail = `<div class="meta">${badge('busy', 'busy')} #${spec.issue_number}: ${E(spec.issue_title || '')}</div>`;
524
+ if (a.progress && a.progress.updates && a.progress.updates.length) {
525
+ detail += `<div class="meta">latest: ${E(a.progress.updates[a.progress.updates.length-1])}</div>`;
526
+ }
527
+ if (a.progress && a.progress.stuck) {
528
+ detail += `<div class="meta" style="color:#da3633">STUCK: ${E(a.progress.stuck)}</div>`;
529
+ }
530
+ }
531
+ return `
532
+ <div class="card">
533
+ <div class="card-header">${E(m.name)} ${badge(state, state)}${latency}</div>
534
+ <div class="meta">host: ${E(m.host)} — repos: ${E((m.repos || []).join(', ') || 'none')}</div>
535
+ ${detail}
536
+ </div>
537
+ `;
538
+ }).join('');
539
+ }
540
+
541
+ // ── Proposals ──────────────────────────────────────────────────────────
542
+ async function loadProposals() {
543
+ const r = await fetch('/api/proposals');
544
+ const data = await r.json();
545
+ const el = document.getElementById('proposals-list');
546
+ if (!data.length) {
547
+ el.innerHTML = '<div class="meta">No pending proposals. Run <code>coord plan</code> first.</div>';
548
+ return;
549
+ }
550
+ el.innerHTML = data.map(p => `
551
+ <div class="proposal" id="proposal-${p.id}">
552
+ <div class="card-header">[${p.id}] ${E(p.machine_name)} → ${E(p.repo_name)} #${p.issue_number}: ${E(p.issue_title)}</div>
553
+ <div class="meta">${E(p.rationale)}</div>
554
+ ${p.files_likely.length ? `<div class="meta">files: ${E(p.files_likely.join(', '))}</div>` : ''}
555
+ <textarea class="briefing-edit" id="briefing-${p.id}">${E(p.briefing || '')}</textarea>
556
+ <div>
557
+ <button class="btn btn-approve" onclick="approveOne(${p.id})">Approve</button>
558
+ <button class="btn btn-reject" onclick="rejectOne(${p.id})">Reject</button>
559
+ </div>
560
+ </div>
561
+ `).join('') + `<button class="btn btn-all" onclick="approveAll()">Approve All</button>`;
562
+ }
563
+
564
+ async function approveOne(id) {
565
+ const briefing = document.getElementById('briefing-' + id)?.value;
566
+ const briefings = {};
567
+ if (briefing !== undefined) briefings[String(id)] = briefing;
568
+ const resp = await fetch('/api/approve', {
569
+ method: 'POST',
570
+ headers: {'Content-Type': 'application/json'},
571
+ body: JSON.stringify({ids: [id], briefings}),
572
+ });
573
+ const data = await resp.json();
574
+ const el = document.getElementById('proposal-' + id);
575
+ if (el && data.results && data.results[0]?.ok) {
576
+ el.style.opacity = '0.4';
577
+ el.querySelector('.btn-approve').disabled = true;
578
+ }
579
+ loadBoard();
580
+ }
581
+
582
+ async function rejectOne(id) {
583
+ await fetch('/api/reject', {
584
+ method: 'POST',
585
+ headers: {'Content-Type': 'application/json'},
586
+ body: JSON.stringify({ids: [id]}),
587
+ });
588
+ loadProposals();
589
+ }
590
+
591
+ async function approveAll() {
592
+ const r = await fetch('/api/proposals');
593
+ const proposals = await r.json();
594
+ const ids = proposals.map(p => p.id);
595
+ const briefings = {};
596
+ for (const p of proposals) {
597
+ const el = document.getElementById('briefing-' + p.id);
598
+ if (el) briefings[String(p.id)] = el.value;
599
+ }
600
+ await fetch('/api/approve', {
601
+ method: 'POST',
602
+ headers: {'Content-Type': 'application/json'},
603
+ body: JSON.stringify({ids, briefings}),
604
+ });
605
+ loadProposals();
606
+ loadBoard();
607
+ }
608
+
609
+ // ── Chat ───────────────────────────────────────────────────────────────
610
+ async function sendChat() {
611
+ const input = document.getElementById('chat-input');
612
+ const msg = input.value.trim();
613
+ if (!msg) return;
614
+ input.value = '';
615
+
616
+ const messages = document.getElementById('chat-messages');
617
+ const userDiv = document.createElement('div');
618
+ userDiv.className = 'chat-msg user';
619
+ userDiv.textContent = msg;
620
+ messages.appendChild(userDiv);
621
+
622
+ const responseDiv = document.createElement('div');
623
+ responseDiv.className = 'chat-msg assistant';
624
+ responseDiv.textContent = '...';
625
+ messages.appendChild(responseDiv);
626
+ messages.scrollTop = messages.scrollHeight;
627
+
628
+ try {
629
+ const r = await fetch('/api/chat', {
630
+ method: 'POST',
631
+ headers: {'Content-Type': 'application/json'},
632
+ body: JSON.stringify({message: msg}),
633
+ });
634
+ const reader = r.body.getReader();
635
+ const decoder = new TextDecoder();
636
+ let text = '';
637
+ while (true) {
638
+ const {done, value} = await reader.read();
639
+ if (done) break;
640
+ const chunk = decoder.decode(value);
641
+ for (const line of chunk.split('\n')) {
642
+ if (line.startsWith('data: ') && line !== 'data: [DONE]') {
643
+ try { const d = JSON.parse(line.slice(6)); text += d.text; responseDiv.textContent = text; } catch(e) {}
644
+ }
645
+ }
646
+ messages.scrollTop = messages.scrollHeight;
647
+ }
648
+ if (!text) responseDiv.textContent = '(no response)';
649
+ } catch(e) { responseDiv.textContent = 'Error: ' + e.message; }
650
+ }
651
+
652
+ document.getElementById('chat-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); });
653
+
654
+ loadBoard();
655
+ connectSSE();
656
+ </script>
657
+ </body>
658
+ </html>