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,140 @@
1
+ /**
2
+ * SessionDetail — the detail slot for one live session, at `/sessions/:id`
3
+ * (#1548).
4
+ *
5
+ * Mirrors `Detail`'s shape (panel content, not a screen; a back control; a
6
+ * not-found state for a stale id) so a session row in `SessionsList` behaves
7
+ * like a pipeline row in `Home` — tap the row, the shell fills the detail
8
+ * column (wide) or drills in (narrow), and the address bar carries exactly
9
+ * what's selected. The one action here is "Take over", which hands off to
10
+ * the full-screen `/terminal/:sessionId` PTY view (#1065) — that route stays
11
+ * deliberately outside the shell (see `App.tsx`), so this is a small
12
+ * summary card plus a door to it, not a second terminal implementation.
13
+ */
14
+ import { useNavigate, useParams } from 'react-router-dom'
15
+ import { useQuery } from '@tanstack/react-query'
16
+
17
+ import { fetchSessions, type SessionInfo } from '@/api/client'
18
+ import { paths } from '@/routes/paths'
19
+ import { cn } from '@/lib/utils'
20
+
21
+ const detailShellClass = 'mx-auto w-full max-w-3xl px-4 py-5 md:px-6'
22
+
23
+ const ASSIGNMENT_TYPE_LABEL: Record<string, string> = {
24
+ smoke: 'test',
25
+ review: 'review',
26
+ 'conflict-fix': 'merge',
27
+ }
28
+
29
+ function stageLabel(stage: string): string {
30
+ return ASSIGNMENT_TYPE_LABEL[stage] ?? stage
31
+ }
32
+
33
+ function statusInfo(session: SessionInfo): { label: string; className: string } {
34
+ if (session.pane_dead) {
35
+ return { label: 'ended', className: 'bg-secondary text-secondary-foreground' }
36
+ }
37
+ if (session.attached) {
38
+ return { label: 'attached', className: 'bg-yellow-600 text-black' }
39
+ }
40
+ return { label: 'live', className: 'bg-primary text-primary-foreground' }
41
+ }
42
+
43
+ export default function SessionDetail() {
44
+ const { id } = useParams<{ id: string }>()
45
+ const navigate = useNavigate()
46
+
47
+ const { data: sessions, isLoading, isError } = useQuery({
48
+ queryKey: ['sessions'],
49
+ queryFn: fetchSessions,
50
+ })
51
+
52
+ const session = sessions?.find((s) => s.session_id === id) ?? null
53
+
54
+ if (isLoading) {
55
+ return (
56
+ <div className={detailShellClass}>
57
+ <p className="py-12 text-center text-sm text-muted-foreground">Loading…</p>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ if (isError) {
63
+ return (
64
+ <div className={detailShellClass}>
65
+ <p className="py-12 text-center text-sm text-destructive">Failed to load sessions</p>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ if (!session) {
71
+ return (
72
+ <div className={detailShellClass}>
73
+ <header className="mb-6 flex items-center gap-3">
74
+ <button
75
+ type="button"
76
+ onClick={() => navigate(-1)}
77
+ className="rounded p-1 text-muted-foreground hover:text-foreground"
78
+ aria-label="Back"
79
+ >
80
+
81
+ </button>
82
+ <h1 className="text-step-1 font-semibold text-foreground">Not found</h1>
83
+ </header>
84
+ <p className="text-sm text-muted-foreground">
85
+ Session <span className="font-mono">{id}</span> is no longer live.
86
+ </p>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ const { label: statusLabel, className: statusClass } = statusInfo(session)
92
+ const title = session.issue_title ?? session.session_name
93
+
94
+ return (
95
+ <div className={detailShellClass}>
96
+ <header className="mb-5">
97
+ <div className="flex items-start gap-2">
98
+ <button
99
+ type="button"
100
+ onClick={() => navigate(-1)}
101
+ aria-label="Back"
102
+ className="-ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
103
+ >
104
+
105
+ </button>
106
+ <div className="min-w-0 flex-1">
107
+ <p className="text-xs text-muted-foreground">
108
+ {session.repo && <span className="font-mono">{session.repo}</span>}
109
+ {session.issue !== null && <span className="font-mono"> #{session.issue}</span>}
110
+ {(session.repo || session.issue !== null) && session.machine && ' · '}
111
+ {session.machine && <span className="font-mono">{session.machine}</span>}
112
+ </p>
113
+ <h2 className="mt-0.5 text-base font-semibold text-foreground">{title}</h2>
114
+ </div>
115
+ <span className={cn('shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold', statusClass)}>
116
+ {statusLabel}
117
+ </span>
118
+ </div>
119
+
120
+ {session.stage && (
121
+ <div className="mt-3 flex flex-wrap gap-1.5">
122
+ <span className="rounded border border-border px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
123
+ {stageLabel(session.stage)}
124
+ </span>
125
+ </div>
126
+ )}
127
+ </header>
128
+
129
+ {!session.pane_dead && (
130
+ <button
131
+ type="button"
132
+ onClick={() => navigate(paths.terminal(session.session_id))}
133
+ className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
134
+ >
135
+ Take over
136
+ </button>
137
+ )}
138
+ </div>
139
+ )
140
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Sessions — the live interactive sessions as a first-class list panel
3
+ * (#1547).
4
+ *
5
+ * No new content: this is the exact `SessionCard` list Home already surfaces
6
+ * above the pipeline (#1067), given its own rail entry so the shell has a
7
+ * second *real* view. That matters for more than symmetry — with one ready
8
+ * view the narrow bottom row degenerates into a single tab, which can't
9
+ * demonstrate (or regression-test) that the rail works as a phone nav at all.
10
+ *
11
+ * Home keeps its own "Live sessions" section unchanged: "attention before
12
+ * detail" says the pipeline view should still open with what's live.
13
+ *
14
+ * Rows navigate to `/sessions/:id` (#1548) — the same list -> detail
15
+ * convention as `Home` -> `Detail` — rather than jumping straight to the
16
+ * full-screen terminal, so the selection is addressable and the shell fills
17
+ * its detail column on wide instead of leaving the shell entirely. Home's
18
+ * own "Live sessions" quick-glance section keeps its direct-to-terminal
19
+ * shortcut unchanged.
20
+ */
21
+ import { useNavigate } from 'react-router-dom'
22
+ import { useQuery } from '@tanstack/react-query'
23
+
24
+ import { fetchSessions } from '@/api/client'
25
+ import { SessionCard } from '@/components/SessionCard'
26
+ import { PanelHeader } from '@/components/PanelHeader'
27
+ import { paths } from '@/routes/paths'
28
+
29
+ export default function SessionsList() {
30
+ const navigate = useNavigate()
31
+ const { data, isLoading, isError, refetch } = useQuery({
32
+ queryKey: ['sessions'],
33
+ queryFn: fetchSessions,
34
+ })
35
+
36
+ const sessions = data ?? []
37
+
38
+ return (
39
+ <div className="mx-auto w-full max-w-lg px-4 py-4">
40
+ <PanelHeader title="Sessions" count={sessions.length} countLabel="live" />
41
+
42
+ {isLoading && (
43
+ <p className="py-12 text-center text-sm text-muted-foreground">Loading sessions…</p>
44
+ )}
45
+
46
+ {isError && (
47
+ <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-6 text-center">
48
+ <p className="text-sm text-destructive">Failed to load sessions</p>
49
+ <button
50
+ type="button"
51
+ onClick={() => void refetch()}
52
+ className="mt-3 rounded-full border border-border px-3 py-1 text-xs text-muted-foreground"
53
+ >
54
+ Retry
55
+ </button>
56
+ </div>
57
+ )}
58
+
59
+ {data && sessions.length > 0 && (
60
+ <section className="space-y-3" aria-label="Live sessions">
61
+ {sessions.map((session) => (
62
+ <SessionCard
63
+ key={session.session_id}
64
+ session={session}
65
+ onClick={() => navigate(paths.session(session.session_id))}
66
+ />
67
+ ))}
68
+ </section>
69
+ )}
70
+
71
+ {data && sessions.length === 0 && (
72
+ <div className="py-12 text-center">
73
+ <p className="text-sm font-medium text-foreground">No live sessions</p>
74
+ <p className="mt-1 text-xs text-muted-foreground">
75
+ Interactive coord sessions show up here while they're running.
76
+ </p>
77
+ </div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Terminal — the live take-over pane for a `coord-*` interactive session
3
+ * (#1068). Reached via `SessionCard` (#1067) at `/terminal/:sessionId`,
4
+ * replacing the `TerminalComingSoon` stub now that the pane itself exists.
5
+ *
6
+ * Opens a WebSocket to `/ws/terminal/{session_id}` (#1065's PTY<->WebSocket
7
+ * bridge) and renders the byte stream with xterm.js:
8
+ * - browser keystrokes -> binary WS frames (the bridge's `message["bytes"]`
9
+ * path writes them straight to the PTY's stdin)
10
+ * - PTY output (binary WS frames) -> `term.write(Uint8Array)`
11
+ * - terminal size -> a JSON text control frame
12
+ * `{"type": "resize", "cols": .., "rows": ..}` on connect, on xterm
13
+ * resize, and on window resize (FitAddon keeps cols/rows matched to the
14
+ * pane's actual size)
15
+ *
16
+ * Closing the pane (the ✕ button, back navigation, or unmount) closes the
17
+ * WebSocket. That alone is enough to hit the bridge's detach path -- the
18
+ * server's `finally` block calls `attached.detach()` on every disconnect,
19
+ * local tmux session included, and detach never kills the session (#1065).
20
+ *
21
+ * Reconnect / detach resilience (#1071): mobile networks drop and resume
22
+ * WebSockets constantly (backgrounding the tab, wifi<->cellular handoff).
23
+ * An *unexpected* close (any code other than the bridge's session-gone
24
+ * signal) re-opens a fresh WebSocket to the same `session_id` with
25
+ * exponential backoff -- the xterm.js `Terminal` instance itself is never
26
+ * recreated, so `tmux attach`'s redraw on reattach just paints into the
27
+ * existing pane. The bridge closes with code 4404 when `session_id` no
28
+ * longer resolves to an active assignment (`resolve_session_target` in
29
+ * `coord/dashboard/terminal.py`) -- that's a *terminal* state (the worker
30
+ * session itself is gone, not just this connection), so it is surfaced
31
+ * distinctly and never retried.
32
+ *
33
+ * Backoff-reset guard (live-smoke fix): the WS handshake completing
34
+ * (`onopen`) does NOT by itself mean the session is usable -- the bridge
35
+ * `accept()`s before it spawns the tmux attach, so a server-side attach
36
+ * failure (or anything else that closes the socket a moment after accept)
37
+ * fires `onopen` immediately followed by `onclose`. Resetting the backoff
38
+ * counter on every `onopen` -- as an earlier version of this file did --
39
+ * meant that flapping case never actually backed off: each cycle reopened,
40
+ * reset the counter to zero, then closed again, so the client hammered the
41
+ * bridge at the *base* interval indefinitely instead of climbing the
42
+ * exponential ladder (reproduced live against #1071 iteration 1: 44 reconnect
43
+ * attempts in ~5-10 minutes). The counter now only resets when a connection
44
+ * stayed open at least `STABLE_CONNECTION_MS` before dropping -- long enough
45
+ * to have been a genuine, working attach rather than an accept-then-fail
46
+ * flap -- so a truly bad/looping backend still gets the full exponential
47
+ * backoff treatment.
48
+ *
49
+ * Layout: the pane occupies the top half of the screen (`h-[50vh]`); the
50
+ * bottom half hosts `MobileKeyBar` (#1070) -- the soft-key row + line-input
51
+ * for driving the interactive `claude` TUI from a phone. iOS/Android
52
+ * safe-area insets pad the header so the close control clears the
53
+ * notch/status bar (`index.html` sets `viewport-fit=cover` so
54
+ * `env(safe-area-inset-*)` resolves to something other than 0).
55
+ */
56
+ import { useEffect, useRef, useState } from 'react'
57
+ import { useNavigate, useParams } from 'react-router-dom'
58
+ import { Terminal as XTerm } from '@xterm/xterm'
59
+ import { FitAddon } from '@xterm/addon-fit'
60
+ import '@xterm/xterm/css/xterm.css'
61
+ import { terminalWebSocketUrl } from '@/api/client'
62
+ import MobileKeyBar from '@/components/MobileKeyBar'
63
+
64
+ type ConnectionState = 'connecting' | 'open' | 'reconnecting' | 'ended'
65
+
66
+ const STATUS_LABEL: Record<ConnectionState, string> = {
67
+ connecting: 'Connecting…',
68
+ open: 'Live',
69
+ reconnecting: 'Reconnecting…',
70
+ ended: 'Session ended',
71
+ }
72
+
73
+ // Bridge close code for "session_id no longer resolves to an active
74
+ // assignment" -- see `resolve_session_target` / `terminal_ws` in
75
+ // `coord/dashboard/terminal.py`. Distinct from every other close (which is
76
+ // treated as a transient drop worth retrying).
77
+ const SESSION_GONE_CODE = 4404
78
+ const RECONNECT_BASE_MS = 1000
79
+ const RECONNECT_MAX_MS = 10000
80
+ // A connection has to survive at least this long after opening before a
81
+ // subsequent drop counts as "was actually working" and resets the backoff
82
+ // counter -- see the backoff-reset guard note above.
83
+ const STABLE_CONNECTION_MS = 2000
84
+
85
+ export default function Terminal() {
86
+ const { sessionId } = useParams<{ sessionId: string }>()
87
+ const navigate = useNavigate()
88
+ const containerRef = useRef<HTMLDivElement | null>(null)
89
+ const wsRef = useRef<WebSocket | null>(null)
90
+ // Set right before any *deliberate* close (the ✕ button, unmount) so the
91
+ // reconnect logic below can tell "we closed this on purpose" apart from
92
+ // "the network/tab dropped it out from under us."
93
+ const intentionalCloseRef = useRef(false)
94
+ const [state, setState] = useState<ConnectionState>('connecting')
95
+ // Incremented on every WebSocket open (initial connect and reconnects).
96
+ // Passed to MobileKeyBar as `connectionKey` so it can reset its scroll-mode
97
+ // badge when a reconnect resets server-side copy-mode state (#1299).
98
+ const [connectionSeq, setConnectionSeq] = useState(0)
99
+
100
+ useEffect(() => {
101
+ if (!sessionId || !containerRef.current) return
102
+
103
+ const term = new XTerm({
104
+ // convertEol: false — tmux sends proper CR+LF sequences; setting this
105
+ // true causes xterm.js to convert the CR to LF as well, producing
106
+ // double newlines (\r\n → \n\n).
107
+ convertEol: false,
108
+ cursorBlink: true,
109
+ fontSize: 13,
110
+ theme: { background: '#0d1117', foreground: '#e6edf3' },
111
+ })
112
+ const fit = new FitAddon()
113
+ term.loadAddon(fit)
114
+ term.open(containerRef.current)
115
+ try {
116
+ fit.fit()
117
+ } catch {
118
+ // container may not have a laid-out size yet (e.g. under test) -- xterm
119
+ // still opens fine, it just keeps its default 80x24 until the next fit.
120
+ }
121
+
122
+ let reconnectAttempt = 0
123
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
124
+ // Timestamp of the most recent `onopen`, or null while not connected.
125
+ // Read (and cleared) in `onclose` to tell a stable connection's drop
126
+ // apart from an accept-then-immediately-fail flap -- see the
127
+ // backoff-reset guard note above the component docstring.
128
+ let openedAt: number | null = null
129
+
130
+ // ── Resize deduplication ────────────────────────────────────────────────
131
+ // Only emit a resize frame when cols or rows actually change. Without
132
+ // this guard, rapid layout events (ResizeObserver + visualViewport +
133
+ // window.resize all firing close together) would send many identical
134
+ // frames and thrash the PTY's TIOCSWINSZ.
135
+ let lastCols = 0
136
+ let lastRows = 0
137
+
138
+ // cols/rows default to term's current values so onopen / scheduleFit
139
+ // paths read from the terminal; the onResize path passes xterm's event
140
+ // args directly (avoids re-reading a stale value after an async hop).
141
+ const sendResize = (ws: WebSocket, cols = term.cols, rows = term.rows) => {
142
+ if (ws.readyState !== WebSocket.OPEN) return
143
+ if (cols === lastCols && rows === lastRows) return
144
+ lastCols = cols
145
+ lastRows = rows
146
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }))
147
+ }
148
+
149
+ // ── Debounced fit scheduler ──────────────────────────────────────────────
150
+ // Coalesces rapid layout events (window resize, ResizeObserver,
151
+ // visualViewport) into a single fit.fit() call 50 ms after the last
152
+ // event. fit.fit() calls term.resize() which fires onResize, which then
153
+ // calls sendResize — so the scheduler doesn't need to call sendResize
154
+ // directly.
155
+ let fitDebounce: ReturnType<typeof setTimeout> | null = null
156
+ const scheduleFit = () => {
157
+ if (fitDebounce) clearTimeout(fitDebounce)
158
+ fitDebounce = setTimeout(() => {
159
+ try {
160
+ fit.fit()
161
+ } catch {
162
+ // layout not ready yet (same situation as the mount-time fit)
163
+ }
164
+ fitDebounce = null
165
+ }, 50)
166
+ }
167
+
168
+ const connect = () => {
169
+ // Opening a connection is always deliberate, so any close that follows
170
+ // is "unintentional" until the ✕ button / unmount sets the flag again.
171
+ // Without this reset, React StrictMode's dev mount→cleanup→mount cycle
172
+ // (cleanup sets the flag true, then a *new* connection is opened) would
173
+ // leave the flag stuck true, so the live connection's onclose below
174
+ // would short-circuit and never surface "Reconnecting…" / "Session
175
+ // ended". Production single-mounts, but the Vite-dev E2E hits this.
176
+ intentionalCloseRef.current = false
177
+ const ws = new WebSocket(terminalWebSocketUrl(sessionId))
178
+ ws.binaryType = 'arraybuffer'
179
+ wsRef.current = ws
180
+
181
+ ws.onopen = () => {
182
+ openedAt = Date.now()
183
+ setState('open')
184
+ // Signal MobileKeyBar to reset its scroll-mode badge: a fresh
185
+ // `tmux attach-session` starts outside copy-mode, so a stale badge
186
+ // from the previous connection would misrepresent the server's state.
187
+ setConnectionSeq((n) => n + 1)
188
+ // Re-fit now that the WS is open: the layout may have settled since
189
+ // the mount-time fit (safe-area insets, MobileKeyBar DOM insertion).
190
+ // This gives us the most accurate terminal size to send as the first
191
+ // resize frame, which the tmux bridge needs to size the session
192
+ // correctly (it can't know the phone's viewport until we tell it).
193
+ try {
194
+ fit.fit()
195
+ } catch {
196
+ // layout not ready (e.g. under test) — term keeps its current size
197
+ }
198
+ // Always send a resize on every open (including reconnects): the
199
+ // server has just accepted a fresh connection and has no size state.
200
+ // Reset dedup so the frame is guaranteed to go out even when the
201
+ // terminal size hasn't changed since the last connection.
202
+ lastCols = 0
203
+ lastRows = 0
204
+ sendResize(ws)
205
+ }
206
+ ws.onclose = (event) => {
207
+ // Ignore closes from a superseded socket: React StrictMode's
208
+ // double-invoke opens ws#1, then cleanup closes it *after* ws#2 has
209
+ // already become the live connection. Only the current connection
210
+ // drives state transitions and reconnect scheduling — otherwise
211
+ // ws#1's late close schedules a spurious reconnect (the #1071
212
+ // reconnect-storm this guard family exists to prevent).
213
+ if (wsRef.current !== ws) return
214
+ if (intentionalCloseRef.current) return
215
+ if (event.code === SESSION_GONE_CODE) {
216
+ // The worker session itself is gone (not just this connection) --
217
+ // a terminal state. Retrying would spin forever against a
218
+ // session_id that will never resolve again.
219
+ setState('ended')
220
+ return
221
+ }
222
+ // Only treat this as "the connection was actually working" -- and
223
+ // so reset the backoff ladder -- if it stayed open long enough to
224
+ // matter. A connection that opened and closed again almost
225
+ // instantly (accept() succeeded, then the attach failed, or
226
+ // anything else killed it right away) keeps climbing the backoff
227
+ // instead of resetting to the base delay every cycle.
228
+ const wasStable =
229
+ openedAt !== null && Date.now() - openedAt >= STABLE_CONNECTION_MS
230
+ openedAt = null
231
+ if (wasStable) reconnectAttempt = 0
232
+ setState('reconnecting')
233
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS)
234
+ reconnectAttempt += 1
235
+ reconnectTimer = setTimeout(connect, delay)
236
+ }
237
+ // The WebSocket spec always fires "close" right after "error", so
238
+ // state transitions + reconnect scheduling live solely in onclose --
239
+ // duplicating them here would double-schedule reconnects.
240
+ ws.onerror = () => {}
241
+ ws.onmessage = (event) => {
242
+ if (typeof event.data === 'string') {
243
+ term.write(event.data)
244
+ } else {
245
+ term.write(new Uint8Array(event.data as ArrayBuffer))
246
+ }
247
+ }
248
+ }
249
+
250
+ connect()
251
+
252
+ const dataDisposable = term.onData((data) => {
253
+ const ws = wsRef.current
254
+ if (ws && ws.readyState === WebSocket.OPEN) {
255
+ ws.send(new TextEncoder().encode(data))
256
+ }
257
+ })
258
+ // Use xterm's event args ({cols, rows}) directly rather than re-reading
259
+ // term.cols/term.rows — avoids any stale-value risk between the resize
260
+ // and our callback.
261
+ const resizeDisposable = term.onResize(({ cols, rows }) => {
262
+ const ws = wsRef.current
263
+ if (ws) sendResize(ws, cols, rows)
264
+ })
265
+
266
+ // ── Layout change listeners ──────────────────────────────────────────────
267
+ // window.resize catches broad viewport changes (desktop browser resize,
268
+ // device orientation change).
269
+ window.addEventListener('resize', scheduleFit)
270
+
271
+ // ResizeObserver on the pane container catches layout shifts that
272
+ // window.resize misses, e.g. the MobileKeyBar growing/shrinking when
273
+ // the content changes height, or safe-area-inset-* adjustments.
274
+ const ro = new ResizeObserver(scheduleFit)
275
+ ro.observe(containerRef.current)
276
+
277
+ // visualViewport fires when the mobile soft keyboard appears or
278
+ // disappears, which shrinks/grows the visual viewport (the area the user
279
+ // actually sees) without necessarily changing window.innerWidth/Height.
280
+ // This is the most reliable hook for phone keyboard events.
281
+ window.visualViewport?.addEventListener('resize', scheduleFit)
282
+
283
+ return () => {
284
+ intentionalCloseRef.current = true
285
+ if (reconnectTimer) clearTimeout(reconnectTimer)
286
+ if (fitDebounce) clearTimeout(fitDebounce)
287
+ window.removeEventListener('resize', scheduleFit)
288
+ window.visualViewport?.removeEventListener('resize', scheduleFit)
289
+ ro.disconnect()
290
+ dataDisposable.dispose()
291
+ resizeDisposable.dispose()
292
+ wsRef.current?.close()
293
+ term.dispose()
294
+ wsRef.current = null
295
+ }
296
+ }, [sessionId])
297
+
298
+ const handleClose = () => {
299
+ intentionalCloseRef.current = true
300
+ wsRef.current?.close()
301
+ navigate(-1)
302
+ }
303
+
304
+ /**
305
+ * Write raw bytes (a JS string, one char per terminal byte) down the same
306
+ * WebSocket path `term.onData` uses for real keystrokes -- the wire format
307
+ * PTY bridge (#1065) expects for input. Used by `MobileKeyBar` (#1070) for
308
+ * its soft keys and line-input submit.
309
+ */
310
+ const sendRaw = (data: string) => {
311
+ const ws = wsRef.current
312
+ if (ws && ws.readyState === WebSocket.OPEN) {
313
+ ws.send(new TextEncoder().encode(data))
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Send a copy-mode control action as a JSON text frame to the bridge
319
+ * (#1299). The bridge routes it to ``tmux send-keys -X`` / ``tmux
320
+ * copy-mode`` server-side, which is prefix- and mode-key-agnostic.
321
+ *
322
+ * Used by `MobileKeyBar`'s Scroll controls via the `onControl` prop.
323
+ * Only sends when the WebSocket is open -- called during user interaction,
324
+ * so an open socket is the normal case; a closed socket just silently
325
+ * drops the frame (same defensive posture as `sendRaw`).
326
+ */
327
+ const sendControl = (action: string) => {
328
+ const ws = wsRef.current
329
+ if (ws && ws.readyState === WebSocket.OPEN) {
330
+ ws.send(JSON.stringify({ type: 'copy-mode', action }))
331
+ }
332
+ }
333
+
334
+ return (
335
+ <div className="flex h-screen flex-col bg-background">
336
+ <header
337
+ className="flex items-center justify-between gap-3 border-b border-border px-4 pb-3"
338
+ style={{ paddingTop: 'calc(env(safe-area-inset-top) + 0.75rem)' }}
339
+ >
340
+ <div className="min-w-0">
341
+ <h1 className="truncate text-sm font-semibold text-foreground">Terminal</h1>
342
+ <p className="truncate font-mono text-xs text-muted-foreground">
343
+ {sessionId} · {STATUS_LABEL[state]}
344
+ </p>
345
+ </div>
346
+ <button
347
+ type="button"
348
+ onClick={handleClose}
349
+ aria-label="Close terminal"
350
+ className="shrink-0 rounded p-2 text-muted-foreground hover:text-foreground"
351
+ >
352
+
353
+ </button>
354
+ </header>
355
+
356
+ {/* Top half: the live pane. Bottom half: the mobile key bar (#1070). */}
357
+ <div className="relative h-[50vh] min-h-0 shrink-0 border-b border-border bg-[#0d1117] px-2 py-2">
358
+ <div ref={containerRef} className="h-full w-full" data-testid="xterm-container" />
359
+ {state === 'ended' && (
360
+ <div
361
+ role="status"
362
+ className="absolute inset-0 flex items-center justify-center bg-[#0d1117]/90 px-6 text-center text-sm text-muted-foreground"
363
+ >
364
+ Session ended — the worker session is no longer available.
365
+ </div>
366
+ )}
367
+ </div>
368
+ <div
369
+ className="min-h-0 flex-1"
370
+ style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
371
+ >
372
+ <MobileKeyBar onSend={sendRaw} onControl={sendControl} connectionKey={connectionSeq} />
373
+ </div>
374
+ </div>
375
+ )
376
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Component tests for ConnectionBadge (#1549) — the honest
3
+ * live/reconnecting/stale-since-<time> indicator. Renders directly against
4
+ * the real ConnectionStatusContext by wrapping with RealtimeProvider's
5
+ * context provider indirectly via a minimal test double: since the context
6
+ * itself isn't exported, these tests drive it through the provider's public
7
+ * surface (RealtimeProvider + a fake EventSource) rather than reaching into
8
+ * internals.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import { act, render, screen, waitFor } from '@testing-library/react'
12
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
13
+ import { RealtimeProvider } from '@/realtime/RealtimeProvider'
14
+ import type { SseHandle } from '@/realtime/connection'
15
+ import { ConnectionBadge } from '../ConnectionBadge'
16
+
17
+ class FakeEventSource implements SseHandle {
18
+ static instances: FakeEventSource[] = []
19
+ url: string
20
+ onopen: ((ev: unknown) => void) | null = null
21
+ onerror: ((ev: unknown) => void) | null = null
22
+ constructor(url: string) {
23
+ this.url = url
24
+ FakeEventSource.instances.push(this)
25
+ }
26
+ addEventListener(): void {}
27
+ close(): void {}
28
+ emitOpen(): void {
29
+ this.onopen?.({})
30
+ }
31
+ emitError(): void {
32
+ this.onerror?.({})
33
+ }
34
+ }
35
+
36
+ function lastInstance(): FakeEventSource {
37
+ const inst = FakeEventSource.instances[FakeEventSource.instances.length - 1]
38
+ if (!inst) throw new Error('no instance')
39
+ return inst
40
+ }
41
+
42
+ function renderBadge() {
43
+ FakeEventSource.instances = []
44
+ return render(
45
+ <QueryClientProvider client={new QueryClient()}>
46
+ <RealtimeProvider
47
+ createEventSource={(url) => new FakeEventSource(url) as unknown as SseHandle}
48
+ backoffScheduleMs={[5]}
49
+ >
50
+ <ConnectionBadge />
51
+ </RealtimeProvider>
52
+ </QueryClientProvider>,
53
+ )
54
+ }
55
+
56
+ describe('ConnectionBadge', () => {
57
+ it('shows "Connecting…" before the first connect', async () => {
58
+ renderBadge()
59
+ await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
60
+ expect(screen.getByRole('status')).toHaveTextContent('Connecting…')
61
+ })
62
+
63
+ it('shows "Live" once the stream opens', async () => {
64
+ renderBadge()
65
+ await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
66
+ act(() => lastInstance().emitOpen())
67
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Live'))
68
+ })
69
+
70
+ it('shows a stale-since time then Reconnecting… after a drop', async () => {
71
+ renderBadge()
72
+ await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
73
+ act(() => lastInstance().emitOpen())
74
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Live'))
75
+
76
+ act(() => lastInstance().emitError())
77
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(/Stale since/))
78
+
79
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Reconnecting…'))
80
+ })
81
+ })