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,335 @@
1
+ /**
2
+ * Activity rail (#1547) — the coord-tui `SidebarView` twin, and the *same*
3
+ * component at every viewport.
4
+ *
5
+ * Three presentations, one component and one item list (`railItems.ts`):
6
+ * wide 216px labelled column, grouped, with counts and a rail foot.
7
+ * collapsed 60px icon strip (labels, counts, group headings and the
8
+ * wordmark all drop out; icons centre).
9
+ * narrow a bottom row of icon-over-label tabs. Only `ready` entries
10
+ * appear — a phone's bottom nav is thumb real estate, and a
11
+ * dimmed un-tappable "soon" item there is noise, not a progress
12
+ * bar. On wide there is room for the whole programme, so the
13
+ * whole programme shows.
14
+ *
15
+ * Matches docs/mocks/web/pipeline-wide.html: the selected item takes the
16
+ * accent wash plus a 3px accent bar on its leading edge, `soon` items sit at
17
+ * ~42% opacity behind a `soon` pill, and counts are mono (a value the machine
18
+ * owns) — amber-washed when the count is something needing a human.
19
+ */
20
+ import {
21
+ ChevronLeft,
22
+ ChevronRight,
23
+ Moon,
24
+ PanelLeftClose,
25
+ PanelLeftOpen,
26
+ Settings2,
27
+ Sun,
28
+ } from 'lucide-react'
29
+
30
+ import { cn } from '@/lib/utils'
31
+ import { useTheme } from '@/components/ui/theme-provider'
32
+ import type { ShellMode } from './breakpoints'
33
+ import { RAIL_GROUPS, type RailItem } from './railItems'
34
+ import type { ShellView } from './shellState'
35
+
36
+ export interface ActivityRailProps {
37
+ mode: ShellMode
38
+ collapsed: boolean
39
+ /** `null` when the current URL doesn't belong to any rail view (#1548) —
40
+ * e.g. a genuinely unknown route. Nothing in the rail lights up rather
41
+ * than falling back to a view that isn't actually selected. */
42
+ view: ShellView | null
43
+ onSelect: (view: ShellView) => void
44
+ onToggleCollapsed: () => void
45
+ /** Wide only: whether the list column is minimised away. */
46
+ listCollapsed?: boolean
47
+ onToggleList?: () => void
48
+ /** Badge counts by view; absent or 0 renders no badge. */
49
+ counts?: Partial<Record<ShellView, number>>
50
+ /** Views whose count should read as "needs a human" (amber, not grey). */
51
+ attentionViews?: ReadonlySet<ShellView>
52
+ /** F6 region registration — see `useRegionFocus`. */
53
+ regionRef?: (el: HTMLElement | null) => void
54
+ /** True while F6 last landed here; draws the region ring. */
55
+ regionFocused?: boolean
56
+ }
57
+
58
+ const navItemBase =
59
+ 'relative flex w-full items-center rounded-sm text-left text-step-0 ' +
60
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0'
61
+
62
+ function NavItem({
63
+ item,
64
+ mode,
65
+ collapsed,
66
+ selected,
67
+ count,
68
+ attention,
69
+ onSelect,
70
+ }: {
71
+ item: RailItem
72
+ mode: ShellMode
73
+ collapsed: boolean
74
+ selected: boolean
75
+ count?: number
76
+ attention?: boolean
77
+ onSelect: (view: ShellView) => void
78
+ }) {
79
+ const soon = item.status === 'soon'
80
+ const Icon = item.icon
81
+ const narrow = mode === 'narrow'
82
+
83
+ return (
84
+ <button
85
+ type="button"
86
+ // A `soon` entry is a signpost, not a control: `aria-disabled` (rather
87
+ // than `disabled`) keeps it reachable by a screen reader reading the
88
+ // rail as a table of contents, while the no-op handler and
89
+ // `cursor-default` keep it inert to a mouse.
90
+ aria-disabled={soon || undefined}
91
+ aria-current={selected ? 'page' : undefined}
92
+ title={item.hint ?? item.label}
93
+ onClick={soon ? undefined : () => onSelect(item.id)}
94
+ className={cn(
95
+ navItemBase,
96
+ narrow
97
+ ? 'min-w-[60px] flex-1 flex-col justify-center gap-0.5 px-1 py-1.5'
98
+ : 'gap-2.5 px-2 py-1.5',
99
+ collapsed && !narrow && 'justify-center px-0 py-2',
100
+ soon
101
+ ? 'cursor-default text-muted-foreground opacity-40 hover:opacity-70'
102
+ : 'text-muted-foreground hover:bg-surface-2 hover:text-foreground',
103
+ selected && !narrow && 'bg-brand-wash text-brand hover:bg-brand-wash hover:text-brand',
104
+ selected && narrow && 'text-brand',
105
+ )}
106
+ >
107
+ {/* Leading accent bar on the selected row — mock's `.nav-item::before`.
108
+ Suppressed on narrow, where the mock drops it too. */}
109
+ {selected && !narrow && (
110
+ <span
111
+ aria-hidden="true"
112
+ className="absolute -left-2 top-1/2 h-[17px] w-[3px] -translate-y-1/2 rounded-r-[3px] bg-brand"
113
+ />
114
+ )}
115
+ <Icon className={cn('shrink-0', narrow ? 'h-[19px] w-[19px]' : 'h-[17px] w-[17px]')} aria-hidden="true" />
116
+ {(!collapsed || narrow) && (
117
+ <span
118
+ className={cn(
119
+ 'overflow-hidden text-ellipsis whitespace-nowrap',
120
+ narrow ? 'text-[.6rem] leading-tight' : 'min-w-0 flex-1',
121
+ )}
122
+ >
123
+ {item.label}
124
+ </span>
125
+ )}
126
+ {!collapsed && !narrow && soon && (
127
+ <span className="shrink-0 rounded-full border border-line-strong px-1 text-[.58rem] uppercase tracking-[.06em] text-faint">
128
+ soon
129
+ </span>
130
+ )}
131
+ {!collapsed && !narrow && !soon && count != null && count > 0 && (
132
+ <span
133
+ className={cn(
134
+ 'rounded-full px-1.5 font-mono text-[.7rem]',
135
+ attention ? 'bg-attn-wash text-attn' : 'bg-surface-2 text-faint',
136
+ )}
137
+ >
138
+ {count}
139
+ </span>
140
+ )}
141
+ </button>
142
+ )
143
+ }
144
+
145
+ export function ActivityRail({
146
+ mode,
147
+ collapsed,
148
+ view,
149
+ onSelect,
150
+ onToggleCollapsed,
151
+ listCollapsed,
152
+ onToggleList,
153
+ counts,
154
+ attentionViews,
155
+ regionRef,
156
+ regionFocused,
157
+ }: ActivityRailProps) {
158
+ const { theme, toggleTheme } = useTheme()
159
+ const narrow = mode === 'narrow'
160
+ // On medium the rail is *always* the icon strip: there isn't room for both
161
+ // a labelled rail and a usable content column, and letting the persisted
162
+ // `collapsed=false` win there is exactly the "snapping between two
163
+ // extremes" the story asks us to avoid.
164
+ const iconOnly = mode === 'medium' || (mode === 'wide' && collapsed)
165
+
166
+ const groups = narrow
167
+ ? [{ heading: undefined, items: RAIL_GROUPS.flatMap((g) => g.items).filter((i) => i.status === 'ready') }]
168
+ : RAIL_GROUPS
169
+
170
+ return (
171
+ <nav
172
+ ref={regionRef}
173
+ tabIndex={-1}
174
+ data-region="rail"
175
+ aria-label="Views"
176
+ className={cn(
177
+ 'flex h-full w-full min-w-0 overflow-hidden bg-surface outline-none',
178
+ narrow
179
+ ? 'flex-row border-t border-border pb-[env(safe-area-inset-bottom,0px)]'
180
+ : 'flex-col border-r border-border',
181
+ regionFocused && 'ring-1 ring-inset ring-ring',
182
+ )}
183
+ >
184
+ {!narrow && (
185
+ <div
186
+ className={cn(
187
+ 'flex items-center gap-[9px] border-b border-border',
188
+ iconOnly ? 'justify-center px-0 py-3' : 'px-3.5 pb-3 pt-3.5',
189
+ )}
190
+ >
191
+ <span
192
+ aria-hidden="true"
193
+ className="grid h-[26px] w-[26px] flex-none place-items-center rounded-[7px] bg-gradient-to-br from-brand to-brand-dim font-mono text-[.72rem] font-bold tracking-[-.02em] text-[#0b1013]"
194
+ >
195
+ co
196
+ </span>
197
+ {!iconOnly && (
198
+ <span className="whitespace-nowrap font-semibold tracking-[-.01em]">
199
+ coord <span className="font-normal text-muted-foreground">web</span>
200
+ </span>
201
+ )}
202
+ </div>
203
+ )}
204
+
205
+ <div
206
+ className={cn(
207
+ 'flex',
208
+ narrow
209
+ ? 'w-full flex-row justify-around gap-0 overflow-x-auto px-0 py-1'
210
+ : 'flex-1 flex-col gap-px overflow-y-auto px-2 py-2.5',
211
+ )}
212
+ >
213
+ {groups.map((group, groupIndex) => (
214
+ <div
215
+ key={group.heading ?? `group-${groupIndex}`}
216
+ className={cn('flex', narrow ? 'w-full flex-row justify-around' : 'flex-col gap-px')}
217
+ >
218
+ {group.heading && !iconOnly && !narrow && (
219
+ <div className="whitespace-nowrap px-2.5 pb-[5px] pt-3.5 text-[.66rem] font-semibold uppercase tracking-[.09em] text-faint">
220
+ {group.heading}
221
+ </div>
222
+ )}
223
+ {group.items.map((item) => (
224
+ <NavItem
225
+ key={item.id}
226
+ item={item}
227
+ mode={mode}
228
+ collapsed={iconOnly}
229
+ selected={item.id === view}
230
+ count={counts?.[item.id]}
231
+ attention={attentionViews?.has(item.id)}
232
+ onSelect={onSelect}
233
+ />
234
+ ))}
235
+ </div>
236
+ ))}
237
+ </div>
238
+
239
+ {!narrow && (
240
+ <div className="flex flex-col gap-px border-t border-border px-2 py-2.5">
241
+ <button
242
+ type="button"
243
+ onClick={toggleTheme}
244
+ title={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
245
+ aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
246
+ className={cn(
247
+ navItemBase,
248
+ 'gap-[11px] px-2.5 py-1.5 text-muted-foreground hover:bg-surface-2 hover:text-foreground',
249
+ iconOnly && 'justify-center px-0 py-2',
250
+ )}
251
+ >
252
+ {theme === 'dark' ? (
253
+ <Sun className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
254
+ ) : (
255
+ <Moon className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
256
+ )}
257
+ {!iconOnly && <span className="flex-1 text-left">Theme</span>}
258
+ </button>
259
+
260
+ <button
261
+ type="button"
262
+ aria-disabled="true"
263
+ title="Settings panel — M-W3"
264
+ className={cn(
265
+ navItemBase,
266
+ 'cursor-default gap-[11px] px-2.5 py-1.5 text-muted-foreground opacity-40 hover:opacity-70',
267
+ iconOnly && 'justify-center px-0 py-2',
268
+ )}
269
+ >
270
+ <Settings2 className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
271
+ {!iconOnly && (
272
+ <>
273
+ <span className="flex-1 text-left">Settings</span>
274
+ <span className="rounded-full border border-line-strong px-[5px] text-[.6rem] uppercase tracking-[.06em] text-faint">
275
+ soon
276
+ </span>
277
+ </>
278
+ )}
279
+ </button>
280
+
281
+ {/* The mock puts "minimise list" in the panel head; it lives here
282
+ instead so it can't collide with whatever a panel chooses to put
283
+ in its own header, and so the way back (expand) is in the same
284
+ place as the way out. Wide-only: it's the only mode with a third
285
+ column to give the space to. */}
286
+ {mode === 'wide' && onToggleList && (
287
+ <button
288
+ type="button"
289
+ onClick={onToggleList}
290
+ aria-label={listCollapsed ? 'Show list panel' : 'Minimize list panel'}
291
+ aria-pressed={!!listCollapsed}
292
+ title={listCollapsed ? 'Show list panel' : 'Minimize list panel'}
293
+ className={cn(
294
+ navItemBase,
295
+ 'gap-[11px] px-2.5 py-1.5 text-muted-foreground hover:bg-surface-2 hover:text-foreground',
296
+ collapsed && 'justify-center px-0 py-2',
297
+ )}
298
+ >
299
+ {listCollapsed ? (
300
+ <PanelLeftOpen className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
301
+ ) : (
302
+ <PanelLeftClose className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
303
+ )}
304
+ {!collapsed && <span className="flex-1 text-left">{listCollapsed ? 'Show list' : 'Hide list'}</span>}
305
+ </button>
306
+ )}
307
+
308
+ {/* Medium pins the rail to icons, so offering a toggle there would
309
+ be a button that does nothing. */}
310
+ {mode === 'wide' && (
311
+ <button
312
+ type="button"
313
+ onClick={onToggleCollapsed}
314
+ aria-label={collapsed ? 'Expand rail' : 'Collapse rail'}
315
+ aria-expanded={!collapsed}
316
+ title={collapsed ? 'Expand rail' : 'Collapse rail'}
317
+ className={cn(
318
+ navItemBase,
319
+ 'gap-[11px] px-2.5 py-1.5 text-muted-foreground hover:bg-surface-2 hover:text-foreground',
320
+ collapsed && 'justify-center px-0 py-2',
321
+ )}
322
+ >
323
+ {collapsed ? (
324
+ <ChevronRight className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
325
+ ) : (
326
+ <ChevronLeft className="h-[17px] w-[17px] shrink-0" aria-hidden="true" />
327
+ )}
328
+ {!collapsed && <span className="flex-1 text-left">Collapse</span>}
329
+ </button>
330
+ )}
331
+ </div>
332
+ )}
333
+ </nav>
334
+ )
335
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * AppShell (#1547) — the responsive frame every panel hangs off.
3
+ *
4
+ * One CSS grid, three compositions, driven entirely by `mode` (see
5
+ * `breakpoints.ts`). The slots are `ReactNode` props rather than a forked
6
+ * component tree: `ShellLayout` passes the *same* list node and the *same*
7
+ * detail node at every viewport, and this file decides only where they land
8
+ * and which of them is on screen.
9
+ *
10
+ * wide rail | list | detail, three columns, status bar spanning the
11
+ * foot. The list/detail boundary is a draggable separator.
12
+ * medium rail | content. The detail arrives as an overlay sheet in the
13
+ * same grid cell as the list, so a drill-in doesn't cost you the
14
+ * list you drilled in from.
15
+ * narrow one column, status bar, then the rail as a bottom row. Exactly
16
+ * one of list/detail is mounted — this is the phone app's
17
+ * drill-in, and mounting both would duplicate every row's text in
18
+ * the accessibility tree.
19
+ *
20
+ * Mirrors docs/mocks/web/pipeline-wide.html's `.app` grid, including its
21
+ * `data-rail` / `data-panel` state attributes (kept as real DOM attributes:
22
+ * they are what an acceptance spec asserts layout state against without
23
+ * having to measure pixels).
24
+ */
25
+ import {
26
+ useCallback,
27
+ useEffect,
28
+ useRef,
29
+ type CSSProperties,
30
+ type KeyboardEvent as ReactKeyboardEvent,
31
+ type PointerEvent as ReactPointerEvent,
32
+ type ReactNode,
33
+ } from 'react'
34
+
35
+ import { cn } from '@/lib/utils'
36
+ import type { ShellMode } from './breakpoints'
37
+ import {
38
+ LIST_WIDTH_MAX_PX,
39
+ LIST_WIDTH_MIN_PX,
40
+ RAIL_WIDTH_COLLAPSED_PX,
41
+ RAIL_WIDTH_PX,
42
+ clampListWidth,
43
+ } from './shellState'
44
+ import type { ShellRegion } from './useRegionFocus'
45
+
46
+ /** Keyboard resize step for the separator (Shift = coarse). */
47
+ const RESIZE_STEP_PX = 16
48
+ const RESIZE_STEP_COARSE_PX = 64
49
+
50
+ /**
51
+ * `inert` isn't in React 18's JSX attribute types (it landed in React 19), but
52
+ * React does forward unknown lowercase string attributes to the DOM, and every
53
+ * browser that ships `inert` honours the empty-string form. This is the only
54
+ * thing that stops Tab from walking into the list hidden *behind* the medium
55
+ * overlay — `aria-hidden` alone hides it from a screen reader while leaving it
56
+ * in the tab order, which is the worse of the two failure modes.
57
+ */
58
+ const INERT_PROPS = { inert: '' } as unknown as Record<string, string>
59
+
60
+ export interface AppShellProps {
61
+ mode: ShellMode
62
+ railCollapsed: boolean
63
+ listCollapsed: boolean
64
+ listWidthPx: number
65
+ onListWidthChange: (px: number) => void
66
+ /** True when a detail item is selected (on narrow: show detail, hide list). */
67
+ detailActive: boolean
68
+ rail: ReactNode
69
+ list: ReactNode
70
+ detail: ReactNode
71
+ status: ReactNode
72
+ registerRegion: (region: ShellRegion) => (el: HTMLElement | null) => void
73
+ focusedRegion: ShellRegion | null
74
+ }
75
+
76
+ export function AppShell({
77
+ mode,
78
+ railCollapsed,
79
+ listCollapsed,
80
+ listWidthPx,
81
+ onListWidthChange,
82
+ detailActive,
83
+ rail,
84
+ list,
85
+ detail,
86
+ status,
87
+ registerRegion,
88
+ focusedRegion,
89
+ }: AppShellProps) {
90
+ const narrow = mode === 'narrow'
91
+ const wide = mode === 'wide'
92
+ // Medium pins the rail to its icon strip regardless of the persisted flag —
93
+ // see ActivityRail for why.
94
+ const railPx = wide && !railCollapsed ? RAIL_WIDTH_PX : RAIL_WIDTH_COLLAPSED_PX
95
+
96
+ // Wide is the only mode with a third column to give away, so it's the only
97
+ // mode where "minimise the list" means anything.
98
+ const listHidden = wide && listCollapsed
99
+ const showList = narrow ? !detailActive : !listHidden
100
+ // Wide is the only mode with a column reserved for the detail, so it's the
101
+ // only one that mounts the "nothing selected" placeholder. On medium the
102
+ // detail shares the list's grid cell — mounting it unselected would paint an
103
+ // opaque placeholder straight over the list.
104
+ const showDetail = wide || detailActive
105
+ // On medium the detail sheet sits *on top of* the list in the same cell.
106
+ const detailOverlays = mode === 'medium' && detailActive
107
+
108
+ const gridStyle: CSSProperties = narrow
109
+ ? { gridTemplateColumns: 'minmax(0,1fr)', gridTemplateRows: 'minmax(0,1fr) auto auto' }
110
+ : {
111
+ gridTemplateColumns: wide
112
+ ? `${railPx}px ${listHidden ? 0 : listWidthPx}px minmax(0,1fr)`
113
+ : `${railPx}px minmax(0,1fr)`,
114
+ gridTemplateRows: 'minmax(0,1fr) auto',
115
+ }
116
+
117
+ const railStyle: CSSProperties = narrow
118
+ ? { gridColumn: 1, gridRow: 3 }
119
+ : { gridColumn: 1, gridRow: 1 }
120
+ const listStyle: CSSProperties = narrow ? { gridColumn: 1, gridRow: 1 } : { gridColumn: 2, gridRow: 1 }
121
+ const detailStyle: CSSProperties = narrow
122
+ ? { gridColumn: 1, gridRow: 1 }
123
+ : { gridColumn: wide ? 3 : 2, gridRow: 1 }
124
+ const statusStyle: CSSProperties = narrow
125
+ ? { gridColumn: 1, gridRow: 2 }
126
+ : { gridColumn: '1 / -1', gridRow: 2 }
127
+
128
+ // ── separator drag ────────────────────────────────────────────────────────
129
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
130
+
131
+ const onPointerDown = useCallback(
132
+ (event: ReactPointerEvent<HTMLDivElement>) => {
133
+ // Ignore secondary buttons: a right-click drag would otherwise capture
134
+ // the pointer and never see a matching pointerup.
135
+ if (event.button !== 0) return
136
+ event.preventDefault()
137
+ event.currentTarget.setPointerCapture(event.pointerId)
138
+ dragRef.current = { startX: event.clientX, startWidth: listWidthPx }
139
+ // A col-resize drag that paints half the issue list blue looks broken,
140
+ // and the cursor must not flicker back to `text` when the pointer
141
+ // leaves the 7px handle mid-drag.
142
+ document.body.style.setProperty('user-select', 'none')
143
+ document.body.style.setProperty('cursor', 'col-resize')
144
+ },
145
+ [listWidthPx],
146
+ )
147
+
148
+ const onPointerMove = useCallback(
149
+ (event: ReactPointerEvent<HTMLDivElement>) => {
150
+ const drag = dragRef.current
151
+ if (!drag) return
152
+ onListWidthChange(clampListWidth(drag.startWidth + (event.clientX - drag.startX)))
153
+ },
154
+ [onListWidthChange],
155
+ )
156
+
157
+ const endDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
158
+ if (!dragRef.current) return
159
+ dragRef.current = null
160
+ document.body.style.removeProperty('user-select')
161
+ document.body.style.removeProperty('cursor')
162
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
163
+ event.currentTarget.releasePointerCapture(event.pointerId)
164
+ }
165
+ }, [])
166
+
167
+ const onSeparatorKeyDown = useCallback(
168
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
169
+ const step = event.shiftKey ? RESIZE_STEP_COARSE_PX : RESIZE_STEP_PX
170
+ switch (event.key) {
171
+ case 'ArrowLeft':
172
+ event.preventDefault()
173
+ onListWidthChange(listWidthPx - step)
174
+ break
175
+ case 'ArrowRight':
176
+ event.preventDefault()
177
+ onListWidthChange(listWidthPx + step)
178
+ break
179
+ case 'Home':
180
+ event.preventDefault()
181
+ onListWidthChange(LIST_WIDTH_MIN_PX)
182
+ break
183
+ case 'End':
184
+ event.preventDefault()
185
+ onListWidthChange(LIST_WIDTH_MAX_PX)
186
+ break
187
+ default:
188
+ break
189
+ }
190
+ },
191
+ [listWidthPx, onListWidthChange],
192
+ )
193
+
194
+ // Unmounting (or dropping out of wide) mid-drag would otherwise strand the
195
+ // document in `user-select: none; cursor: col-resize` forever.
196
+ useEffect(
197
+ () => () => {
198
+ document.body.style.removeProperty('user-select')
199
+ document.body.style.removeProperty('cursor')
200
+ },
201
+ [],
202
+ )
203
+
204
+ return (
205
+ <div
206
+ className="grid h-screen w-full overflow-hidden bg-background text-foreground [height:100dvh]"
207
+ style={gridStyle}
208
+ data-shell-mode={mode}
209
+ data-rail={narrow ? 'bottom' : !wide || railCollapsed ? 'collapsed' : 'expanded'}
210
+ data-panel={listHidden ? 'collapsed' : 'expanded'}
211
+ data-detail={detailActive ? 'active' : 'empty'}
212
+ >
213
+ <div style={railStyle} className="min-w-0 overflow-hidden">
214
+ {rail}
215
+ </div>
216
+
217
+ {showList && (
218
+ <section
219
+ ref={registerRegion('list')}
220
+ tabIndex={-1}
221
+ data-region="list"
222
+ aria-label="List"
223
+ aria-hidden={detailOverlays || undefined}
224
+ {...(detailOverlays ? INERT_PROPS : {})}
225
+ style={listStyle}
226
+ className={cn(
227
+ 'relative flex min-w-0 flex-col overflow-hidden bg-background outline-none',
228
+ !narrow && 'border-r border-border',
229
+ focusedRegion === 'list' && 'ring-1 ring-inset ring-ring',
230
+ )}
231
+ >
232
+ <div className="min-h-0 flex-1 overflow-y-auto">{list}</div>
233
+
234
+ {wide && (
235
+ <div
236
+ role="separator"
237
+ aria-orientation="vertical"
238
+ aria-label="Resize list panel"
239
+ aria-valuenow={listWidthPx}
240
+ aria-valuemin={LIST_WIDTH_MIN_PX}
241
+ aria-valuemax={LIST_WIDTH_MAX_PX}
242
+ tabIndex={0}
243
+ onPointerDown={onPointerDown}
244
+ onPointerMove={onPointerMove}
245
+ onPointerUp={endDrag}
246
+ onPointerCancel={endDrag}
247
+ onKeyDown={onSeparatorKeyDown}
248
+ className="absolute -right-[3px] bottom-0 top-0 z-20 w-[7px] cursor-col-resize touch-none bg-transparent transition-colors hover:bg-brand/40 focus-visible:bg-brand/60 focus-visible:outline-none"
249
+ />
250
+ )}
251
+ </section>
252
+ )}
253
+
254
+ {showDetail && (
255
+ <main
256
+ ref={registerRegion('detail')}
257
+ tabIndex={-1}
258
+ data-region="detail"
259
+ aria-label="Detail"
260
+ style={detailStyle}
261
+ className={cn(
262
+ 'flex min-w-0 flex-col overflow-hidden bg-background outline-none',
263
+ detailOverlays && 'z-10 border-l border-border shadow-elevation',
264
+ focusedRegion === 'detail' && 'ring-1 ring-inset ring-ring',
265
+ )}
266
+ >
267
+ <div className="min-h-0 flex-1 overflow-y-auto">{detail}</div>
268
+ </main>
269
+ )}
270
+
271
+ <div style={statusStyle} className="min-w-0">
272
+ {status}
273
+ </div>
274
+ </div>
275
+ )
276
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The list panel's content for a rail-listed view that has a route (#1548
3
+ * asks for "placeholders for the panels M-W5+ will add") but no panel yet —
4
+ * Board, Machines, Merge queue, Milestones, Audit, Spend, Settings.
5
+ *
6
+ * These routes exist for exactly one reason: so a link to `/board` or
7
+ * `/spend` is a real, bookmarkable, not-found-free address today, and
8
+ * swapping this placeholder for the real panel later is a one-line change in
9
+ * `App.tsx`, not a new route. The rail itself already marks these `'soon'`
10
+ * (see `railItems.ts`) and won't navigate here on a click — this is what a
11
+ * *typed or pasted* link to one of them lands on in the meantime.
12
+ */
13
+ import { RAIL_ITEMS } from './railItems'
14
+ import type { ShellView } from './shellState'
15
+
16
+ export interface ComingSoonProps {
17
+ view: ShellView
18
+ }
19
+
20
+ export function ComingSoon({ view }: ComingSoonProps) {
21
+ const item = RAIL_ITEMS.find((i) => i.id === view)
22
+ const Icon = item?.icon
23
+
24
+ return (
25
+ <div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-12 text-center">
26
+ {Icon && <Icon className="h-7 w-7 text-faint" aria-hidden="true" />}
27
+ <p className="text-sm font-medium text-foreground">{item?.label ?? 'Coming soon'}</p>
28
+ <p className="max-w-xs text-xs text-muted-foreground">
29
+ {item?.hint ?? 'This panel is not built yet.'}
30
+ </p>
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What the wide layout's detail column shows before anything is selected
3
+ * (#1547).
4
+ *
5
+ * Only ever rendered on wide/medium: on narrow the shell mounts the list
6
+ * *instead of* the detail, so there is no "nothing selected" state to fill.
7
+ * It doubles as the shell's one piece of keyboard-affordance signage — F6 is
8
+ * discoverable nowhere else, and this is the emptiest surface in the app.
9
+ */
10
+ import { MousePointerClick } from 'lucide-react'
11
+
12
+ export function EmptyDetail() {
13
+ return (
14
+ <div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-12 text-center">
15
+ <MousePointerClick className="h-7 w-7 text-faint" aria-hidden="true" />
16
+ <p className="text-sm font-medium text-foreground">Nothing selected</p>
17
+ <p className="max-w-xs text-xs text-muted-foreground">
18
+ Pick an item from the list to see its stages, worker and actions here.
19
+ </p>
20
+ <p className="text-xs text-faint">
21
+ <kbd className="rounded border border-line-strong px-1 font-mono text-[.7rem]">F6</kbd>{' '}
22
+ cycles rail → list → detail
23
+ </p>
24
+ </div>
25
+ )
26
+ }