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,800 @@
1
+ /**
2
+ * Detail — per-item detail for a pipeline assignment.
3
+ *
4
+ * Like Home, this is *panel content*, not a screen (#1547): the shell renders
5
+ * it into the detail slot — the third column on wide, the whole screen after a
6
+ * drill-in on narrow — and nothing here branches on viewport. App chrome
7
+ * (wordmark, connection state) moved to the shell, which owns exactly one copy
8
+ * of each now that both panels can be on screen at the same time.
9
+ *
10
+ * Shows the assignment header, test-gate actions, review section (findings +
11
+ * verdict), merge section (gate status + merge / force), optional smoke/unstick
12
+ * actions, and a collapsible diff viewer with syntax highlighting.
13
+ *
14
+ * All write operations go through POST /api/pipeline/action; the pipeline data
15
+ * is read from the same ['pipeline'] React-Query cache used by the Home screen
16
+ * so updates are reflected immediately on both screens.
17
+ *
18
+ * Route (#1548): `/pipeline/:repo/:issue[/:tab]`, not `assignment_id` — an
19
+ * issue outlives any one assignment (work, then a fix, then another fix are
20
+ * separate `assignment_id`s for the same issue), and `repo` + `issue` is what
21
+ * a link pasted into a GitHub comment or Slack actually means by "that
22
+ * issue". `:tab` round-trips (so `/pipeline/repo/42/log` is a valid, stable
23
+ * address) but doesn't change what renders yet — the `Overview / Issue / Log
24
+ * / Findings / Summary` tab set is M-W2 scope (`docs/WEB_CONTROL_CENTER.md`);
25
+ * this component still renders its one flowing view regardless of `tab`.
26
+ */
27
+ import { useCallback, useState } from 'react'
28
+ import { useNavigate, useParams } from 'react-router-dom'
29
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
30
+ import {
31
+ fetchPipeline,
32
+ fetchDiff,
33
+ pipelineAction,
34
+ type PipelineView,
35
+ type PipelineActionRequest,
36
+ } from '@/api/client'
37
+ import { cn } from '@/lib/utils'
38
+ import { paths } from '@/routes/paths'
39
+
40
+ // ── Toast ─────────────────────────────────────────────────────────────────────
41
+
42
+ interface Toast {
43
+ id: string
44
+ message: string
45
+ type: 'success' | 'error'
46
+ }
47
+
48
+ interface ToastListProps {
49
+ toasts: Toast[]
50
+ }
51
+
52
+ function ToastList({ toasts }: ToastListProps) {
53
+ if (toasts.length === 0) return null
54
+ return (
55
+ <div
56
+ role="status"
57
+ aria-live="polite"
58
+ aria-label="Notifications"
59
+ className="fixed bottom-6 left-0 right-0 z-50 flex flex-col items-center gap-2 px-4 pointer-events-none"
60
+ >
61
+ {toasts.map((t) => (
62
+ <div
63
+ key={t.id}
64
+ className={cn(
65
+ 'w-full max-w-sm rounded-lg px-4 py-3 text-sm font-medium shadow-lg',
66
+ t.type === 'success'
67
+ ? 'bg-green-700 text-white'
68
+ : 'bg-destructive text-destructive-foreground',
69
+ )}
70
+ >
71
+ {t.message}
72
+ </div>
73
+ ))}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ // ── Fail dialog ───────────────────────────────────────────────────────────────
79
+
80
+ interface FailDialogProps {
81
+ reason: string
82
+ onReasonChange: (v: string) => void
83
+ onConfirm: () => void
84
+ onCancel: () => void
85
+ disabled: boolean
86
+ }
87
+
88
+ function FailDialog({ reason, onReasonChange, onConfirm, onCancel, disabled }: FailDialogProps) {
89
+ return (
90
+ <div
91
+ role="dialog"
92
+ aria-modal="true"
93
+ aria-label="Record test failure"
94
+ className="fixed inset-0 z-40 flex items-end justify-center bg-black/60 px-4 pb-8"
95
+ >
96
+ <div className="w-full max-w-sm rounded-xl border border-border bg-card p-4 shadow-xl">
97
+ <h2 className="mb-3 text-sm font-semibold text-card-foreground">Record test failure</h2>
98
+ <label className="mb-1 block text-xs text-muted-foreground" htmlFor="fail-reason">
99
+ Reason (optional)
100
+ </label>
101
+ <textarea
102
+ id="fail-reason"
103
+ value={reason}
104
+ onChange={(e) => onReasonChange(e.target.value)}
105
+ rows={3}
106
+ placeholder="What failed?"
107
+ className="mb-3 w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
108
+ />
109
+ <div className="flex gap-2">
110
+ <button
111
+ type="button"
112
+ onClick={onCancel}
113
+ disabled={disabled}
114
+ className="flex-1 rounded-lg border border-border py-2 text-sm text-muted-foreground disabled:opacity-50"
115
+ >
116
+ Cancel
117
+ </button>
118
+ <button
119
+ type="button"
120
+ onClick={onConfirm}
121
+ disabled={disabled}
122
+ className="flex-1 rounded-lg bg-destructive py-2 text-sm font-medium text-destructive-foreground disabled:opacity-50"
123
+ >
124
+ {disabled ? 'Recording…' : 'Confirm Fail'}
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ // ── Diff viewer ───────────────────────────────────────────────────────────────
133
+
134
+ interface DiffViewerProps {
135
+ assignmentId: string
136
+ expanded: boolean
137
+ onToggle: () => void
138
+ }
139
+
140
+ function DiffViewer({ assignmentId, expanded, onToggle }: DiffViewerProps) {
141
+ const { data, isLoading, isError } = useQuery({
142
+ queryKey: ['diff', assignmentId],
143
+ queryFn: () => fetchDiff(assignmentId),
144
+ enabled: expanded,
145
+ staleTime: 60_000,
146
+ })
147
+
148
+ return (
149
+ <section className="mt-4" aria-label="Diff viewer">
150
+ <button
151
+ type="button"
152
+ onClick={onToggle}
153
+ aria-expanded={expanded}
154
+ className="flex w-full items-center justify-between rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium text-card-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
155
+ >
156
+ <span>Diff</span>
157
+ <span className="text-muted-foreground" aria-hidden="true">
158
+ {expanded ? '▲' : '▼'}
159
+ </span>
160
+ </button>
161
+
162
+ {expanded && (
163
+ <div className="mt-1 rounded-lg border border-border bg-black/30 p-3">
164
+ {isLoading && (
165
+ <p className="text-xs text-muted-foreground">Loading diff…</p>
166
+ )}
167
+ {isError && (
168
+ <p className="text-xs text-destructive">Failed to load diff</p>
169
+ )}
170
+ {data && (
171
+ <>
172
+ <pre className="overflow-x-auto text-xs leading-5" aria-label="Code diff">
173
+ {data.diff.split('\n').map((line, i) => (
174
+ <span
175
+ key={i}
176
+ className={cn(
177
+ 'block',
178
+ line.startsWith('+++') || line.startsWith('---')
179
+ ? 'text-muted-foreground'
180
+ : line.startsWith('+')
181
+ ? 'text-green-400'
182
+ : line.startsWith('-')
183
+ ? 'text-red-400'
184
+ : line.startsWith('@@')
185
+ ? 'text-blue-400'
186
+ : 'text-foreground',
187
+ )}
188
+ >
189
+ {line || ' '}
190
+ </span>
191
+ ))}
192
+ </pre>
193
+ <p className="mt-2 text-right text-xs text-muted-foreground">
194
+ source: {data.source}
195
+ </p>
196
+ </>
197
+ )}
198
+ </div>
199
+ )}
200
+ </section>
201
+ )
202
+ }
203
+
204
+ // ── Stage display helpers (mirrors PipelineCard) ──────────────────────────────
205
+
206
+ const STAGE_LABEL: Record<string, string> = {
207
+ coding: 'work',
208
+ smoke: 'test',
209
+ review: 'review',
210
+ merge: 'merge',
211
+ }
212
+
213
+ const FAILED_STAGES = new Set(['failed', 'review_failed', 'smoke_failed'])
214
+
215
+ /**
216
+ * The detail's own content column (#1547).
217
+ *
218
+ * `max-w-3xl` rather than the old `max-w-lg`: this is now the *detail slot* of
219
+ * the shell, which on a wide viewport is whatever is left after the rail and
220
+ * the list — a 512px column stranded in the middle of 1000px of empty ground
221
+ * looked like a rendering bug. On a phone every candidate max-width exceeds
222
+ * the viewport, so `px-4` is what actually sets the measure there and the
223
+ * layout is byte-for-byte what it was.
224
+ */
225
+ const detailShellClass = 'mx-auto w-full max-w-3xl px-4 py-5 md:px-6'
226
+
227
+ function stageStatus(currentStage: string): { label: string; className: string } {
228
+ const RUNNING = new Set(['coding', 'review_running', 'smoke_running', 'merging'])
229
+ if (RUNNING.has(currentStage)) {
230
+ return { label: 'running', className: 'bg-primary text-primary-foreground' }
231
+ }
232
+ if (FAILED_STAGES.has(currentStage)) {
233
+ return { label: 'failed', className: 'bg-destructive text-destructive-foreground' }
234
+ }
235
+ switch (currentStage) {
236
+ case 'merged': return { label: 'merged', className: 'bg-green-700 text-white' }
237
+ case 'merge_ready': return { label: 'mergeable', className: 'bg-yellow-600 text-black' }
238
+ case 'review_done': return { label: 'review ✓', className: 'bg-yellow-600 text-black' }
239
+ case 'smoke_passed':return { label: 'test ✓', className: 'bg-yellow-600 text-black' }
240
+ case 'done': return { label: 'work done', className: 'bg-yellow-600 text-black' }
241
+ default: return { label: currentStage.replace(/_/g, ' '), className: 'bg-secondary text-secondary-foreground' }
242
+ }
243
+ }
244
+
245
+ // ── Main component ────────────────────────────────────────────────────────────
246
+
247
+ export default function Detail() {
248
+ const { repo, issue } = useParams<{ repo: string; issue: string }>()
249
+ const navigate = useNavigate()
250
+ const queryClient = useQueryClient()
251
+
252
+ // Pipeline data — shared cache key with Home screen. #1549: no
253
+ // refetchInterval -- RealtimeProvider invalidates ['pipeline'] on the
254
+ // relevant SSE events, so this screen updates the same way Home does.
255
+ const { data: pipeline, isLoading, isError } = useQuery({
256
+ queryKey: ['pipeline'],
257
+ queryFn: fetchPipeline,
258
+ })
259
+
260
+ // Keyed on repo + issue_number (#1548), not assignment_id — see the route
261
+ // comment above. `issue` arrives as a route-param string; PipelineView's
262
+ // `issue_number` is a number, so the comparison converts rather than the
263
+ // reverse (a leading-zero or malformed URL segment must fail the match,
264
+ // not silently coerce to some other issue's number).
265
+ const view: PipelineView | null =
266
+ pipeline?.find((v) => v.repo_name === repo && String(v.issue_number) === issue) ?? null
267
+
268
+ // `navigate(-1)` is a silent no-op when there is no in-app history entry to
269
+ // pop -- exactly what happens on a cold deep-link load (#1551): a user
270
+ // follows a shared `/pipeline/repo/42` link (a bookmark, a notification, a
271
+ // pasted Slack URL -- precisely what #1548's route shape exists to make
272
+ // possible) directly into Detail, with nothing before it in the tab's
273
+ // history, and Back does nothing, stranding them here. React Router's
274
+ // browser history stamps `window.history.state.idx` with this tab's own
275
+ // navigation-stack position (0 at the very first entry it ever
276
+ // pushed/replaced); `idx > 0` means there really is a previous in-app entry
277
+ // to go back to. Falling back to the pipeline list route (rather than
278
+ // leaving the button a no-op, or leaving the SPA via `window.history.back()`
279
+ // straight to whatever opened the tab) matches what Back already means on
280
+ // wide -- "clear the detail selection, stay on the list".
281
+ const handleBack = useCallback(() => {
282
+ const idx = (window.history.state as { idx?: number } | null)?.idx
283
+ if (typeof idx === 'number' && idx > 0) navigate(-1)
284
+ else navigate(paths.pipeline())
285
+ }, [navigate])
286
+
287
+ // UI state
288
+ const [diffExpanded, setDiffExpanded] = useState(false)
289
+ const [inFlight, setInFlight] = useState<string | null>(null)
290
+ const [toasts, setToasts] = useState<Toast[]>([])
291
+ const [failDialogOpen, setFailDialogOpen] = useState(false)
292
+ const [failReason, setFailReason] = useState('')
293
+ const [forceMode, setForceMode] = useState(false)
294
+
295
+ // Toast helpers
296
+ const addToast = useCallback((message: string, type: 'success' | 'error') => {
297
+ const toastId = String(Date.now())
298
+ setToasts((prev) => [...prev, { id: toastId, message, type }])
299
+ setTimeout(() => {
300
+ setToasts((prev) => prev.filter((t) => t.id !== toastId))
301
+ }, 4_000)
302
+ }, [])
303
+
304
+ // Generic action handler — sets in-flight, calls API, shows toast, invalidates cache
305
+ const doAction = useCallback(
306
+ async (req: PipelineActionRequest, key: string, successMsg?: string) => {
307
+ if (inFlight) return
308
+ setInFlight(key)
309
+ try {
310
+ const result = await pipelineAction(req)
311
+ if (result.ok) {
312
+ addToast(successMsg ?? 'Done', 'success')
313
+ void queryClient.invalidateQueries({ queryKey: ['pipeline'] })
314
+ } else {
315
+ addToast(result.error ?? 'Action failed', 'error')
316
+ }
317
+ } catch (e) {
318
+ addToast(e instanceof Error ? e.message : 'Action failed', 'error')
319
+ } finally {
320
+ setInFlight(null)
321
+ }
322
+ },
323
+ [inFlight, addToast, queryClient],
324
+ )
325
+
326
+ // Gate availability helper
327
+ const hasGate = (action: string): boolean =>
328
+ view?.available_gates.some((g) => g.action === action) ?? false
329
+
330
+ // Shared button base class
331
+ const btnBase =
332
+ 'rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
333
+
334
+ // ── Loading / error / not-found ───────────────────────────────────────────
335
+
336
+ if (isLoading) {
337
+ return (
338
+ <div className={detailShellClass}>
339
+ <p className="py-12 text-center text-sm text-muted-foreground">Loading…</p>
340
+ </div>
341
+ )
342
+ }
343
+
344
+ if (isError) {
345
+ return (
346
+ <div className={detailShellClass}>
347
+ <p className="py-12 text-center text-sm text-destructive">Failed to load pipeline</p>
348
+ </div>
349
+ )
350
+ }
351
+
352
+ if (!view) {
353
+ return (
354
+ <div className={detailShellClass}>
355
+ <header className="mb-6 flex items-center gap-3">
356
+ <button
357
+ type="button"
358
+ onClick={handleBack}
359
+ className="rounded p-1 text-muted-foreground hover:text-foreground"
360
+ aria-label="Back"
361
+ >
362
+
363
+ </button>
364
+ <h1 className="text-step-1 font-semibold text-foreground">Not found</h1>
365
+ </header>
366
+ <p className="text-sm text-muted-foreground">
367
+ Issue{' '}
368
+ <span className="font-mono">
369
+ {repo}#{issue}
370
+ </span>{' '}
371
+ not found in the pipeline.
372
+ </p>
373
+ </div>
374
+ )
375
+ }
376
+
377
+ const { label: statusLabel, className: statusClass } = stageStatus(view.current_stage)
378
+
379
+ return (
380
+ <div className={cn(detailShellClass, 'pb-24')}>
381
+ <ToastList toasts={toasts} />
382
+
383
+ {failDialogOpen && (
384
+ <FailDialog
385
+ reason={failReason}
386
+ onReasonChange={setFailReason}
387
+ onConfirm={() => {
388
+ const req: PipelineActionRequest = {
389
+ assignment_id: view.assignment_id,
390
+ action: 'test-verdict',
391
+ verdict: 'failed',
392
+ ...(failReason.trim() ? { reason: failReason.trim() } : {}),
393
+ }
394
+ void doAction(req, 'test-fail', 'Test marked failed').then(() => {
395
+ setFailDialogOpen(false)
396
+ setFailReason('')
397
+ })
398
+ }}
399
+ onCancel={() => {
400
+ setFailDialogOpen(false)
401
+ setFailReason('')
402
+ }}
403
+ disabled={inFlight === 'test-fail'}
404
+ />
405
+ )}
406
+
407
+ {/* ── Header ────────────────────────────────────────────────────────── */}
408
+ <header className="mb-5">
409
+ <div className="flex items-start gap-2">
410
+ {/* Back is the phone's drill-out and the wide layout's "clear the
411
+ selection" — `handleBack` pops in-app history when there is any
412
+ (the previous entry is the list at `/` either way), and falls
413
+ back to the pipeline list route on a cold deep-link load, where
414
+ there is none (#1551). */}
415
+ <button
416
+ type="button"
417
+ onClick={handleBack}
418
+ aria-label="Back"
419
+ 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"
420
+ >
421
+
422
+ </button>
423
+ <div className="min-w-0 flex-1">
424
+ <p className="text-xs text-muted-foreground">
425
+ <span className="font-mono">{view.repo_name}</span>{' '}
426
+ <span className="font-mono">#{view.issue_number}</span>
427
+ {' · '}
428
+ <span className="font-mono">{view.machine_name}</span>
429
+ </p>
430
+ <h2 className="mt-0.5 text-base font-semibold text-foreground">
431
+ {view.issue_title}
432
+ </h2>
433
+ </div>
434
+ <span
435
+ className={cn(
436
+ 'shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold',
437
+ statusClass,
438
+ )}
439
+ >
440
+ {statusLabel}
441
+ </span>
442
+ </div>
443
+
444
+ {/* Stage chips */}
445
+ <div className="mt-3 flex flex-wrap gap-1.5">
446
+ {view.stages.map((stage) => {
447
+ const base = 'rounded px-1.5 py-0.5 text-xs font-medium'
448
+ let cls: string
449
+ if (stage.is_current) {
450
+ cls = FAILED_STAGES.has(view.current_stage)
451
+ ? cn(base, 'bg-destructive text-destructive-foreground')
452
+ : cn(base, 'bg-primary text-primary-foreground')
453
+ } else if (stage.status === 'completed') {
454
+ cls = cn(base, 'bg-green-700 text-white')
455
+ } else if (stage.status === 'skipped') {
456
+ cls = cn(base, 'border border-border text-muted-foreground opacity-40')
457
+ } else {
458
+ cls = cn(base, 'border border-border text-muted-foreground')
459
+ }
460
+ return (
461
+ <span key={stage.name} className={cls}>
462
+ {STAGE_LABEL[stage.name] ?? stage.name}
463
+ </span>
464
+ )
465
+ })}
466
+ </div>
467
+ </header>
468
+
469
+ {/* ── Test gate ─────────────────────────────────────────────────────── */}
470
+ {(hasGate('test-verdict') || view.test_verdict != null) && (
471
+ <section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Test gate">
472
+ <h3 className="mb-3 text-sm font-semibold text-card-foreground">Test</h3>
473
+
474
+ {view.test_verdict && (
475
+ <p
476
+ className={cn(
477
+ 'mb-3 text-sm font-medium',
478
+ view.test_verdict === 'passed' ? 'text-green-400' : 'text-destructive',
479
+ )}
480
+ >
481
+ Verdict: {view.test_verdict}
482
+ </p>
483
+ )}
484
+
485
+ {hasGate('test-verdict') && (
486
+ <div className="flex gap-2">
487
+ <button
488
+ type="button"
489
+ onClick={() =>
490
+ doAction(
491
+ { assignment_id: view.assignment_id, action: 'test-verdict', verdict: 'passed' },
492
+ 'test-pass',
493
+ 'Test marked passed',
494
+ )
495
+ }
496
+ disabled={!!inFlight}
497
+ className={cn(btnBase, 'flex-1 bg-green-700 text-white')}
498
+ >
499
+ {inFlight === 'test-pass' ? 'Recording…' : 'Pass'}
500
+ </button>
501
+ <button
502
+ type="button"
503
+ onClick={() => setFailDialogOpen(true)}
504
+ disabled={!!inFlight}
505
+ className={cn(btnBase, 'flex-1 bg-destructive text-destructive-foreground')}
506
+ >
507
+ Fail
508
+ </button>
509
+ </div>
510
+ )}
511
+
512
+ {/* Dispatch-fix offered when test failed */}
513
+ {hasGate('dispatch_fix') && view.test_verdict === 'failed' && (
514
+ <button
515
+ type="button"
516
+ onClick={() =>
517
+ doAction(
518
+ { assignment_id: view.assignment_id, action: 'dispatch_fix' },
519
+ 'dispatch-fix',
520
+ 'Fix dispatched',
521
+ )
522
+ }
523
+ disabled={!!inFlight}
524
+ className={cn(btnBase, 'mt-3 w-full border border-border text-foreground')}
525
+ >
526
+ {inFlight === 'dispatch-fix' ? 'Dispatching…' : 'Dispatch Fix'}
527
+ </button>
528
+ )}
529
+ </section>
530
+ )}
531
+
532
+ {/* ── Dispatch review ───────────────────────────────────────────────── */}
533
+ {hasGate('dispatch_review') && (
534
+ <section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Review">
535
+ <h3 className="mb-3 text-sm font-semibold text-card-foreground">Review</h3>
536
+ <button
537
+ type="button"
538
+ onClick={() =>
539
+ doAction(
540
+ { assignment_id: view.assignment_id, action: 'dispatch_review' },
541
+ 'dispatch-review',
542
+ 'Review dispatched',
543
+ )
544
+ }
545
+ disabled={!!inFlight}
546
+ className={cn(btnBase, 'w-full bg-primary text-primary-foreground')}
547
+ >
548
+ {inFlight === 'dispatch-review' ? 'Dispatching…' : 'Start Review'}
549
+ </button>
550
+ </section>
551
+ )}
552
+
553
+ {/* ── Review findings + verdict ──────────────────────────────────────── */}
554
+ {!hasGate('dispatch_review') &&
555
+ (view.review_findings_body !== null ||
556
+ view.review_verdict !== null ||
557
+ hasGate('record-review-verdict') ||
558
+ hasGate('post_findings')) && (
559
+ <section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Review">
560
+ <h3 className="mb-3 text-sm font-semibold text-card-foreground">Review</h3>
561
+
562
+ {view.review_verdict && (
563
+ <p
564
+ className={cn(
565
+ 'mb-3 text-sm font-medium',
566
+ view.review_verdict === 'approve' ? 'text-green-400' : 'text-yellow-500',
567
+ )}
568
+ >
569
+ Verdict:{' '}
570
+ {view.review_verdict === 'approve' ? 'Approved' : 'Changes requested'}
571
+ </p>
572
+ )}
573
+
574
+ {view.review_findings_body && (
575
+ <div className="mb-3">
576
+ <p className="mb-1 text-xs text-muted-foreground">Findings</p>
577
+ <pre className="max-h-64 overflow-y-auto rounded border border-border bg-black/30 px-3 py-2 text-xs leading-5 text-foreground whitespace-pre-wrap break-words">
578
+ {view.review_findings_body}
579
+ </pre>
580
+ </div>
581
+ )}
582
+
583
+ {view.review_findings_pending && (
584
+ <div className="mb-3 flex items-center justify-between gap-2">
585
+ <p className="text-xs text-yellow-500">⚠ Findings not yet posted to GitHub</p>
586
+ {hasGate('post_findings') && (
587
+ <button
588
+ type="button"
589
+ onClick={() =>
590
+ doAction(
591
+ { assignment_id: view.assignment_id, action: 'post_findings' },
592
+ 'post-findings',
593
+ 'Findings posted',
594
+ )
595
+ }
596
+ disabled={!!inFlight}
597
+ className="rounded border border-yellow-600 px-2 py-1 text-xs text-yellow-500 disabled:opacity-50"
598
+ >
599
+ {inFlight === 'post-findings' ? 'Posting…' : 'Post'}
600
+ </button>
601
+ )}
602
+ </div>
603
+ )}
604
+
605
+ {hasGate('record-review-verdict') && (
606
+ <div className="flex gap-2">
607
+ <button
608
+ type="button"
609
+ onClick={() =>
610
+ doAction(
611
+ {
612
+ assignment_id: view.assignment_id,
613
+ action: 'record-review-verdict',
614
+ verdict: 'approve',
615
+ },
616
+ 'approve',
617
+ 'Review approved',
618
+ )
619
+ }
620
+ disabled={!!inFlight}
621
+ className={cn(btnBase, 'flex-1 bg-green-700 text-white')}
622
+ >
623
+ {inFlight === 'approve' ? 'Approving…' : 'Approve'}
624
+ </button>
625
+ <button
626
+ type="button"
627
+ onClick={() =>
628
+ doAction(
629
+ { assignment_id: view.assignment_id, action: 'dispatch_fix' },
630
+ 'request-changes',
631
+ 'Fix dispatched',
632
+ )
633
+ }
634
+ disabled={!!inFlight}
635
+ className={cn(btnBase, 'flex-1 bg-destructive text-destructive-foreground')}
636
+ >
637
+ {inFlight === 'request-changes' ? 'Dispatching…' : 'Request Changes'}
638
+ </button>
639
+ </div>
640
+ )}
641
+ </section>
642
+ )}
643
+
644
+ {/* ── Merge ─────────────────────────────────────────────────────────── */}
645
+ {(hasGate('merge') ||
646
+ hasGate('enqueue') ||
647
+ ['merge_ready', 'merging', 'merged'].includes(view.current_stage)) && (
648
+ <section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Merge">
649
+ <h3 className="mb-3 text-sm font-semibold text-card-foreground">Merge</h3>
650
+
651
+ {/* Gate status list */}
652
+ <div className="mb-3 space-y-1.5">
653
+ {view.stages.map((stage) => (
654
+ <div key={stage.name} className="flex items-center gap-2 text-xs">
655
+ <span
656
+ className={cn(
657
+ 'h-1.5 w-1.5 shrink-0 rounded-full',
658
+ stage.status === 'completed' || stage.status === 'skipped'
659
+ ? 'bg-green-500'
660
+ : stage.is_current
661
+ ? 'bg-primary'
662
+ : 'bg-border',
663
+ )}
664
+ />
665
+ <span className="text-muted-foreground">{STAGE_LABEL[stage.name] ?? stage.name}</span>
666
+ <span
667
+ className={cn(
668
+ stage.status === 'completed' || stage.status === 'skipped'
669
+ ? 'text-green-400'
670
+ : stage.is_current
671
+ ? 'text-primary'
672
+ : 'text-muted-foreground',
673
+ )}
674
+ >
675
+ {stage.status}
676
+ </span>
677
+ </div>
678
+ ))}
679
+ </div>
680
+
681
+ {view.current_stage === 'merged' ? (
682
+ <p className="text-sm font-medium text-green-400">✓ Merged</p>
683
+ ) : (
684
+ <>
685
+ {hasGate('merge') && (
686
+ <label className="mb-3 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
687
+ <input
688
+ type="checkbox"
689
+ checked={forceMode}
690
+ onChange={(e) => setForceMode(e.target.checked)}
691
+ className="h-3.5 w-3.5"
692
+ />
693
+ Force (skip CI / review checks)
694
+ </label>
695
+ )}
696
+
697
+ <div className="flex gap-2">
698
+ {hasGate('enqueue') && (
699
+ <button
700
+ type="button"
701
+ onClick={() =>
702
+ doAction(
703
+ { assignment_id: view.assignment_id, action: 'enqueue' },
704
+ 'enqueue',
705
+ 'Added to merge queue',
706
+ )
707
+ }
708
+ disabled={!!inFlight}
709
+ className={cn(btnBase, 'flex-1 border border-border text-foreground')}
710
+ >
711
+ {inFlight === 'enqueue' ? 'Queuing…' : 'Enqueue'}
712
+ </button>
713
+ )}
714
+ {hasGate('merge') && (
715
+ <button
716
+ type="button"
717
+ onClick={() =>
718
+ doAction(
719
+ {
720
+ assignment_id: view.assignment_id,
721
+ action: 'merge',
722
+ ...(forceMode ? { force: true } : {}),
723
+ },
724
+ 'merge',
725
+ 'Merged successfully',
726
+ )
727
+ }
728
+ disabled={!!inFlight}
729
+ className={cn(
730
+ btnBase,
731
+ 'flex-1',
732
+ forceMode
733
+ ? 'bg-yellow-600 text-black'
734
+ : 'bg-primary text-primary-foreground',
735
+ )}
736
+ >
737
+ {inFlight === 'merge'
738
+ ? 'Merging…'
739
+ : forceMode
740
+ ? 'Force Merge'
741
+ : 'Merge'}
742
+ </button>
743
+ )}
744
+ </div>
745
+ </>
746
+ )}
747
+ </section>
748
+ )}
749
+
750
+ {/* ── Dispatch smoke test ────────────────────────────────────────────── */}
751
+ {hasGate('dispatch_smoke') && (
752
+ <section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Smoke test">
753
+ <h3 className="mb-3 text-sm font-semibold text-card-foreground">Smoke Test</h3>
754
+ <button
755
+ type="button"
756
+ onClick={() =>
757
+ doAction(
758
+ { assignment_id: view.assignment_id, action: 'dispatch_smoke' },
759
+ 'dispatch-smoke',
760
+ 'Smoke test dispatched',
761
+ )
762
+ }
763
+ disabled={!!inFlight}
764
+ className={cn(btnBase, 'w-full bg-primary text-primary-foreground')}
765
+ >
766
+ {inFlight === 'dispatch-smoke' ? 'Dispatching…' : 'Start Smoke Test'}
767
+ </button>
768
+ </section>
769
+ )}
770
+
771
+ {/* ── Unstick ───────────────────────────────────────────────────────── */}
772
+ {hasGate('unstick') && (
773
+ <section className="mb-4 rounded-lg border border-border bg-card p-4" aria-label="Stuck assignment">
774
+ <h3 className="mb-3 text-sm font-semibold text-card-foreground">Stuck</h3>
775
+ <button
776
+ type="button"
777
+ onClick={() =>
778
+ doAction(
779
+ { assignment_id: view.assignment_id, action: 'unstick' },
780
+ 'unstick',
781
+ 'Assignment cancelled',
782
+ )
783
+ }
784
+ disabled={!!inFlight}
785
+ className={cn(btnBase, 'w-full border border-destructive text-destructive')}
786
+ >
787
+ {inFlight === 'unstick' ? 'Cancelling…' : 'Cancel (unstick)'}
788
+ </button>
789
+ </section>
790
+ )}
791
+
792
+ {/* ── Diff viewer ───────────────────────────────────────────────────── */}
793
+ <DiffViewer
794
+ assignmentId={view.assignment_id}
795
+ expanded={diffExpanded}
796
+ onToggle={() => setDiffExpanded((v) => !v)}
797
+ />
798
+ </div>
799
+ )
800
+ }