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
@@ -5,8 +5,8 @@
5
5
  import { runInAction } from 'mobx'
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
8
- import type { HubSt } from './hub-st.ts'
9
- import { isAud, rollUrl, thumbUrl } from './media-svc.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
+ import { isAud, type MediaSvc } from './media-svc.ts'
10
10
  import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
11
11
  import type { CatRow, CustomPanelRef } from './hub-types.ts'
12
12
 
@@ -14,7 +14,7 @@ const TEX = 1024
14
14
  const MIX_UNIT = 24
15
15
  const MIX_GROUND = 44
16
16
 
17
- export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
17
+ export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
18
18
  const m = app.media
19
19
  const fileRef = useRef<HTMLInputElement>(null)
20
20
  const taRef = useRef<HTMLTextAreaElement>(null)
@@ -71,7 +71,7 @@ export const MediaTopbar = observer(({ app }: { app: HubSt }) => {
71
71
  )
72
72
  })
73
73
 
74
- export const InfoChips = observer(({ app }: { app: HubSt }) => {
74
+ export const InfoChips = observer(({ app }: { app: RepoSt }) => {
75
75
  const info = app.media.detail?.info
76
76
  if (info == null) return null
77
77
  const chips: { k: string; v: ReactNode; warn?: boolean }[] = [{ k: 'backend', v: info.backend }]
@@ -91,7 +91,7 @@ export const InfoChips = observer(({ app }: { app: HubSt }) => {
91
91
  )
92
92
  })
93
93
 
94
- export const AssetStage = observer(({ app }: { app: HubSt }) => {
94
+ export const AssetStage = observer(({ app }: { app: RepoSt }) => {
95
95
  const m = app.media
96
96
  const stageRef = useRef<HTMLDivElement>(null)
97
97
  const imgRef = useRef<HTMLImageElement>(null)
@@ -116,9 +116,9 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
116
116
  else if (isAud(shown)) content = (
117
117
  // key = the roll identity: heartbeats re-render but never recreate
118
118
  // the element, so playback survives; the src carries no cache-bust
119
- <div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={rollUrl(m.curId!, shown)} /></div>
119
+ <div className="stageaud" key={`${m.curId}/${shown}`}>🔊<audio controls src={m.rollUrl(m.curId!, shown)} /></div>
120
120
  )
121
- else content = <img className="roll" src={rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
121
+ else content = <img className="roll" src={m.rollUrl(m.curId!, shown)} ref={imgRef} onLoad={remeasure} />
122
122
  const imgShown = shown != null && !isAud(shown)
123
123
  const measured = imgShown && box != null
124
124
  return (
@@ -137,7 +137,7 @@ export const AssetStage = observer(({ app }: { app: HubSt }) => {
137
137
  )
138
138
  })
139
139
 
140
- export const ToolsBar = observer(({ app }: { app: HubSt }) => {
140
+ export const ToolsBar = observer(({ app }: { app: RepoSt }) => {
141
141
  const m = app.media
142
142
  const d = m.detail
143
143
  if (d == null || m.curId == null) return null
@@ -177,7 +177,7 @@ export const ToolsBar = observer(({ app }: { app: HubSt }) => {
177
177
 
178
178
  // variants live in a THIN resizable side bar — the stage keeps the room.
179
179
  // closed = a slim rail with the count; the drag handle resizes; drop = upload
180
- export const VariantsBar = observer(({ app }: { app: HubSt }) => {
180
+ export const VariantsBar = observer(({ app }: { app: RepoSt }) => {
181
181
  const m = app.media
182
182
  const rolls = m.detail?.rolls ?? []
183
183
  const genHere = m.curId != null && m.pending.includes(m.curId)
@@ -213,7 +213,7 @@ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
213
213
  onMouseEnter={() => { if (!isAud(x.name)) runInAction(() => { m.hover = x.name }) }}
214
214
  onMouseLeave={() => runInAction(() => { m.hover = null })}
215
215
  onClick={() => m.setActive(x.name)}>
216
- {isAud(x.name) ? <>🔊<audio controls src={rollUrl(m.curId!, x.name)} /></> : <img src={rollUrl(m.curId!, x.name)} />}
216
+ {isAud(x.name) ? <>🔊<audio controls src={m.rollUrl(m.curId!, x.name)} /></> : <img src={m.rollUrl(m.curId!, x.name)} />}
217
217
  {x.picked ? <span className="st">★</span> : null}
218
218
  <div className="btns">
219
219
  <button className="b-pick" onClick={(e) => { e.stopPropagation(); void m.pick(x.name) }}>pick</button>
@@ -228,7 +228,7 @@ export const VariantsBar = observer(({ app }: { app: HubSt }) => {
228
228
  })
229
229
 
230
230
  // ── category grid + size-true lineup ─────────────────────────────────────
231
- const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
231
+ const MixLineup = observer(({ m, rows }: { m: MediaSvc; rows: CatRow[] }) => {
232
232
  const items = rows.filter((r) => !r.icon && r.rect != null).sort((a, b) => a.rect!.size - b.rect!.size || a.label.localeCompare(b.label))
233
233
  const boxH = 420
234
234
  const baseY = boxH - MIX_GROUND
@@ -241,7 +241,7 @@ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
241
241
  cx += slot / 2
242
242
  const fx = rect.flip === true ? TEX - rect.x - rect.w : rect.x
243
243
  sprites.push(
244
- <img key={it.id} className="mixsprite" src={thumbUrl(it.id)} style={{
244
+ <img key={it.id} className="mixsprite" src={m.thumbUrl(it.id)} style={{
245
245
  width: TEX * ts, height: TEX * ts,
246
246
  left: cx - (fx + rect.w / 2) * ts, top: baseY - (rect.y + rect.h / 2) * ts,
247
247
  transform: rect.flip === true ? 'scaleX(-1)' : undefined, zIndex: Math.round(cx),
@@ -262,7 +262,7 @@ const MixLineup = observer(({ rows }: { rows: CatRow[] }) => {
262
262
  )
263
263
  })
264
264
 
265
- export const CatGrid = observer(({ app }: { app: HubSt }) => {
265
+ export const CatGrid = observer(({ app }: { app: RepoSt }) => {
266
266
  const m = app.media
267
267
  const cat = m.catMode
268
268
  if (cat == null) return null
@@ -288,9 +288,9 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
288
288
  // inner controls stopPropagation
289
289
  <div className="card" key={row.id} onClick={() => app.selectAsset(row.id)}>
290
290
  {isAud(row.name)
291
- ? <div className="aud">🔊<audio controls src={rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
291
+ ? <div className="aud">🔊<audio controls src={m.rollUrl(row.id, row.name)} onClick={(e) => e.stopPropagation()} />{m.pending.includes(row.id) ? <div className="spin" /> : null}</div>
292
292
  : <div className="th">
293
- <img src={thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
293
+ <img src={m.thumbUrl(row.id)} style={fl ? { transform: 'scaleX(-1)' } : undefined} />
294
294
  {row.rect != null ? <div className="ovmini" style={{
295
295
  left: pc(fl ? TEX - row.rect.x - row.rect.w : row.rect.x), top: pc(row.rect.y),
296
296
  width: pc(row.rect.w), height: pc(row.rect.h),
@@ -314,13 +314,13 @@ export const CatGrid = observer(({ app }: { app: HubSt }) => {
314
314
  )
315
315
  })}
316
316
  </div>
317
- <MixLineup rows={m.catRows} />
317
+ <MixLineup m={m} rows={m.catRows} />
318
318
  </div>
319
319
  )
320
320
  })
321
321
 
322
322
  // ── dock: the active tools' panels, side by side under the stage ─────────
323
- const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
323
+ const PanelHost = observer(({ app, c }: { app: RepoSt; c: CustomPanelRef }) => {
324
324
  const m = app.media
325
325
  const ref = useRef<HTMLDivElement>(null)
326
326
  const key = `${m.curId}|${m.active}|${c.id}`
@@ -337,7 +337,7 @@ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
337
337
  void (async () => {
338
338
  try {
339
339
  // repo-defined editor module, served by the media API
340
- mod = (await import(`/media/${c.url}`)) as Record<string, unknown>
340
+ mod = (await import(app.url(`/media/${c.url}`))) as Record<string, unknown>
341
341
  if (typeof mod.mount !== 'function') throw new Error(`${c.url} must export mount(el, api)`)
342
342
  if (!dead) await (mod.mount as (el: HTMLElement, api: unknown) => unknown)(host, api)
343
343
  } catch (e) {
@@ -357,7 +357,7 @@ const PanelHost = observer(({ app, c }: { app: HubSt; c: CustomPanelRef }) => {
357
357
  // floating panels sit ON the canvas (drag by the title bar), so toggling
358
358
  // them never reflows the stage — canvas size/position survive tab switches
359
359
  const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
360
- app: HubSt; id: string; title: string; dx: number; dy: number; anchor?: 'tl' | 'bl'; children: ReactNode
360
+ app: RepoSt; id: string; title: string; dx: number; dy: number; anchor?: 'tl' | 'bl'; children: ReactNode
361
361
  }) => {
362
362
  const m = app.media
363
363
  const ref = useRef<HTMLDivElement>(null)
@@ -408,7 +408,7 @@ const FloatPanel = observer(({ app, id, title, dx, dy, anchor, children }: {
408
408
  )
409
409
  })
410
410
 
411
- export const FloatPanels = observer(({ app }: { app: HubSt }) => {
411
+ export const FloatPanels = observer(({ app }: { app: RepoSt }) => {
412
412
  const m = app.media
413
413
  const customs = (m.detail?.asset.custom ?? []).filter((c) => m.tools.has(`c:${c.id}`))
414
414
  const showJson = m.tools.has('json')
@@ -1,8 +1,9 @@
1
- // WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check on a clock
2
- // except the hub's daily health pass (serve/health-schedule.ts), so the answer
3
- // is read off where the check runs (`when`), what it remembers, and that one
4
- // timer. Pure, so every case is pinned by a test rather than a screenshot.
5
- import type { Schedule, CheckFacts } from './hub-types.ts'
1
+ // WHEN A CHECK RUNS NEXT, as a true statement. Nothing runs a check nobody
2
+ // asked for but the plan (plan/planner.ts), so the answer is where the check
3
+ // runs (`when`) and its row of that plan. Pure, so every case is pinned by a
4
+ // test rather than a screenshot.
5
+ import type { PlanRow } from '../../plan/planner.ts'
6
+ import type { CheckFacts } from './hub-types.ts'
6
7
 
7
8
  export type NextRun = {
8
9
  /** the value the page shows */
@@ -37,32 +38,25 @@ export function spanWords(ms: number): string {
37
38
  return `${Math.round(ms / 1000)}s`
38
39
  }
39
40
 
40
- export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'> & { keepsUntil?: number | null }
41
+ export type NextRunFacts = Pick<CheckFacts, 'when' | 'whenReason'>
41
42
 
42
- export function nextRun(u: NextRunFacts, s: Schedule | null, now: number): NextRun {
43
- if (u.when === 'always')
44
- return { words: 'next shipkit ci', at: null, help: 'it runs on every `shipkit ci` and in every health pass. A cached answer is used when nothing it depends on moved' }
45
- if (u.when === 'release')
46
- return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it, and so does the health pass. The quick gate you run while working does not' }
43
+ /** a plan row in a few words: why it is due, or what makes it due */
44
+ export function planWords(row: PlanRow, now: number): { words: string; at: number | null } {
45
+ if (row.state === 'running') return { words: 'running now', at: row.dueAt }
46
+ if (row.state === 'due') return { words: row.reason === 'asked' ? 'due now, asked' : row.reason === 'changed' ? 'due now, a save moved it' : 'due now', at: row.dueAt }
47
+ if (row.state === 'late') return { words: 'waits for the repo to be active', at: row.dueAt }
48
+ if (row.state === 'next' && row.dueAt != null) return { words: `in ${aheadWords(row.dueAt - now)}`, at: row.dueAt }
49
+ return { words: 'on the next save that moves what it reads', at: null }
50
+ }
51
+
52
+ export function nextRun(u: NextRunFacts, row: PlanRow | null, now: number): NextRun {
47
53
  if (u.when === 'off')
48
54
  return { words: u.whenReason != null && u.whenReason !== '' ? `never: ${u.whenReason}` : 'never', at: null, help: 'it is off: nothing runs it until the switch below puts it somewhere' }
49
- if (s == null)
50
- return { words: 'next health pass', at: null, help: 'this hub runs no daily timer, so it runs when `shipkit check` runs or when you press run the health pass' }
51
- if (s.running) return { words: 'running now', at: now, help: 'the daily health pass is running right now' }
52
- const pass = Math.max(now, s.nextAt)
53
- const keeps = u.keepsUntil ?? null
54
- if (keeps != null && keeps > pass) {
55
- const k = Math.ceil((keeps - pass) / s.everyMs)
56
- const at = pass + k * s.everyMs
57
- return {
58
- words: `in ${aheadWords(at - now)}`,
59
- at,
60
- help: `the health pass runs every ${spanWords(s.everyMs)}, and uses this check's cached answer until it runs out. The first pass after that runs it again`,
61
- }
62
- }
63
- return {
64
- words: pass <= now ? 'due now' : `in ${aheadWords(pass - now)}`,
65
- at: pass,
66
- help: `the next health pass: the hub runs it every ${spanWords(s.everyMs)} while it serves, and at startup when the last one is older than that`,
67
- }
55
+ if (u.when === 'release')
56
+ return { words: 'before a release', at: null, help: '`shipkit ci --full` and the release path run it. The plan and the quick gate you run while working do not' }
57
+ if (row == null)
58
+ return { words: u.when === 'always' ? 'next shipkit ci' : 'next shipkit check', at: null, help: 'no plan covers it here, so it runs when a terminal or a button runs it' }
59
+ const w = planWords(row, now)
60
+ const inactive = row.state === 'late' || !row.active ? ` While the repo is inactive: ${row.inactive}.` : ''
61
+ return { words: w.words, at: w.at, help: `planned by: ${row.rule}.${inactive}${row.error != null ? ` Error: ${row.error}.` : ''} A shipkit ci or check from a terminal runs it too` }
68
62
  }
@@ -0,0 +1,92 @@
1
+ // the one picker of the page (cmd+k, keys.ts), a node of the root: the
2
+ // home's places (home, planned, activity) first, then the shown repo's own jump
3
+ // targets, then every other house repo as a place to go. Another repo's
4
+ // resources are never listed: a jump to a repo selects it in place, and its
5
+ // own targets are one more cmd+k away.
6
+ import { makeAutoObservable } from 'mobx'
7
+ import type { HomeSt } from './home-st.ts'
8
+ import type { Jump } from './repo-st.ts'
9
+
10
+ /** how many rows the picker shows */
11
+ export const PAL_ROWS = 12
12
+
13
+ export class PaletteSt {
14
+ open = false
15
+ query = ''
16
+ idx = 0
17
+
18
+ constructor(private st: HomeSt) {
19
+ makeAutoObservable<this, 'st'>(this, { st: false })
20
+ }
21
+
22
+ /** the home's places, first in the list: a hub of its own has none */
23
+ get places(): Jump[] {
24
+ const st = this.st
25
+ if (st.machine == null) return []
26
+ return [
27
+ { id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
28
+ { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
29
+ { id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
30
+ { id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
31
+ ]
32
+ }
33
+
34
+ /** every house repo but the one on screen, as a place to go: on a
35
+ * global page that is every repo */
36
+ get others(): Jump[] {
37
+ const st = this.st
38
+ if (st.machine == null) return []
39
+ const on = st.shown?.key
40
+ return st.projects.list.filter((p) => p.name !== on).map((p) => ({ id: `repo:${p.name}`, label: p.name, section: 'repo', face: { repo: p.name }, run: () => st.select(p.name) }))
41
+ }
42
+
43
+ get jumps(): Jump[] {
44
+ return [...this.places, ...(this.st.shown?.jumps ?? []), ...this.others]
45
+ }
46
+
47
+ /** ranked: prefix matches first, then substring. With no query, home,
48
+ * planned and activity, then the shown repo's first targets, then every other repo,
49
+ * so a jump elsewhere is always in reach */
50
+ get results(): Jump[] {
51
+ const q = this.query.trim().toLowerCase()
52
+ if (q === '') {
53
+ const own = this.st.shown?.jumps ?? []
54
+ const places = this.places
55
+ const others = this.others
56
+ return [...places, ...own.slice(0, Math.max(4, PAL_ROWS - places.length - others.length)), ...others]
57
+ }
58
+ const pre: Jump[] = []
59
+ const sub: Jump[] = []
60
+ for (const j of this.jumps) {
61
+ const l = j.label.toLowerCase()
62
+ if (l.startsWith(q)) pre.push(j)
63
+ else if (l.includes(q)) sub.push(j)
64
+ }
65
+ return [...pre, ...sub].slice(0, PAL_ROWS * 2)
66
+ }
67
+
68
+ toggle(): void {
69
+ this.open = !this.open
70
+ this.query = ''
71
+ this.idx = 0
72
+ }
73
+ close(): void {
74
+ this.open = false
75
+ }
76
+ setQuery(v: string): void {
77
+ this.query = v
78
+ this.idx = 0
79
+ }
80
+ setIdx(n: number): void {
81
+ this.idx = n
82
+ }
83
+ move(d: number): void {
84
+ const n = this.results.length
85
+ if (n > 0) this.idx = (this.idx + d + n) % n
86
+ }
87
+ run(j: Jump | undefined = this.results[this.idx]): void {
88
+ if (j == null) return
89
+ this.open = false
90
+ j.run()
91
+ }
92
+ }
@@ -0,0 +1,47 @@
1
+ // the one picker, cmd+k (palette-st.ts): home, planned, activity and
2
+ // archived, the shown repo's targets, then every other repo. A place wears
3
+ // its button's icon, a repo its own icon, as the project list shows them.
4
+ import { observer } from 'mobx-react-lite'
5
+ import type { HomeSt } from './home-st.ts'
6
+ import { Icon, ICON } from './icons.tsx'
7
+ import { Face } from './project-list.tsx'
8
+ import type { JumpFace } from './repo-st.ts'
9
+
10
+ /** the icon a picker entry wears; an empty slot keeps the labels aligned */
11
+ const PalFace = observer(({ app, face }: { app: HomeSt; face: JumpFace | undefined }) => {
12
+ if (face != null && 'place' in face) return <span className="pface"><Icon d={ICON[face.place]} /></span>
13
+ const p = face == null ? null : app.projects.list.find((x) => x.name === face.repo) ?? null
14
+ return <span className="pface">{p == null ? null : <Face p={p} px={16} />}</span>
15
+ })
16
+
17
+ export const Palette = observer(({ app }: { app: HomeSt }) => {
18
+ const p = app.palette
19
+ if (!p.open) return null
20
+ return (
21
+ <div className="palov" onClick={() => p.close()}>
22
+ <div className="pal" onClick={(e) => e.stopPropagation()}>
23
+ <input autoFocus placeholder="jump to…" value={p.query}
24
+ onChange={(e) => p.setQuery(e.target.value)}
25
+ onKeyDown={(e) => {
26
+ if (e.key === 'ArrowDown') { e.preventDefault(); p.move(1) }
27
+ else if (e.key === 'ArrowUp') { e.preventDefault(); p.move(-1) }
28
+ else if (e.key === 'Enter') p.run()
29
+ else if (e.key === 'Escape') p.close()
30
+ }} />
31
+ <div className="prows">
32
+ {p.results.map((jp, n) => (
33
+ <div key={jp.id} className={`prow${n === p.idx ? ' on' : ''}`}
34
+ onMouseEnter={() => p.setIdx(n)}
35
+ onClick={() => p.run(jp)}>
36
+ <PalFace app={app} face={jp.face} />
37
+ <span className="plabel">{jp.label}</span>
38
+ <span className="pchip">{jp.section}</span>
39
+ {jp.key == null ? null : <kbd className="pjkey">{jp.key}</kbd>}
40
+ </div>
41
+ ))}
42
+ {p.results.length === 0 ? <div className="prow none">no match</div> : null}
43
+ </div>
44
+ </div>
45
+ </div>
46
+ )
47
+ })
@@ -0,0 +1,65 @@
1
+ // a row of small boxes that stays one row at any width: the ones that do not
2
+ // fit fold into a `+N` box at its end, whose hover lists them. An invisible
3
+ // copy of every box, out of the flow, gives their widths, so what shows is
4
+ // decided before the paint and never wraps. Each box's hover is its title.
5
+ import { observer, useLocalObservable } from 'mobx-react-lite'
6
+ import { useLayoutEffect, useRef, type ReactNode } from 'react'
7
+ import { Tip } from './floating.tsx'
8
+
9
+ export type PillItem = { key: string; node: ReactNode; title: string }
10
+
11
+ /** how many boxes of these widths fit in `avail`, a `+N` box of `plusW`
12
+ * taking its room as soon as one does not */
13
+ export function fitCount(widths: number[], avail: number, plusW: number, gap: number): number {
14
+ const all = widths.reduce((a, w) => a + w, 0) + gap * Math.max(0, widths.length - 1)
15
+ if (all <= avail) return widths.length
16
+ let used = plusW
17
+ for (let i = 0; i < widths.length; i++) {
18
+ used += widths[i]! + gap
19
+ if (used > avail) return i
20
+ }
21
+ return widths.length
22
+ }
23
+
24
+ const lines = (text: string): ReactNode => text.split('\n').map((l, i) => <div key={i}>{l}</div>)
25
+
26
+ export const PillRow = observer(({ items, className }: { items: PillItem[]; className?: string }) => {
27
+ const st = useLocalObservable(() => ({ n: Number.POSITIVE_INFINITY, set(v: number) { this.n = v } }))
28
+ const box = useRef<HTMLDivElement | null>(null)
29
+ const probe = useRef<HTMLDivElement | null>(null)
30
+ const measure = (): void => {
31
+ const b = box.current
32
+ const p = probe.current
33
+ if (b == null || p == null) return
34
+ const kids = [...p.children] as HTMLElement[]
35
+ const plus = kids.pop()
36
+ if (plus == null) return
37
+ const gap = Number.parseFloat(getComputedStyle(b).columnGap) || 0
38
+ st.set(fitCount(kids.map((k) => k.getBoundingClientRect().width), b.clientWidth, plus.getBoundingClientRect().width, gap))
39
+ }
40
+ // after every render: the boxes' words change with the clock
41
+ useLayoutEffect(measure)
42
+ useLayoutEffect(() => {
43
+ const b = box.current
44
+ if (b == null || typeof ResizeObserver === 'undefined') return
45
+ const ro = new ResizeObserver(() => measure())
46
+ ro.observe(b)
47
+ return () => ro.disconnect()
48
+ }, [])
49
+ const n = Math.min(st.n, items.length)
50
+ const hidden = items.slice(n)
51
+ return (
52
+ <div ref={box} className={`pchips${className == null ? '' : ` ${className}`}`}>
53
+ {items.slice(0, n).map((it) => <Tip key={it.key} className="pchipw" content={lines(it.title)}>{it.node}</Tip>)}
54
+ {hidden.length === 0 ? null : (
55
+ <Tip className="pchipw" content={hidden.map((h) => <div key={h.key}>{lines(h.title)}</div>)}>
56
+ <span className="pchip dim">+{hidden.length}</span>
57
+ </Tip>
58
+ )}
59
+ <div ref={probe} className="pchips pchipprobe" aria-hidden="true">
60
+ {items.map((it) => <span key={it.key} className="pchipw">{it.node}</span>)}
61
+ <span className="pchip dim">+{items.length}</span>
62
+ </div>
63
+ </div>
64
+ )
65
+ })
@@ -0,0 +1,107 @@
1
+ // the planned page's state: the machine's one plan as the daemon answers it,
2
+ // as ONE list ordered by time (now, next), filtered by a text that
3
+ // matches the project or the check. A check that only waits for a save has no
4
+ // time, so it is not listed. The order and every word a row says are pure
5
+ // functions, so a test pins them rather than a screenshot.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import type { PlannedAnswer } from '../../home/home-routes.ts'
8
+ import type { PlanRow } from '../../plan/planner.ts'
9
+ import { sectionOf, timeline, titleWords, waitingRows, waitingWords } from '../../plan/plan-timeline.ts'
10
+ import type { HomeSt } from './home-st.ts'
11
+
12
+ export const PLANNED_POLL_MS = 1_000
13
+
14
+ export * from '../../plan/plan-timeline.ts'
15
+
16
+ export class PlannedSt {
17
+ data: PlannedAnswer | null = null
18
+ error = ''
19
+ /** the text filter, the `?q=` of /planned: every word must be in the project or the check */
20
+ query = ''
21
+ /** the checks waiting for their repo to be active, listed under their line */
22
+ waitingOpen = false
23
+ private timer: ReturnType<typeof setInterval> | null = null
24
+
25
+ constructor(private st: HomeSt) {
26
+ makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false, data: observableRef })
27
+ }
28
+
29
+ get rows() {
30
+ return timeline(this.data?.rows ?? [], this.query)
31
+ }
32
+
33
+ /** the checks waiting for their repo to be active, the filter applied */
34
+ get waiting(): PlanRow[] {
35
+ return waitingRows(this.data?.rows ?? [], this.query)
36
+ }
37
+
38
+ /** their one line, or '' */
39
+ get waitingLine(): string {
40
+ return waitingWords(this.data?.rows ?? [], this.query)
41
+ }
42
+
43
+ toggleWaiting(): void {
44
+ this.waitingOpen = !this.waitingOpen
45
+ }
46
+
47
+ /** how many checks have a time of their own, filter aside */
48
+ get timed(): number {
49
+ return (this.data?.rows ?? []).filter((r) => sectionOf(r) != null).length
50
+ }
51
+
52
+ /** the address of the plan with this filter */
53
+ get address(): string {
54
+ const q = this.query.trim()
55
+ return q === '' ? '/planned' : `/planned?q=${encodeURIComponent(q)}`
56
+ }
57
+
58
+ /** a keystroke in the filter: the url follows, in place */
59
+ setQuery(q: string): void {
60
+ this.query = q
61
+ if (this.st.page === 'planned') window.history.replaceState(null, '', this.address)
62
+ }
63
+
64
+ /** the project name: that repo, in place */
65
+ openRepo(repo: string): void {
66
+ this.st.select(repo)
67
+ }
68
+
69
+ openCheck(repo: string, check: string): void {
70
+ this.st.openCheck(repo, check)
71
+ }
72
+
73
+ /** the check's title as its repo's hub names it, once that hub was read */
74
+ titleOf(repo: string, check: string): string | null {
75
+ const t = this.st.repos.get(repo)?.cols.find('checks', `check:${check}`)?.title ?? null
76
+ return titleWords(t, check)
77
+ }
78
+
79
+ async poll(): Promise<void> {
80
+ try {
81
+ const r = await fetch('/api/home/planned.json', { signal: AbortSignal.timeout(8_000) })
82
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
83
+ const j = (await r.json()) as PlannedAnswer
84
+ runInAction(() => { this.data = j; this.error = '' })
85
+ } catch (e) {
86
+ runInAction(() => { this.error = (e as Error).message })
87
+ }
88
+ }
89
+
90
+ async run(row: PlanRow): Promise<void> {
91
+ const r = await fetch('/api/home/planned/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo: row.repo, check: row.check }) })
92
+ if (!r.ok) runInAction(() => { this.error = `run ${row.check}: ${r.status} ${r.statusText}` })
93
+ await this.poll()
94
+ }
95
+
96
+ /** polls while the plan is the page shown */
97
+ start(): void {
98
+ if (this.timer != null) return
99
+ void this.poll()
100
+ this.timer = setInterval(() => void this.poll(), PLANNED_POLL_MS)
101
+ }
102
+
103
+ stop(): void {
104
+ if (this.timer != null) clearInterval(this.timer)
105
+ this.timer = null
106
+ }
107
+ }
@@ -0,0 +1,114 @@
1
+ // `/planned`: every check the machine runs on its own time, in ONE list
2
+ // ordered by time under two small labels (now, next), then one quiet line
3
+ // counting the checks that wait for their repo to be active, which opens on a
4
+ // click. A row says
5
+ // when, which project and check, and one mark; everything else (the rule,
6
+ // the reason, the last result, the inactive policy, an error, run now) is in
7
+ // the card that floats over the row on hover. The project opens its repo in
8
+ // place, the check opens its own page in that repo.
9
+ import { observer } from 'mobx-react-lite'
10
+ import type { CSSProperties, MouseEvent, ReactNode } from 'react'
11
+ import type { PlanRow } from '../../plan/planner.ts'
12
+ import { Tip } from './floating.tsx'
13
+ import type { HomeSt } from './home-st.ts'
14
+ import { checkAddress, plainClick, repoPage } from './home-st.ts'
15
+ import { LAST_TONE, markOf, reasonWords, whenWords, type Section } from './planned-st.ts'
16
+ import { ageWords, stampWords } from './time-ago.tsx'
17
+
18
+ /** a link that works as one (a new tab on cmd+click) and moves in place on a plain click */
19
+ const InPlace = ({ href, className, go, children }: { href: string; className: string; go: () => void; children: ReactNode }) => (
20
+ <a className={className} href={href} onClick={(e: MouseEvent) => {
21
+ if (!plainClick(e)) return
22
+ e.preventDefault()
23
+ go()
24
+ }}>{children}</a>
25
+ )
26
+
27
+ /** the repo's rail icon, or nothing */
28
+ const Face = observer(({ app, repo }: { app: HomeSt; repo: string }) => {
29
+ const icon = app.projects.list.find((p) => p.name === repo)?.icon ?? null
30
+ return icon == null ? null : <img className="plface" src={icon} alt="" width={14} height={14} draggable={false} />
31
+ })
32
+
33
+ /** one line of the card: a dim label and its value */
34
+ const Line = ({ k, children }: { k: string; children: ReactNode }) => (
35
+ <div className="plkv"><span>{k}</span><span>{children}</span></div>
36
+ )
37
+
38
+ const Card = observer(({ app, r, now }: { app: HomeSt; r: PlanRow; now: number }) => {
39
+ const st = app.planned
40
+ const title = st.titleOf(r.repo, r.check)
41
+ const running = r.state === 'running'
42
+ return (
43
+ <div className="plcardin">
44
+ <div className="plct">{r.repo} · {r.check}{title != null ? <span className="pldim"> {title}</span> : null}</div>
45
+ {r.error != null ? <div className="plerr">{r.error}</div> : null}
46
+ <Line k="when">{r.dueAt == null ? whenWords(r, now) : `${whenWords(r, now)}, ${stampWords(r.dueAt)}`}</Line>
47
+ {r.reason != null ? <Line k="why">{reasonWords(r)}</Line> : null}
48
+ <Line k="rule">{r.rule}</Line>
49
+ <Line k="inactive">{r.active ? r.inactive : `repo inactive: ${r.inactive}`}</Line>
50
+ <Line k="last">
51
+ {r.last == null ? 'never ran' : (
52
+ <span className={`l-${LAST_TONE[r.last.status]}`}>
53
+ {r.last.status}, {ageWords(r.last.at, now)}{r.last.streak > 1 ? `, ${r.last.streak} in a row` : ''}
54
+ </span>
55
+ )}
56
+ </Line>
57
+ <button className="plgo" disabled={running} onClick={() => void st.run(r)}>{running ? 'running' : 'run now'}</button>
58
+ </div>
59
+ )
60
+ })
61
+
62
+ const Row = observer(({ app, r, now }: { app: HomeSt; r: PlanRow; now: number }) => {
63
+ const st = app.planned
64
+ const m = markOf(r)
65
+ const colour = { '--pc': app.projects.identityOf(r.repo).colorDark } as CSSProperties
66
+ return (
67
+ <Tip className={`plr m-${m.mark}`} content={<Card app={app} r={r} now={now} />} tipClass="plcard" placement="right-start" fallbacks={['bottom-end', 'top-end']} interactive>
68
+ <span className="plwhen">{whenWords(r, now)}</span>
69
+ <span className="plwhat">
70
+ <InPlace className="plrepo" href={repoPage(r.repo)} go={() => st.openRepo(r.repo)}>
71
+ <Face app={app} repo={r.repo} />
72
+ <span style={colour}>{r.repo}</span>
73
+ </InPlace>
74
+ <span className="pldim"> · </span>
75
+ <InPlace className="plcheck" href={checkAddress(r.repo, r.check)} go={() => st.openCheck(r.repo, r.check)}>{r.check}</InPlace>
76
+ </span>
77
+ <span className="plmark">
78
+ {m.mark === 'running' ? <i className="spin" /> : <i className={`plsym s-${m.mark}`} />}
79
+ {m.words !== '' ? <span>{m.words}</span> : null}
80
+ </span>
81
+ </Tip>
82
+ )
83
+ })
84
+
85
+ export const PlannedView = observer(({ app }: { app: HomeSt }) => {
86
+ const st = app.planned
87
+ const d = st.data
88
+ const now = d?.now ?? Date.now()
89
+ let prev: Section | null = null
90
+ return (
91
+ <div className="hall planned">
92
+ <input className="plfind" type="search" placeholder="filter" aria-label="filter by project or check" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
93
+ {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
94
+ {(d?.held ?? []).map((h) => <div key={h} className="pldim">{h}</div>)}
95
+ {(d?.errors ?? []).map((e) => <div key={e.repo} className="hnote">{e.repo}: {e.error}</div>)}
96
+ <div className="pllist">
97
+ {st.rows.flatMap(({ section, row }) => {
98
+ const item = <Row key={`${row.repo}\0${row.check}`} app={app} r={row} now={now} />
99
+ if (section === prev) return [item]
100
+ prev = section
101
+ return [<div key={`s:${section}`} className="plsec">{section}</div>, item]
102
+ })}
103
+ {d == null ? <div className="pldim">loading…</div> : st.rows.length === 0 ? <div className="pldim">{st.timed === 0 ? 'nothing is planned' : 'nothing matches'}</div> : null}
104
+ </div>
105
+ {st.waitingLine !== '' ? (
106
+ <div className="plwait">
107
+ <button className="plwaitline" aria-expanded={st.waitingOpen} onClick={() => st.toggleWaiting()}>{st.waitingOpen ? '▾' : '▸'} {st.waitingLine}</button>
108
+ {st.waitingOpen ? <div className="pllist">{st.waiting.map((r) => <Row key={`${r.repo}\0${r.check}`} app={app} r={r} now={now} />)}</div> : null}
109
+ </div>
110
+ ) : null}
111
+ <p className="hdim">in the console: shipkit planned</p>
112
+ </div>
113
+ )
114
+ })