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,435 @@
1
+ /**
2
+ * Home — the Pipeline panel.
3
+ *
4
+ * It is the *list panel's* content, not a screen (#1547): the app shell owns
5
+ * the frame (rail, status bar, detail column) and renders this same component
6
+ * into the list slot at every viewport — a 360px column on a 32" monitor, the
7
+ * whole screen on a phone. Nothing below is breakpoint-aware; the only
8
+ * concession to width is Tailwind responsive classes on presentation.
9
+ *
10
+ * A "Live sessions" section (#1067) surfaces live coord-* interactive
11
+ * sessions (#1066's `GET /api/sessions`) at the TOP, above everything else —
12
+ * these are in-progress human-attended sessions that can be taken over via
13
+ * the `/terminal/:sessionId` view, so they get first billing over the
14
+ * pipeline list below. (The rail's Sessions view lists the same cards; this
15
+ * section stays because "attention before detail" says the pipeline should
16
+ * still open with what's live.)
17
+ *
18
+ * Below that: in-flight pipeline items as tappable cards, kept live by the
19
+ * SSE `/events` stream (#1549) rather than a poll timer -- see
20
+ * `src/realtime/`. Two filter tabs:
21
+ * Active — everything currently in the pipeline (not yet merged)
22
+ * Needs me — items with at least one available gate action (need human input)
23
+ *
24
+ * Pull-to-refresh: drag down from the top (when already scrolled to top) to
25
+ * trigger an immediate refetch.
26
+ */
27
+ import { useCallback, useRef, useState } from 'react'
28
+ import { useMatch, useNavigate } from 'react-router-dom'
29
+ import { useQuery } from '@tanstack/react-query'
30
+ import { fetchPipeline, fetchSessions, type PipelineView, type SessionInfo } from '@/api/client'
31
+ import { PipelineCard } from '@/components/PipelineCard'
32
+ import { SessionCard } from '@/components/SessionCard'
33
+ import { PanelHeader } from '@/components/PanelHeader'
34
+ import { isActive, needsMe } from '@/lib/pipeline'
35
+ import { paths } from '@/routes/paths'
36
+
37
+ // ── Filter logic ──────────────────────────────────────────────────────────────
38
+
39
+ type FilterTab = 'active' | 'needs-me'
40
+
41
+ const FILTER_FNS: Record<FilterTab, (v: PipelineView) => boolean> = {
42
+ 'active': isActive,
43
+ 'needs-me': needsMe,
44
+ }
45
+
46
+ /**
47
+ * `current_stage` values that PipelineCard's `stageStatusInfo` renders as a
48
+ * "done-ish" yellow badge — the work itself has finished, but the item is
49
+ * waiting on a downstream gate (review/smoke/merge) rather than actively
50
+ * running. These are the low-signal cards #1218 asked to group + collapse
51
+ * out of the primary "Active" view by default (e.g. a merged item's
52
+ * penultimate "work done" state sitting near the top of the list).
53
+ */
54
+ const DONE_ISH_STAGES = new Set(['done', 'review_done', 'smoke_passed', 'merge_ready'])
55
+
56
+ function isDoneIsh(view: PipelineView): boolean {
57
+ return DONE_ISH_STAGES.has(view.current_stage)
58
+ }
59
+
60
+ /**
61
+ * `current_stage` values that indicate a subprocess is actively running —
62
+ * mirrors PipelineCard's RUNNING_STAGES so the "in progress" bucket can
63
+ * prioritize live work after needs-me items.
64
+ */
65
+ const RUNNING_STAGES = new Set(['coding', 'review_running', 'smoke_running', 'merging'])
66
+
67
+ /**
68
+ * Split the "Active" tab's items into "in progress" (shown expanded) and
69
+ * "done-ish" (collapsed by default, see DONE_ISH_STAGES). Within "in
70
+ * progress", items needing human input (available_gates.length > 0) sort
71
+ * first, then actively-running items, with the incoming order preserved as
72
+ * the tiebreak (stable sort). "Done-ish" items are returned unsorted here —
73
+ * the caller sorts them by finished_at descending.
74
+ */
75
+ function groupActiveItems(views: PipelineView[]): { inProgress: PipelineView[]; done: PipelineView[] } {
76
+ const inProgress: PipelineView[] = []
77
+ const done: PipelineView[] = []
78
+ for (const view of views) {
79
+ if (isDoneIsh(view)) {
80
+ done.push(view)
81
+ } else {
82
+ inProgress.push(view)
83
+ }
84
+ }
85
+
86
+ const priority = (view: PipelineView): number => {
87
+ if (needsMe(view)) return 0
88
+ if (RUNNING_STAGES.has(view.current_stage)) return 1
89
+ return 2
90
+ }
91
+ inProgress.sort((a, b) => priority(a) - priority(b))
92
+
93
+ // Most recently finished first; items with no finished_at yet sort last.
94
+ done.sort((a, b) => (b.finished_at ?? -Infinity) - (a.finished_at ?? -Infinity))
95
+
96
+ return { inProgress, done }
97
+ }
98
+
99
+ // ── Pull-to-refresh ───────────────────────────────────────────────────────────
100
+
101
+ const PTR_THRESHOLD_PX = 80
102
+
103
+ interface UsePullToRefreshOptions {
104
+ onRefresh: () => void
105
+ }
106
+
107
+ /**
108
+ * "Already at the top?" — walked up from the touch target rather than read off
109
+ * `window`.
110
+ *
111
+ * This used to be `window.scrollY === 0`, which was true because the panel was
112
+ * the document. Under the shell (#1547) the list scrolls inside an
113
+ * `overflow-y-auto` column, so `window.scrollY` is *permanently* 0 and that
114
+ * test would have fired a refetch on every downward scroll gesture on the
115
+ * phone — the exact regression the story's "phone flows work unchanged"
116
+ * criterion is about.
117
+ */
118
+ function isScrolledToTop(target: EventTarget | null): boolean {
119
+ let el: HTMLElement | null = target instanceof HTMLElement ? target : null
120
+ while (el && el !== document.body) {
121
+ if (el.scrollTop > 0) return false
122
+ el = el.parentElement
123
+ }
124
+ return (window.scrollY || document.documentElement.scrollTop || 0) === 0
125
+ }
126
+
127
+ function usePullToRefresh({ onRefresh }: UsePullToRefreshOptions) {
128
+ const startYRef = useRef(0)
129
+ const startedAtTopRef = useRef(false)
130
+
131
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
132
+ startYRef.current = e.touches[0].clientY
133
+ // Sampled at touch *start*: by the time the gesture ends the container has
134
+ // already been scrolled by it, so asking then would answer the wrong
135
+ // question.
136
+ startedAtTopRef.current = isScrolledToTop(e.target)
137
+ }, [])
138
+
139
+ const onTouchEnd = useCallback(
140
+ (e: React.TouchEvent) => {
141
+ const dy = e.changedTouches[0].clientY - startYRef.current
142
+ if (dy > PTR_THRESHOLD_PX && startedAtTopRef.current) {
143
+ onRefresh()
144
+ }
145
+ },
146
+ [onRefresh],
147
+ )
148
+
149
+ return { onTouchStart, onTouchEnd }
150
+ }
151
+
152
+ // ── Filter tab component ──────────────────────────────────────────────────────
153
+
154
+ interface FilterTabsProps {
155
+ active: FilterTab
156
+ counts: Record<FilterTab, number>
157
+ onChange: (tab: FilterTab) => void
158
+ }
159
+
160
+ function FilterTabs({ active: activeTab, counts, onChange }: FilterTabsProps) {
161
+ const tabs: Array<{ id: FilterTab; label: string }> = [
162
+ { id: 'active', label: 'Active' },
163
+ { id: 'needs-me', label: 'Needs me' },
164
+ ]
165
+
166
+ return (
167
+ <div className="flex gap-2" role="tablist" aria-label="Pipeline filters">
168
+ {tabs.map(({ id, label }) => {
169
+ const isSelected = id === activeTab
170
+ const count = counts[id]
171
+ return (
172
+ <button
173
+ key={id}
174
+ type="button"
175
+ role="tab"
176
+ aria-selected={isSelected}
177
+ onClick={() => onChange(id)}
178
+ className={
179
+ isSelected
180
+ ? 'flex items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground'
181
+ : 'flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground'
182
+ }
183
+ >
184
+ {label}
185
+ {count > 0 && (
186
+ <span
187
+ className={
188
+ isSelected
189
+ ? 'rounded-full bg-primary-foreground/20 px-1.5 text-xs font-mono'
190
+ : 'rounded-full bg-secondary px-1.5 text-xs font-mono'
191
+ }
192
+ >
193
+ {count}
194
+ </span>
195
+ )}
196
+ </button>
197
+ )
198
+ })}
199
+ </div>
200
+ )
201
+ }
202
+
203
+ // ── Live sessions section ───────────────────────────────────────────────────────
204
+
205
+ interface LiveSessionsProps {
206
+ sessions: SessionInfo[]
207
+ onSelect: (sessionId: string) => void
208
+ }
209
+
210
+ function LiveSessions({ sessions, onSelect }: LiveSessionsProps) {
211
+ if (sessions.length === 0) return null
212
+
213
+ return (
214
+ <section aria-label="Live sessions" className="mb-5 space-y-3">
215
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
216
+ Live sessions
217
+ </h2>
218
+ {sessions.map((session) => (
219
+ <SessionCard
220
+ key={session.session_id}
221
+ session={session}
222
+ onClick={() => onSelect(session.session_id)}
223
+ />
224
+ ))}
225
+ </section>
226
+ )
227
+ }
228
+
229
+ // ── Done section (collapsed by default) ─────────────────────────────────────────
230
+
231
+ interface DoneSectionProps {
232
+ items: PipelineView[]
233
+ onSelect: (view: PipelineView) => void
234
+ isSelected: (view: PipelineView) => boolean
235
+ }
236
+
237
+ /**
238
+ * Collapsed-by-default "Work done" section for the Active tab's done-ish
239
+ * items (#1218) — keeps merged/finished noise out of the primary view while
240
+ * staying one tap away. Sorted by finished_at descending by the caller
241
+ * (groupActiveItems), so expanding always shows most-recently-finished first.
242
+ */
243
+ function DoneSection({ items, onSelect, isSelected }: DoneSectionProps) {
244
+ const [expanded, setExpanded] = useState(false)
245
+
246
+ if (items.length === 0) return null
247
+
248
+ return (
249
+ <section aria-label="Work done" className="mt-3">
250
+ <button
251
+ type="button"
252
+ onClick={() => setExpanded((v) => !v)}
253
+ aria-expanded={expanded}
254
+ className="flex w-full items-center justify-between rounded-lg border border-border bg-card px-4 py-2 text-left text-xs font-medium text-muted-foreground"
255
+ >
256
+ <span>Work done ({items.length})</span>
257
+ <span aria-hidden="true">{expanded ? '▾' : '▸'}</span>
258
+ </button>
259
+ {expanded && (
260
+ <div className="mt-3 space-y-3">
261
+ {items.map((view) => (
262
+ <PipelineCard
263
+ key={view.assignment_id}
264
+ view={view}
265
+ selected={isSelected(view)}
266
+ onClick={() => onSelect(view)}
267
+ />
268
+ ))}
269
+ </div>
270
+ )}
271
+ </section>
272
+ )
273
+ }
274
+
275
+ // ── Home screen ───────────────────────────────────────────────────────────────
276
+
277
+ export default function Home() {
278
+ const navigate = useNavigate()
279
+ const [filterTab, setFilterTab] = useState<FilterTab>('active')
280
+
281
+ // Which row is open in the detail panel. Read from the route rather than
282
+ // passed in, because on narrow this component and the detail are never on
283
+ // screen together and there is nobody to pass it — the URL is the one thing
284
+ // both compositions agree on. Renders as a marker only; on narrow the
285
+ // detail has replaced the list anyway, so nothing is marked.
286
+ //
287
+ // Keyed on repo + issue (#1548), not `assignment_id`: the URL is
288
+ // `/pipeline/:repo/:issue[/:tab]`, so both the two- and three-segment forms
289
+ // must mark the same row.
290
+ const itemMatch = useMatch('/pipeline/:repo/:issue')
291
+ const itemTabMatch = useMatch('/pipeline/:repo/:issue/:tab')
292
+ const detailMatch = itemMatch ?? itemTabMatch
293
+ const selectedRepo = detailMatch?.params.repo
294
+ const selectedIssue = detailMatch?.params.issue
295
+ const isSelected = (view: PipelineView): boolean =>
296
+ selectedRepo === view.repo_name && selectedIssue === String(view.issue_number)
297
+
298
+ // #1549: no refetchInterval -- kept fresh by SSE-driven invalidation
299
+ // (RealtimeProvider invalidates ['pipeline'] on assignment_*/board_updated
300
+ // events). isFetching still pulses briefly on every SSE-triggered refetch.
301
+ const { data, isLoading, isError, isFetching, dataUpdatedAt, refetch } = useQuery({
302
+ queryKey: ['pipeline'],
303
+ queryFn: fetchPipeline,
304
+ })
305
+
306
+ // #1549: /api/sessions fans out one blocking ssh per machine (see
307
+ // fetchSessions doc comment) -- a 4s poll against a ~5s server sweep is
308
+ // exactly the hazard the offline-cooldown cache exists to paper over. SSE
309
+ // invalidation (machine_connected/disconnected, assignment_*) now drives
310
+ // this instead, so the request only fires when something plausibly changed.
311
+ const { data: sessions } = useQuery({
312
+ queryKey: ['sessions'],
313
+ queryFn: fetchSessions,
314
+ })
315
+
316
+ const handleRefresh = useCallback(() => {
317
+ void refetch()
318
+ }, [refetch])
319
+
320
+ const { onTouchStart, onTouchEnd } = usePullToRefresh({ onRefresh: handleRefresh })
321
+
322
+ const filtered = data ? data.filter(FILTER_FNS[filterTab]) : []
323
+
324
+ // Active tab only: group into "in progress" (expanded, needs-me-first) and
325
+ // "done-ish" (collapsed "Work done" section, sorted by recency) — #1218.
326
+ // The "Needs me" tab stays a flat list; its semantics are unchanged.
327
+ const { inProgress, done } =
328
+ filterTab === 'active' ? groupActiveItems(filtered) : { inProgress: filtered, done: [] }
329
+
330
+ const counts: Record<FilterTab, number> = {
331
+ 'active': data ? data.filter(isActive).length : 0,
332
+ 'needs-me': data ? data.filter(needsMe).length : 0,
333
+ }
334
+
335
+ const updatedLabel = dataUpdatedAt
336
+ ? new Date(dataUpdatedAt).toLocaleTimeString()
337
+ : null
338
+
339
+ return (
340
+ <div
341
+ className="mx-auto w-full max-w-lg px-4 py-4"
342
+ onTouchStart={onTouchStart}
343
+ onTouchEnd={onTouchEnd}
344
+ >
345
+ {/* Header. Connection state moved to the shell's status bar (#1547) —
346
+ it's app-wide truth, and with wide showing list + detail at once,
347
+ two live-region badges saying the same thing is noise. */}
348
+ <PanelHeader title="Pipeline" count={data ? counts.active : undefined}>
349
+ {isFetching && !isLoading && (
350
+ <span className="h-2 w-2 animate-pulse rounded-full bg-primary" aria-label="Refreshing" />
351
+ )}
352
+ {updatedLabel && <span className="font-mono text-[.7rem] text-faint">{updatedLabel}</span>}
353
+ </PanelHeader>
354
+
355
+ {/* Live sessions — surfaced above everything else (#1067) */}
356
+ <LiveSessions
357
+ sessions={sessions ?? []}
358
+ onSelect={(sessionId) => navigate(paths.terminal(sessionId))}
359
+ />
360
+
361
+ {/* Filter tabs */}
362
+ {data && (
363
+ <div className="mb-4">
364
+ <FilterTabs active={filterTab} counts={counts} onChange={setFilterTab} />
365
+ </div>
366
+ )}
367
+
368
+ {/* Loading state */}
369
+ {isLoading && (
370
+ <p className="py-12 text-center text-sm text-muted-foreground">
371
+ Loading pipeline…
372
+ </p>
373
+ )}
374
+
375
+ {/* Error state */}
376
+ {isError && (
377
+ <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-6 text-center">
378
+ <p className="text-sm text-destructive">Failed to load pipeline</p>
379
+ <p className="mt-1 text-xs text-muted-foreground">
380
+ Is the dashboard server running?
381
+ </p>
382
+ <button
383
+ type="button"
384
+ onClick={handleRefresh}
385
+ className="mt-3 rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
386
+ >
387
+ Retry
388
+ </button>
389
+ </div>
390
+ )}
391
+
392
+ {/* Card list */}
393
+ {data && filtered.length > 0 && (
394
+ <>
395
+ <section className="space-y-3" aria-label={filterTab === 'active' ? 'Active items' : 'Items needing attention'}>
396
+ {inProgress.map((view) => (
397
+ <PipelineCard
398
+ key={view.assignment_id}
399
+ view={view}
400
+ selected={isSelected(view)}
401
+ onClick={() => navigate(paths.pipelineItem(view.repo_name, view.issue_number))}
402
+ />
403
+ ))}
404
+ </section>
405
+ <DoneSection
406
+ items={done}
407
+ isSelected={isSelected}
408
+ onSelect={(view) => navigate(paths.pipelineItem(view.repo_name, view.issue_number))}
409
+ />
410
+ </>
411
+ )}
412
+
413
+ {/* Empty state */}
414
+ {data && filtered.length === 0 && (
415
+ <div className="py-12 text-center">
416
+ {filterTab === 'needs-me' ? (
417
+ <>
418
+ <p className="text-sm font-medium text-foreground">All clear</p>
419
+ <p className="mt-1 text-xs text-muted-foreground">
420
+ No items are waiting for your input right now.
421
+ </p>
422
+ </>
423
+ ) : (
424
+ <>
425
+ <p className="text-sm font-medium text-foreground">No active pipeline items</p>
426
+ <p className="mt-1 text-xs text-muted-foreground">
427
+ Dispatch a worker to get started.
428
+ </p>
429
+ </>
430
+ )}
431
+ </div>
432
+ )}
433
+ </div>
434
+ )
435
+ }