rvlib-shipkit 1.1.1 → 1.3.0

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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,32 @@
1
+ /* small controls any page can use. Colours are hub.css tokens, spacing its
2
+ scale (tests/hub-css-invariants.test.ts reads this sheet too) */
3
+
4
+ /* the cycling button (cycle-button.tsx): the current label, one dot per
5
+ option with the current one in the ink colour, then its shortcut */
6
+ .cycle{flex:none;display:inline-flex;align-items:center;gap:var(--s1);height:18px;padding:0 var(--s1);background:var(--panel2);border:1px solid var(--line);border-radius:9px;
7
+ color:var(--ink);font-size:10px;line-height:1;cursor:pointer}
8
+ .cycle:hover{border-color:var(--accent)}
9
+ .cyclbl{min-width:26px;text-align:center}
10
+ .cycdots{display:inline-flex;align-items:center;gap:var(--s0)}
11
+ .cycdot{width:4px;height:4px;border-radius:2px;background:var(--dim);opacity:.5}
12
+ .cycdot.on{background:var(--ink);opacity:1}
13
+ .cyckey{font:9px/1 ui-monospace,Menlo,monospace;color:var(--dim);background:none;border:0;padding:0}
14
+
15
+ /* a row of pills (pill-row.tsx): one row always, the rest folded into +N.
16
+ The probe is an invisible copy that only gives the widths */
17
+ .pchips{position:relative;display:flex;align-items:center;gap:var(--s1);min-width:0;overflow:hidden;white-space:nowrap}
18
+ .pchipw{flex:none;display:inline-flex}
19
+ .pchipprobe{position:absolute;left:0;top:0;width:max-content;overflow:visible;visibility:hidden;pointer-events:none}
20
+ .pchip{display:inline-flex;align-items:center;gap:var(--s1);height:16px;padding:0 var(--s1);border:1px solid var(--line);border-radius:8px;
21
+ background:var(--panel2);color:var(--dim);font-size:10px;line-height:1}
22
+ .pchip.accent{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,var(--line))}
23
+ .pchip.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,var(--line))}
24
+ .pchip img,.pchip .dgico{flex:none;border-radius:2px}
25
+ .pcmark{font-weight:700}
26
+ .pcmark.ok{color:var(--ok)}
27
+ .pcmark.red{color:var(--red)}
28
+ .pcmark.warn{color:var(--warn)}
29
+ .pcmark.dim{color:var(--dim)}
30
+ .pctext{color:var(--ink);opacity:.8}
31
+ .pchip.accent .pctext,.pchip.warn .pctext{color:inherit;opacity:1}
32
+ .pcage{color:var(--dim);opacity:.75}
@@ -0,0 +1,36 @@
1
+ // a button that cycles through a few options: it shows only the current
2
+ // option's label, then one dot per option (the current one lit), then its
3
+ // shortcut. A click moves to the next option, the last one wrapping to the
4
+ // first; the page's key handler calls the same move for the shortcut.
5
+
6
+ export type CycleOption<T extends string> = { readonly value: T; readonly label: string }
7
+
8
+ /** the option after `value`, the last one wrapping to the first */
9
+ export function nextOption<T extends string>(options: readonly CycleOption<T>[], value: T): T {
10
+ const i = options.findIndex((o) => o.value === value)
11
+ return options[(i + 1) % options.length]!.value
12
+ }
13
+
14
+ export function CycleButton<T extends string>(p: {
15
+ options: readonly CycleOption<T>[]
16
+ value: T
17
+ onChange: (next: T) => void
18
+ /** the key that cycles it too, shown as a badge */
19
+ shortcut?: string
20
+ /** what the button is for, on hover */
21
+ title?: string
22
+ className?: string
23
+ }) {
24
+ const cur = p.options.find((o) => o.value === p.value)
25
+ const next = p.options.find((o) => o.value === nextOption(p.options, p.value))
26
+ const hint = `${p.title == null ? '' : `${p.title}\n`}click${p.shortcut == null ? '' : ` or ${p.shortcut}`}: ${next?.label ?? ''}`
27
+ return (
28
+ <button className={`cycle${p.className == null ? '' : ` ${p.className}`}`} title={hint} onClick={() => p.onChange(nextOption(p.options, p.value))}>
29
+ <span className="cyclbl">{cur?.label ?? p.value}</span>
30
+ <span className="cycdots" aria-hidden="true">
31
+ {p.options.map((o) => <i key={o.value} className={`cycdot${o.value === p.value ? ' on' : ''}`} />)}
32
+ </span>
33
+ {p.shortcut == null ? null : <kbd className="cyckey">{p.shortcut}</kbd>}
34
+ </button>
35
+ )
36
+ }
@@ -0,0 +1,25 @@
1
+ // the dock's size change, eased: the class on the page's vertical split
2
+ // carries a flex-grow transition (dock.css), put on only while a size change
3
+ // resizes the panels, so a drag, a window resize and the first paint stay
4
+ // instant. prefers-reduced-motion drops the transition in the sheet.
5
+
6
+ /** how long the dock takes to reach its new size */
7
+ export const DOCK_ANIM_MS = 180
8
+ /** the class that eases the split's panels */
9
+ export const DOCK_ANIM = 'dockanim'
10
+
11
+ const timers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
12
+
13
+ /** runs `resize` with the class on `group`, and takes it off once the
14
+ * transition ended; a second call meanwhile keeps it on until its own end */
15
+ export function animateDock(group: HTMLElement | null, resize: () => void, ms = DOCK_ANIM_MS + 40): void {
16
+ if (group == null) return resize()
17
+ const was = timers.get(group)
18
+ if (was != null) clearTimeout(was)
19
+ group.classList.add(DOCK_ANIM)
20
+ resize()
21
+ timers.set(group, setTimeout(() => {
22
+ timers.delete(group)
23
+ group.classList.remove(DOCK_ANIM)
24
+ }, ms))
25
+ }
@@ -0,0 +1,329 @@
1
+ // THE DOCK, as the page holds it: the run chart docked under the whole page,
2
+ // every house repo's runs on one clock, one group of lanes per repo that ran
3
+ // something in the window. A node of the root (home-st.ts).
4
+ //
5
+ // Three sizes, cycled by one key (keys.ts), and never nothing: the line, one
6
+ // row saying what runs, what waits and the last check that ended; slim, the
7
+ // last two minutes in thin lanes; open, the last ten in the full chart, its
8
+ // height dragged and kept. The size and the height are read from this browser
9
+ // before the first render, so the page paints at its final layout and a
10
+ // toggle moves nothing but the boundary.
11
+ //
12
+ // the one constraint a reader breaks first: it polls ONE route for every repo
13
+ // (/api/house-runs.json, cached on the server), and only while the page is
14
+ // visible. In a background tab it costs nothing.
15
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
16
+ import type { HouseGroup, HouseView } from '../../ledger/house-runs.ts'
17
+ import type { TimelineBar } from '../../ledger/run-timeline.ts'
18
+ import { ChartView, type ChartSource } from './chart-view.ts'
19
+ import { nextOption, type CycleOption } from './cycle-button.tsx'
20
+ import { ClockSvc } from './clock-svc.ts'
21
+ import { serverNow } from './live-svc.ts'
22
+ import { ROWS, type RowSize } from './timeline-geometry.ts'
23
+ import { ageWords } from './time-ago.tsx'
24
+ import type { HomeSt } from './home-st.ts'
25
+
26
+ export type DockSize = 'line' | 'slim' | 'open'
27
+
28
+ /** the sizes, in the order the size button and the key cycle them */
29
+ export const DOCK_SIZES = [{ value: 'line', label: 'line' }, { value: 'slim', label: 'slim' }, { value: 'open', label: 'open' }] as const satisfies readonly CycleOption<DockSize>[]
30
+
31
+ /** the one key's cycle: the line, slim, open, the line again */
32
+ export const nextSize = (s: DockSize): DockSize => nextOption(DOCK_SIZES, s)
33
+
34
+ /** where this browser keeps the dock, and its heights in px */
35
+ export const DOCK = { sizeKey: 'hub:dock', heightKey: 'hub:dock-height', line: 22, slim: 72, min: 140, max: 720, def: 280 } as const
36
+
37
+ /** how often it asks while it shows: the server shares one answer a second,
38
+ * and the open dock redraws at this pace */
39
+ export const DOCK_POLL_MS = 2_000
40
+ /** the slim dock's window; open, the server's whole window (LIVE_HISTORY_MS) */
41
+ export const SLIM_WINDOW_MS = 120_000
42
+ /** the most lanes one repo takes */
43
+ export const LANE_CAP: Record<Exclude<DockSize, 'line'>, number> = { slim: 3, open: 8 }
44
+ /** the height a group's label needs beside its lanes */
45
+ export const LABEL_PX = 14
46
+ /** the fewest lanes a group takes at a lane size, so its label fits */
47
+ export const minLanesFor = (rows: RowSize): number => Math.ceil(LABEL_PX / (ROWS[rows].lane + ROWS[rows].gap))
48
+
49
+ /** a kept size, read. `hidden`, which an older build kept, is the line now */
50
+ export const parseSize = (v: unknown): DockSize => (v === 'line' || v === 'hidden' ? 'line' : v === 'open' ? 'open' : 'slim')
51
+
52
+ /** an open height in its bounds, or the default for anything else */
53
+ export const clampHeight = (v: unknown): number => {
54
+ const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN
55
+ return Number.isFinite(n) && n >= DOCK.min && n <= DOCK.max ? Math.round(n) : DOCK.def
56
+ }
57
+
58
+ /** the dock as this browser left it: slim at the default height when it
59
+ * kept nothing or cannot say */
60
+ export function readDock(): { size: DockSize; height: number } {
61
+ try {
62
+ return { size: parseSize(globalThis.localStorage?.getItem(DOCK.sizeKey)), height: clampHeight(globalThis.localStorage?.getItem(DOCK.heightKey)) }
63
+ } catch {
64
+ return { size: 'slim', height: DOCK.def }
65
+ }
66
+ }
67
+
68
+ const keep = (key: string, v: string): void => {
69
+ try {
70
+ globalThis.localStorage?.setItem(key, v)
71
+ } catch { /* private window or blocked site data: the dock lasts the tab */ }
72
+ }
73
+
74
+ /** the dock's height in px at a size */
75
+ export const dockPx = (size: DockSize, height: number): number => (size === 'line' ? DOCK.line : size === 'slim' ? DOCK.slim : height)
76
+
77
+ /** a verdict as one glyph */
78
+ const MARK: Record<TimelineBar['status'], string> = { pass: '✓', fail: '✗', warn: '!', skip: '○' }
79
+
80
+ /** the line's words: `2 running · 5 queued · last: app-one typecheck ✓ 12s
81
+ * ago`, `idle` with the last check when the window holds one. Queued is
82
+ * every check of a run in flight that is neither done nor running */
83
+ export function lineWords(view: HouseView | null, now: number): string {
84
+ if (view == null) return 'reading…'
85
+ let last: { repo: string; bar: TimelineBar } | null = null
86
+ let queued = 0
87
+ for (const g of view.groups) {
88
+ queued += g.runs.reduce((a, r) => a + r.total - r.done, 0) - g.busy
89
+ for (const i of g.invocations) for (const b of i.bars) if (b.running !== true && (last == null || b.end > last.bar.end)) last = { repo: g.repo, bar: b }
90
+ }
91
+ const lastWords = last == null ? '' : `last: ${last.repo} ${last.bar.id} ${MARK[last.bar.status]}${last.bar.replayed ? ' cached' : ''} ${ageWords(last.bar.end, now)}`
92
+ const head = view.busy > 0 ? `${view.busy} running · ${Math.max(0, queued)} queued` : 'idle'
93
+ return [head, lastWords, ...(view.held ?? [])].filter((w) => w !== '').join(' · ')
94
+ }
95
+
96
+ /** a pill of the dock's header: a word or two in a dim box, its hover the
97
+ * whole story. The last run adds its repo, its verdict and its age */
98
+ export type DockPill = {
99
+ key: 'state' | 'queued' | 'held' | 'last' | 'settling' | 'window'
100
+ tone: 'dim' | 'accent' | 'warn'
101
+ text: string
102
+ title: string
103
+ repo?: string
104
+ mark?: string
105
+ markTone?: 'ok' | 'red' | 'warn' | 'dim'
106
+ age?: string
107
+ }
108
+
109
+ /** the verdict glyph a pill wears, and its colour */
110
+ const PILL_MARK: Record<TimelineBar['status'], { mark: string; tone: 'ok' | 'red' | 'warn' | 'dim' }> = {
111
+ pass: { mark: '✓', tone: 'ok' }, fail: { mark: '✕', tone: 'red' }, warn: { mark: '!', tone: 'warn' }, skip: { mark: '○', tone: 'dim' },
112
+ }
113
+
114
+ export const windowWords = (ms: number): string => `last ${Math.round(ms / 60_000)} min`
115
+
116
+ /** the longest check id a pill shows whole */
117
+ export const ID_MAX = 16
118
+ export const cutId = (id: string): string => (id.length <= ID_MAX ? id : `${id.slice(0, ID_MAX - 1)}…`)
119
+
120
+ /** `settling: app-one, 48s`, one line of Planner.held */
121
+ const SETTLING = /^settling: (.+), (\d+s)$/
122
+
123
+ /** the header's status, left to right: what runs or idle, what waits, what
124
+ * holds the plan, the last check that ended, the settling repos as one,
125
+ * and slim and open their window. The row folds from the end */
126
+ export function dockPills(view: HouseView | null, now: number, size: DockSize): DockPill[] {
127
+ if (view == null) return [{ key: 'state', tone: 'dim', text: 'reading…', title: 'the runs of every repo are being read' }]
128
+ let last: { repo: string; bar: TimelineBar } | null = null
129
+ let queued = 0
130
+ for (const g of view.groups) {
131
+ queued += g.runs.reduce((a, r) => a + r.total - r.done, 0) - g.busy
132
+ for (const i of g.invocations) for (const b of i.bars) if (b.running !== true && (last == null || b.end > last.bar.end)) last = { repo: g.repo, bar: b }
133
+ }
134
+ queued = Math.max(0, queued)
135
+ const flight = `${view.busy} check(s) in flight in every repo, cap ${view.cap}, ${queued} waiting for a slot`
136
+ const out: DockPill[] = [view.busy > 0
137
+ ? { key: 'state', tone: 'accent', text: `${view.busy} running`, title: flight }
138
+ : { key: 'state', tone: 'dim', text: 'idle', title: flight }]
139
+ if (queued > 0) out.push({ key: 'queued', tone: 'dim', text: `${queued} queued`, title: flight })
140
+ const settling: string[] = []
141
+ for (const line of view.held ?? []) {
142
+ const m = line.match(SETTLING)
143
+ if (m != null) settling.push(`${m[1]} ${m[2]}`)
144
+ else out.push({ key: 'held', tone: 'warn', text: line.replace(/ on \d+ cores$/, ''), title: line })
145
+ }
146
+ if (last != null) {
147
+ const v = PILL_MARK[last.bar.status]
148
+ out.push({
149
+ key: 'last', tone: 'dim', repo: last.repo, mark: v.mark, markTone: v.tone, text: cutId(last.bar.id), age: ageWords(last.bar.end, now),
150
+ title: `the last check that ended: ${last.repo} ${last.bar.id} ${v.mark}${last.bar.replayed ? ' cached' : ''} ${ageWords(last.bar.end, now)}`,
151
+ })
152
+ }
153
+ if (settling.length > 0) out.push({ key: 'settling', tone: 'dim', text: `settling ${settling.length}`, title: settling.join('\n') })
154
+ if (size !== 'line') {
155
+ const ms = size === 'open' ? view.historyMs : SLIM_WINDOW_MS
156
+ out.push({ key: 'window', tone: 'dim', text: windowWords(ms), title: 'the window the chart draws, ending now' })
157
+ }
158
+ return out
159
+ }
160
+
161
+ /** the groups a window draws: a repo that ran nothing since `from` has none,
162
+ * and the rest stand in the order of the project list, a repo it does not
163
+ * list after them by name */
164
+ export function dockGroups(view: HouseView, from: number, order: readonly string[]): HouseGroup[] {
165
+ const at = (repo: string): number => {
166
+ const i = order.indexOf(repo)
167
+ return i < 0 ? order.length : i
168
+ }
169
+ return view.groups
170
+ .filter((g) => g.invocations.some((i) => i.running === true || i.ended >= from))
171
+ .sort((a, b) => at(a.repo) - at(b.repo) || a.repo.localeCompare(b.repo))
172
+ }
173
+
174
+ /** what the chart draws at a size: the window that ends now, one group per repo */
175
+ export function dockSource(view: HouseView | null, now: number, size: DockSize, order: readonly string[]): ChartSource | null {
176
+ if (view == null || size === 'line') return null
177
+ const windowMs = size === 'open' ? view.historyMs : SLIM_WINDOW_MS
178
+ const groups = dockGroups(view, now - windowMs, order)
179
+ return {
180
+ invocations: groups.flatMap((g) => g.invocations),
181
+ groups: groups.map((g) => ({ key: g.repo, invocations: g.invocations })),
182
+ now,
183
+ windowMs,
184
+ laneCap: LANE_CAP[size],
185
+ minLanes: minLanesFor(size === 'open' ? 'full' : 'slim'),
186
+ zoomable: size === 'open',
187
+ cap: view.cap,
188
+ }
189
+ }
190
+
191
+ export class DockSt {
192
+ size: DockSize
193
+ /** the open height, kept even while the line or slim shows */
194
+ height: number
195
+ view: HouseView | null = null
196
+ /** the page clock when `view` arrived */
197
+ receivedAt = 0
198
+ error = ''
199
+ /** the page is in front: a background tab asks nothing */
200
+ visible = true
201
+ clock = new ClockSvc()
202
+ chart: ChartView
203
+ private timer: ReturnType<typeof setInterval> | null = null
204
+ private inFlight = false
205
+ private readonly onVisibility = (): void => this.setVisible(globalThis.document?.visibilityState !== 'hidden')
206
+
207
+ constructor(private home: HomeSt) {
208
+ const kept = readDock()
209
+ this.size = kept.size
210
+ this.height = kept.height
211
+ this.chart = new ChartView(null, () => dockSource(this.view, this.now, this.size, this.home.projects.list.map((p) => p.name)))
212
+ makeAutoObservable<this, 'home' | 'timer' | 'inFlight' | 'onVisibility'>(this, { home: false, timer: false, inFlight: false, onVisibility: false, view: observableRef, chart: false, clock: false })
213
+ }
214
+
215
+ /** its height on the page now */
216
+ get px(): number {
217
+ return dockPx(this.size, this.height)
218
+ }
219
+ get rows(): RowSize {
220
+ return this.size === 'open' ? 'full' : 'slim'
221
+ }
222
+ get windowMs(): number {
223
+ return this.size === 'open' ? (this.view?.historyMs ?? SLIM_WINDOW_MS) : SLIM_WINDOW_MS
224
+ }
225
+ /** the server clock, as of the page clock's last tick */
226
+ get now(): number {
227
+ return this.view == null ? this.clock.now : serverNow(this.view, this.receivedAt, this.clock.now)
228
+ }
229
+ get groups(): HouseGroup[] {
230
+ return this.view == null ? [] : dockGroups(this.view, this.now - this.windowMs, this.home.projects.list.map((p) => p.name))
231
+ }
232
+ /** it asks while the page is in front: every size shows something */
233
+ get wanted(): boolean {
234
+ return this.visible
235
+ }
236
+ /** the line's words, on the clock: the header's whole status on hover */
237
+ get line(): string {
238
+ return lineWords(this.view, this.now)
239
+ }
240
+ /** the header's status as pills, on the clock */
241
+ get pills(): DockPill[] {
242
+ return dockPills(this.view, this.now, this.size)
243
+ }
244
+
245
+ cycle(): void {
246
+ this.setSize(nextSize(this.size))
247
+ }
248
+ setSize(s: DockSize): void {
249
+ if (s === this.size) return
250
+ this.size = s
251
+ keep(DOCK.sizeKey, s)
252
+ // a size opens live, on its whole window
253
+ this.chart.home()
254
+ this.chart.attach()
255
+ this.sync()
256
+ }
257
+ /** a drag of the separator above the open dock ended at this height */
258
+ setHeight(px: number): void {
259
+ this.height = clampHeight(Math.round(Math.min(DOCK.max, Math.max(DOCK.min, px))))
260
+ keep(DOCK.heightKey, String(this.height))
261
+ }
262
+ setVisible(v: boolean): void {
263
+ this.visible = v
264
+ this.sync()
265
+ }
266
+
267
+ /** start or stop asking, as `wanted` says */
268
+ sync(): void {
269
+ if (this.wanted && this.timer == null) {
270
+ void this.poll()
271
+ this.timer = setInterval(() => void this.poll(), DOCK_POLL_MS)
272
+ } else if (!this.wanted && this.timer != null) {
273
+ clearInterval(this.timer)
274
+ this.timer = null
275
+ }
276
+ // the line's ages and the slim chart's two minutes move every second,
277
+ // so they tick. Open, a thousand bars over ten minutes redraw only
278
+ // when an answer lands (each answer ticks the clock): a second's
279
+ // slide is under two px there, and a redraw a second is what costs
280
+ if (this.wanted && this.size !== 'open') this.clock.start()
281
+ else this.clock.stop()
282
+ }
283
+
284
+ boot(): void {
285
+ globalThis.document?.addEventListener('visibilitychange', this.onVisibility)
286
+ this.onVisibility()
287
+ }
288
+ dispose(): void {
289
+ globalThis.document?.removeEventListener('visibilitychange', this.onVisibility)
290
+ this.visible = false
291
+ this.sync()
292
+ }
293
+
294
+ async poll(): Promise<void> {
295
+ // a slow answer must not stack a second request behind it
296
+ if (this.inFlight) return
297
+ this.inFlight = true
298
+ try {
299
+ const r = await fetch('/api/house-runs.json')
300
+ if (!r.ok) throw new Error(await r.text())
301
+ const v = (await r.json()) as HouseView
302
+ runInAction(() => {
303
+ this.view = v
304
+ this.receivedAt = Date.now()
305
+ this.clock.tick(this.receivedAt)
306
+ this.error = ''
307
+ })
308
+ } catch (e) {
309
+ runInAction(() => { this.error = `the runs could not be read: ${(e as Error).message}` })
310
+ } finally {
311
+ this.inFlight = false
312
+ }
313
+ }
314
+
315
+ /** a bar: its check's page in its repo */
316
+ openCheck(repo: string, check: string): void {
317
+ const h = this.home
318
+ if (h.machine != null) return h.openCheck(repo, check)
319
+ const own = h.shown
320
+ if (own != null && own.name === repo) own.cols.select('checks', `check:${check}`)
321
+ else h.projects.onSelect(repo)
322
+ }
323
+ /** a group's label: its repo's page */
324
+ openRepo(repo: string): void {
325
+ const h = this.home
326
+ if (h.machine != null) return h.select(repo)
327
+ if (h.shown?.name !== repo) h.projects.onSelect(repo)
328
+ }
329
+ }
@@ -0,0 +1,68 @@
1
+ // THE DOCK, drawn: under the whole page, on every page, what shipkit runs in
2
+ // every house repo, as the one run chart (timeline-chart.tsx) with one group
3
+ // of lanes per repo. Its header is one row: the size as a cycling button,
4
+ // then the status as small pills that fold into +N rather than wrap. The
5
+ // state is dock-st.ts; the height is the page's panel (hub-app.tsx).
6
+ import { observer } from 'mobx-react-lite'
7
+ import type { HomeSt } from './home-st.ts'
8
+ import { CycleButton } from './cycle-button.tsx'
9
+ import { DOCK_KEY } from './keys.ts'
10
+ import { DOCK_SIZES, SLIM_WINDOW_MS, windowWords, type DockPill } from './dock-st.ts'
11
+ import { PillRow } from './pill-row.tsx'
12
+ import { TimelineChart } from './timeline-chart.tsx'
13
+ import type { PlacedGroup } from './timeline-geometry.ts'
14
+
15
+ /** what the dock is, on its size button's hover */
16
+ export const DOCK_HELP = `every check shipkit runs in every repo, whoever started it: a page, an agent in a terminal, the plan. The line, the pills alone; slim, the last two minutes, one group of lanes per repo; open, the last ten, zoomed by the wheel and panned by a drag, its height dragged at the top edge`
17
+
18
+ /** a group's label: the repo's icon and its name in its colour */
19
+ const GroupLabel = observer(({ app, g }: { app: HomeSt; g: PlacedGroup }) => {
20
+ const p = app.projects.list.find((x) => x.name === g.key)
21
+ const id = app.projects.identityOf(g.key)
22
+ return (
23
+ <button className="dgroup" onClick={() => app.dock.openRepo(g.key)}
24
+ title={`${g.key}: open the repo${g.hidden > 0 ? `\n${g.hidden} check run(s) did not fit in its lanes` : ''}`}>
25
+ {p?.icon != null ? <img src={p.icon} alt="" width={12} height={12} draggable={false} /> : <i className="dgico" style={{ background: id.colorDark }} />}
26
+ <span style={{ color: id.colorDark }}>{g.key}</span>
27
+ {/* slim folds its lanes on purpose: the count is news only open */}
28
+ {g.hidden > 0 && app.dock.size === 'open' ? <b>+{g.hidden}</b> : null}
29
+ </button>
30
+ )
31
+ })
32
+
33
+ /** one pill's face: the repo icon on the last run, its verdict, its words, its age */
34
+ const PillFace = observer(({ app, p }: { app: HomeSt; p: DockPill }) => {
35
+ const pj = p.repo == null ? null : app.projects.list.find((x) => x.name === p.repo)
36
+ return (
37
+ <span className={`pchip ${p.tone}`}>
38
+ {p.repo == null ? null : pj?.icon != null
39
+ ? <img src={pj.icon} alt="" width={12} height={12} draggable={false} />
40
+ : <i className="dgico" style={{ background: app.projects.identityOf(p.repo).colorDark }} />}
41
+ {p.mark == null ? null : <b className={`pcmark ${p.markTone ?? 'dim'}`}>{p.mark}</b>}
42
+ <span className="pctext">{p.text}</span>
43
+ {p.age == null ? null : <span className="pcage">{p.age}</span>}
44
+ </span>
45
+ )
46
+ })
47
+
48
+ export const Dock = observer(({ app }: { app: HomeSt }) => {
49
+ const d = app.dock
50
+ const v = d.view
51
+ const groups = d.groups
52
+ return (
53
+ <div className={`dock ${d.size}`}>
54
+ <div className="dhead">
55
+ <CycleButton options={DOCK_SIZES} value={d.size} onChange={(s) => d.setSize(s)} shortcut={DOCK_KEY} title={DOCK_HELP} />
56
+ <PillRow items={d.pills.map((p) => ({ key: p.key, title: p.title, node: <PillFace app={app} p={p} /> }))} />
57
+ {d.error !== '' ? <span className="warnline">{d.error}</span> : null}
58
+ </div>
59
+ {d.size === 'line' ? null : <div className="dbody">
60
+ {v == null ? null : groups.length === 0
61
+ ? <div className="dempty">nothing ran in the {windowWords(d.size === 'open' ? v.historyMs : SLIM_WINDOW_MS)}</div>
62
+ : <TimelineChart view={d.chart} compact={d.size === 'slim'} rows={d.rows} follow zoomable={d.size === 'open'}
63
+ onBar={(b) => { if (b.group != null) d.openCheck(b.group, b.id) }}
64
+ groupLabel={(g) => <GroupLabel app={app} g={g} />} />}
65
+ </div>}
66
+ </div>
67
+ )
68
+ })
@@ -0,0 +1,35 @@
1
+ /* ── the dock (dock-view.tsx): the run chart under the whole page, every
2
+ repo's runs, one group of lanes each. Its height is its panel's, set by
3
+ the page before the first paint, so nothing inside it sizes the page. The
4
+ header is one row, the chart under it scrolls when the groups outgrow it ── */
5
+ .dock{display:flex;flex-direction:column;height:100%;background:var(--bg-dock);font-size:11px;color:var(--dim)}
6
+ .dhead{display:flex;gap:var(--s2);align-items:center;height:22px;flex:none;padding:0 var(--s3)}
7
+ /* the status pills take the room left, and fold rather than wrap */
8
+ .dhead .pchips{flex:1}
9
+ .dbody{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 var(--s3) var(--s1);scrollbar-width:thin;scrollbar-color:var(--line) transparent}
10
+ .dempty{padding:var(--s1) 0;color:var(--dim)}
11
+ .dock .tlax.compact{height:auto;padding-top:0}
12
+ /* a group's label stands in a gutter left of the chart, level with its lanes */
13
+ .tlgroups{display:flex;align-items:flex-start}
14
+ .tlgroups > .tlax,.tlgroups > .tlchart{flex:1;min-width:0}
15
+ .tlgutter{position:relative;flex:none;width:132px}
16
+ .tlglbl{position:absolute;left:0;right:0;display:flex;align-items:center;overflow:hidden}
17
+ .dgroup{display:flex;gap:var(--s1);align-items:center;max-width:100%;padding:0;border:0;background:transparent;font-size:10px;line-height:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
18
+ .dgroup img{flex:none;border-radius:2px}
19
+ .dgroup span{overflow:hidden;text-overflow:ellipsis}
20
+ .dgroup:hover span{text-decoration:underline}
21
+ .dgroup b{font-weight:500;color:var(--warn)}
22
+ .dgico{flex:none;width:8px;height:8px;border-radius:2px}
23
+ .tlgrp{stroke:var(--line)}
24
+ /* the separator over the dock: a 1px rule, dragged only while the dock is open */
25
+ .rsep.hsep{width:auto;height:1px}
26
+ .rsep.hsep::before{top:-4px;bottom:-4px;left:0;right:0;cursor:row-resize}
27
+ .rsep.hsep.fixed::before{cursor:default;pointer-events:none}
28
+ .rsep.hsep.fixed:hover{background:var(--line)}
29
+ /* a size change of the dock slides the boundary (dock-anim.ts): the class is
30
+ on the split only while it runs, so a drag and the first paint stay instant */
31
+ .dockanim>[data-panel]{transition:flex-grow 180ms ease-out}
32
+ @media (prefers-reduced-motion:reduce){.dockanim>[data-panel]{transition:none}}
33
+ /* the runs page's record chart: its (?) in its top left corner */
34
+ .rchart{position:relative;flex:none;overflow:hidden;margin-bottom:var(--s2)}
35
+ .rchelp{position:absolute;top:0;left:0;z-index:4}
@@ -0,0 +1,49 @@
1
+ // THE way the page shows anything that floats over it: a tooltip, a help
2
+ // bubble, a process log on hover, a hover card. @floating-ui/react places it
3
+ // (flip to stay in the window, shift off its edges, an offset from the
4
+ // trigger), a portal lifts it out of every box that clips, and hover opens
5
+ // it after a short delay, so a pointer passing by never flickers one open.
6
+ // Nothing here is positioned by hand.
7
+ import { autoUpdate, flip, FloatingPortal, offset, safePolygon, shift, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
8
+ import { observer, useLocalObservable } from 'mobx-react-lite'
9
+ import type { HTMLAttributes, ReactNode } from 'react'
10
+
11
+ /** how long a pointer rests on a trigger before its float opens */
12
+ export const OPEN_MS = 150
13
+
14
+ /** the placement middleware every float shares; `fallbacks` are the sides
15
+ * tried in order when the chosen one does not fit */
16
+ export const middleware = (gap = 6, fallbacks?: Placement[]) => [offset(gap), flip({ padding: 8, fallbackPlacements: fallbacks }), shift({ padding: 8 })]
17
+
18
+ type TipProps = Omit<HTMLAttributes<HTMLSpanElement>, 'content'> & {
19
+ /** what floats; nothing floats while it is null */
20
+ content: ReactNode
21
+ /** the class of the floating box */
22
+ tipClass?: string
23
+ placement?: Placement
24
+ /** the sides tried in order when `placement` does not fit */
25
+ fallbacks?: Placement[]
26
+ /** the pointer can move into it (a log to scroll): it stays open on the way */
27
+ interactive?: boolean
28
+ }
29
+
30
+ /** a trigger and what floats over it on hover or focus */
31
+ export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, ...rest }: TipProps) => {
32
+ const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
33
+ const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(6, fallbacks), whileElementsMounted: autoUpdate })
34
+ const hover = useHover(f.context, { delay: { open: OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
35
+ const focus = useFocus(f.context)
36
+ const dismiss = useDismiss(f.context)
37
+ const role = useRole(f.context, { role: 'tooltip' })
38
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role])
39
+ return (
40
+ <>
41
+ <span ref={f.refs.setReference} {...getReferenceProps(rest)}>{children}</span>
42
+ {st.open && content != null ? (
43
+ <FloatingPortal>
44
+ <div ref={f.refs.setFloating} className={tipClass} style={f.floatingStyles} {...getFloatingProps()}>{content}</div>
45
+ </FloatingPortal>
46
+ ) : null}
47
+ </>
48
+ )
49
+ })
@@ -7,7 +7,7 @@
7
7
  import { runInAction } from 'mobx'
8
8
  import { observer } from 'mobx-react-lite'
9
9
  import { useRef } from 'react'
10
- import type { HubSt } from './hub-st.ts'
10
+ import type { RepoSt } from './repo-st.ts'
11
11
  import type { Leg } from './hub-types.ts'
12
12
 
13
13
  export type Box = { scale: number; x: number; y: number; w: number; h: number }
@@ -27,7 +27,7 @@ const LEG_TAGS = ['L·front', 'L·back', 'R·back', 'R·front']
27
27
 
28
28
  type DragStart = { px: number; py: number; x: number; y: number; w: number; legs: Leg[]; cy: number; mode: 'move' | 'resize' }
29
29
 
30
- const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number; leg: Leg }) => {
30
+ const LegBox = observer(({ app, box, i, leg }: { app: RepoSt; box: Box; i: number; leg: Leg }) => {
31
31
  const m = app.media
32
32
  const start = useRef<{ px: number; py: number; legs: Leg[]; mode: 'move' | 'resize' } | null>(null)
33
33
  const px = (v: number): number => v * box.scale
@@ -80,7 +80,7 @@ const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number
80
80
  )
81
81
  })
82
82
 
83
- export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | null }) => {
83
+ export const GeometryLayer = observer(({ app, box }: { app: RepoSt; box: Box | null }) => {
84
84
  const m = app.media
85
85
  const start = useRef<DragStart | null>(null)
86
86
  const rect = m.rect
@@ -137,7 +137,7 @@ export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | nu
137
137
  )
138
138
  })
139
139
 
140
- export const Rectbar = observer(({ app }: { app: HubSt }) => {
140
+ export const Rectbar = observer(({ app }: { app: RepoSt }) => {
141
141
  const m = app.media
142
142
  const rect = m.rect
143
143
  const showSize = m.tools.has('size')