rvlib-shipkit 1.3.0 → 1.4.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 (80) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +4 -2
  4. package/src/assets/kits/identity-kit.ts +15 -13
  5. package/src/assets/run-assets.ts +12 -2
  6. package/src/check.ts +6 -4
  7. package/src/checks/SK001-package-json-shape.check.ts +20 -2
  8. package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
  9. package/src/checks/SK014-prose-ste.check.ts +16 -6
  10. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  11. package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
  12. package/src/checks/SK028-readme.check.ts +6 -2
  13. package/src/cli/cmd_CrossRepoActivity.ts +4 -1
  14. package/src/collections/collection-types.ts +15 -0
  15. package/src/collections/providers.ts +6 -2
  16. package/src/contracts/apply-contracts.ts +9 -8
  17. package/src/contracts/contract-types.ts +8 -2
  18. package/src/contracts/contracts-engine.ts +34 -13
  19. package/src/contracts/manifests/identity.contract.ts +6 -5
  20. package/src/contracts/manifests/social.contract.ts +4 -2
  21. package/src/contracts/manifests/steam.contract.ts +7 -3
  22. package/src/contracts/web-surface.ts +59 -0
  23. package/src/core/budget.ts +19 -9
  24. package/src/core/check-runner.ts +26 -5
  25. package/src/core/discover-checks.ts +8 -5
  26. package/src/core/layout.ts +2 -0
  27. package/src/core/motherhome.ts +88 -66
  28. package/src/core/run-check.ts +2 -5
  29. package/src/core/run-ci.ts +3 -3
  30. package/src/core/wire.ts +38 -9
  31. package/src/home/activity-shape.ts +4 -1
  32. package/src/home/activity.ts +22 -15
  33. package/src/home/gui-app/ShipkitGui.swift +144 -25
  34. package/src/home/gui-app.ts +93 -30
  35. package/src/home/home-launchd.ts +10 -2
  36. package/src/home/home-routes.ts +2 -1
  37. package/src/home/home-start.ts +33 -5
  38. package/src/home/machine-stores.ts +4 -2
  39. package/src/home/open-app.ts +23 -10
  40. package/src/home/pm-config.ts +15 -11
  41. package/src/home/pm-inputs.ts +76 -0
  42. package/src/home/pm.ts +3 -3
  43. package/src/home/process-rows.ts +23 -6
  44. package/src/home/resources.ts +67 -15
  45. package/src/home/run-daemon.ts +6 -1
  46. package/src/home/run-pm.ts +29 -5
  47. package/src/home/widgets.ts +32 -3
  48. package/src/hooks/commit-message.ts +2 -3
  49. package/src/hooks/run-hooks.ts +12 -2
  50. package/src/ledger/gate-checks.ts +3 -3
  51. package/src/ledger/store.ts +11 -0
  52. package/src/ledger/world-cache.ts +17 -15
  53. package/src/repo-config.ts +6 -1
  54. package/src/serve/hub-app/activity-view.tsx +3 -3
  55. package/src/serve/hub-app/checks-svc.ts +2 -1
  56. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  57. package/src/serve/hub-app/col-tables.ts +115 -0
  58. package/src/serve/hub-app/cols-svc.ts +28 -0
  59. package/src/serve/hub-app/dock.css +1 -1
  60. package/src/serve/hub-app/home-st.ts +1 -1
  61. package/src/serve/hub-app/home-strip.css +32 -17
  62. package/src/serve/hub-app/home-strip.tsx +99 -89
  63. package/src/serve/hub-app/hub-app.tsx +7 -1
  64. package/src/serve/hub-app/hub-types.ts +6 -3
  65. package/src/serve/hub-app/hub.css +24 -2
  66. package/src/serve/hub-app/icons.tsx +4 -0
  67. package/src/serve/hub-app/keys.ts +6 -6
  68. package/src/serve/hub-app/machine-st.ts +26 -37
  69. package/src/serve/hub-app/palette-st.ts +5 -5
  70. package/src/serve/hub-app/palette-view.tsx +1 -1
  71. package/src/serve/hub-app/project-list.tsx +3 -3
  72. package/src/serve/hub-app/repo-st.ts +1 -1
  73. package/src/serve/hub-app/runs-tabs.ts +1 -1
  74. package/src/serve/hub-app/timeline-view.tsx +14 -11
  75. package/src/serve/project-gutter.ts +1 -2
  76. package/src/serve/warm-cell.ts +49 -16
  77. package/src/suite/judge-suites.ts +42 -17
  78. package/src/suite/suite-cli.ts +5 -8
  79. package/src/wiki/lint-md.ts +2 -0
  80. package/src/wiki/scan-md.ts +37 -10
@@ -5,21 +5,21 @@
5
5
  // (header-bar.tsx); on the overview and the plan, the page's name. At the
6
6
  // far right, only machine wide trouble, and only while it lasts (pm or the
7
7
  // daemon down, a child or a global process crashed). The places themselves
8
- // are the place buttons at the top of the project list (⌘J, ⌘L, ⌘', the
9
- // picker's ⌘K between the first two) and the first three entries of the
8
+ // are the place buttons at the top of the project list (the picker's ⌘J,
9
+ // then ⌘K, ⌘L, ⌘') and the first three entries of the
10
10
  // picker. What administers one repo (reload its hub, keep it active, archive
11
11
  // it) is its admin page (admin-view.tsx). NotUp stands in for the hub page
12
12
  // while its hub cannot answer, with the fix. Every link of the home is a
13
13
  // page of the same app: a plain click moves it in place. The daemon or pm
14
- // down is said in MachineBar, a reserved line the strip brings with it,
15
- // drawn last in the page column (its flex order).
14
+ // down is said in MachineToasts, floating over the page column's bottom
15
+ // right corner, taking no room.
16
16
  import { observer } from 'mobx-react-lite'
17
17
  import type { ReactNode } from 'react'
18
18
  import type { ProcessIcon, ResourceView } from '../../home/resources.ts'
19
19
  import type { WorkerView } from '../../home/hubs.ts'
20
20
  import { NOT_INSTALLED } from '../../home/home-words.ts'
21
21
  import { splitWidgetHead } from '../../home/widget-text.ts'
22
- import type { MachineSt, OpenHow } from './machine-st.ts'
22
+ import type { MachineSt } from './machine-st.ts'
23
23
  import type { Role } from '../../home/pm.ts'
24
24
  import { processIcon, stateWords, type ProcessRow } from '../../home/process-rows.ts'
25
25
  import { plainClick, repoPage, type HomeSt } from './home-st.ts'
@@ -64,81 +64,81 @@ export const ResourceChip = observer(({ m, r }: { m: MachineSt; r: ResourceView
64
64
  const Mark = ({ i }: { i: ProcessIcon | null }) =>
65
65
  i == null ? <i className="hpico" /> : 'text' in i ? <span className="hpico">{i.text}</span> : <img className="hpico" src={i.src} alt="" width={16} height={16} />
66
66
 
67
- /** a global process's state in words, on the one clock: `up 2h`, `crashed 3m ago` */
68
- const StateWords = observer(({ m, r }: { m: MachineSt; r: ProcessRow }) => (
69
- <span title={r.since == null ? undefined : `since ${stampWords(r.since)}`}>{stateWords(r, m.clock.now)}</span>
70
- ))
67
+ /** a process's state in words, on the one clock (`up 2h`, `crashed 3m ago`,
68
+ * `no answer`). What it has to say (its note, a refused take over) is a
69
+ * mark after the words and the hover, never a line of its own, so the
70
+ * table keeps its height whatever a process says */
71
+ const StateCell = observer(({ m, r }: { m: MachineSt; r: ProcessRow }) => {
72
+ const say = [r.note, r.takeOver?.ok === false ? `cannot take over: ${r.takeOver.why}` : null].filter((x) => x != null)
73
+ const title = [r.since == null ? null : `since ${stampWords(r.since)}`, ...say].filter((x) => x != null).join('\n')
74
+ return (
75
+ <td className={`gpstate ${r.dot}`}>
76
+ <span title={title === '' ? undefined : title}>{stateWords(r, m.clock.now)}</span>
77
+ {say.length > 0 ? <i className="gpwarn" /> : null}
78
+ </td>
79
+ )
80
+ })
71
81
 
72
82
  /** the button columns a table holds: a kind no line needs takes none */
73
83
  type BtnCols = { act: string | null; take: boolean }
74
84
 
75
85
  /** one process as one line, like a commit on the activity page: its dot,
76
- * icon and label (cut to its column, whole with its log on hover), its url
77
- * as a link, its version, its pid (a click copies it), its state, then one
78
- * cell per kind of button, so every start sits under every start: its
79
- * declared actions, take over, restart, start (stop while it runs), open.
80
- * One of shipkit's own wears a core tag and has its stop disabled: it is
81
- * always on, restarted through pm (pm itself by launchd). A running line is
82
- * tinted green, a crashed one red */
83
- const ProcessLine = observer(({ m, r, repoIcon, head, cols, span }: { m: MachineSt; r: ProcessRow; repoIcon: string | null; head: ReactNode; cols: BtnCols; span: number }) => {
86
+ * icon and label (cut to its column, whole with its log on hover), then
87
+ * its two switches as small icons, start or stop (play while stopped, stop
88
+ * while it runs) and restart, each in a column of its own so every play
89
+ * sits under every play, then its url: open in Chrome, open as its own
90
+ * app (a spinner while that app builds), then the url itself, a link to
91
+ * the default browser. Each starts the process first when it is down. Then
92
+ * its version, its pid (a click copies it), its state, and its
93
+ * declared actions and take over. One of shipkit's own wears a core tag and
94
+ * has its stop disabled: it is always on, restarted through pm (pm itself
95
+ * by launchd). A running line is tinted green, a crashed one red */
96
+ const ProcessLine = observer(({ m, r, repoIcon, head, cols }: { m: MachineSt; r: ProcessRow; repoIcon: string | null; head: ReactNode; cols: BtnCols }) => {
84
97
  const on = r.status === 'up' || r.status === 'starting'
85
98
  const about = [r.label, ...(r.core ? [r.description] : [`${r.repo}/${r.file}`, r.description, r.where != null ? `on ${r.where}` : null])].filter((x) => x != null).join(' · ')
86
99
  const log = r.tail.length > 0 ? `${about}\n\n${r.tail.join('\n')}` : null
87
- const via = m.openHow === 'app' ? 'as a shipkit app window' : 'in the default browser'
88
- const note = r.takeOver?.ok === false ? `cannot take over: ${r.takeOver.why}` : r.note
100
+ const first = r.status === 'up' ? '' : `start ${r.label}, then `
89
101
  const stopTitle = r.core ? `always on: ${r.id === 'pm' ? 'pm is the LaunchAgent com.shipkit.pm, launchd starts it again when it dies' : `restart ${r.label} instead`}`
90
102
  : on && !r.canStop ? `${r.label} was started outside pm, so pm cannot stop it` : on ? `stop ${r.label}` : `start ${r.label}`
103
+ const stops = on || r.core
91
104
  const copied = r.pid != null && m.copiedPid === r.pid
92
105
  return (
93
- <>
94
- <tr className={`gpline ${r.dot}`}>
95
- {head}
96
- <td className="gpname">
97
- <Tip className="gplabel" tipClass="hpop" placement="bottom-start" interactive content={log} data-log={log == null ? undefined : 'yes'}>
98
- <i className={`hdot ${r.dot}`} /><Mark i={processIcon(r, repoIcon)} /><span title={log != null ? undefined : about}>{r.label}</span>
99
- {r.core ? <b className="gptag" title="built into shipkit: always on">core</b> : null}
100
- </Tip>
101
- </td>
102
- <td className="gphost">
103
- {r.url == null ? null : r.core ? <span>{r.host}</span> : (
104
- <a href={r.url} title={`open ${r.url} ${via}`} onClick={(e) => {
106
+ <tr className={`gpline ${r.dot}`}>
107
+ {head}
108
+ <td className="gpname">
109
+ <Tip className="gplabel" tipClass="hpop" placement="bottom-start" interactive content={log} data-log={log == null ? undefined : 'yes'}>
110
+ <i className={`hdot ${r.dot}`} /><Mark i={processIcon(r, repoIcon)} /><span title={log != null ? undefined : about}>{r.label}</span>
111
+ {r.core ? <b className="gptag" title="built into shipkit: always on">core</b> : null}
112
+ </Tip>
113
+ </td>
114
+ <td className="gpb stop"><button className={`gpi${stops ? '' : ' go'}`} aria-label={stops ? 'stop' : 'start'} disabled={r.core || r.status === 'stopping' || r.status === 'unknown' || (on && !r.canStop)}
115
+ title={stopTitle} onClick={() => void m.processAct(r.id, on ? 'stop' : 'start')}><Icon d={stops ? ICON.stop : ICON.play} /></button></td>
116
+ <td className="gpb restart">{r.canRestart ? <button className="gpi" aria-label="restart" onClick={() => void (r.core ? m.restartChild(r.id as Role) : m.processAct(r.id, 'restart'))} title={r.core ? `stop the ${r.label} and start it again, through pm` : `stop ${r.label} and start it again`}><Icon d={ICON.restart} /></button> : null}</td>
117
+ <td className="gphost">
118
+ {r.url == null ? null : r.core ? <span>{r.host}</span> : (
119
+ <>
120
+ <button className="gpi" aria-label="open in Chrome" onClick={() => void m.openProcess(r.id, 'chrome')} title={`${first}open ${r.url} in Chrome`}><Icon d={ICON.browser} /></button>
121
+ <button className="gpi" aria-label="open as an app" disabled={r.appBusy} aria-busy={r.appBusy || undefined} onClick={() => void m.openProcess(r.id, 'app')}
122
+ title={r.appBusy ? `building the ${r.label} app, then opening it` : `${first}open ${r.url} as its own app, ${r.label} in the dock`}>{r.appBusy ? <span className="spin" /> : <Icon d={ICON.window} />}</button>
123
+ <a href={r.url} title={`${first}open ${r.url} in the default browser`} onClick={(e) => {
105
124
  if (!plainClick(e)) return
106
125
  e.preventDefault()
107
- void m.openProcess(r.id)
126
+ void m.openProcess(r.id, 'browser')
108
127
  }}>{r.host}</a>
109
- )}
110
- </td>
111
- <td className="gpver">{r.version ?? ''}</td>
112
- <td className="gppid">{r.pid == null ? null : <button className={copied ? 'gpcopy on' : 'gpcopy'} title={`copy pid ${r.pid}`} onClick={() => void m.copyPid(r.pid!)}>{copied ? 'copied' : r.pid}</button>}</td>
113
- <td className={`gpstate ${r.dot}`}><StateWords m={m} r={r} /></td>
114
- {cols.act == null ? null : <td className="gpb act">{r.actions.map((a) => (
115
- <button key={a.id} className="ghost hsm" disabled={r.running != null} onClick={() => void m.processAct(r.id, 'action', a.id)}>{r.running === a.id ? <span className="spin" /> : null}{a.label}</button>
116
- ))}</td>}
117
- {!cols.take ? null : <td className="gpb take">{r.takeOver?.ok === true ? <button className="ghost hsm" onClick={() => void m.processAct(r.id, 'takeover')} title={`stop the copy of ${r.label} started elsewhere (the process group listening on its port) and start one pm supervises, in one go`}>take over</button> : null}</td>}
118
- <td className="gpb restart">{r.canRestart ? <button className="ghost hsm" onClick={() => void (r.core ? m.restartChild(r.id as Role) : m.processAct(r.id, 'restart'))} title={r.core ? `stop the ${r.label} and start it again, through pm` : `stop ${r.label} and start it again`}>restart</button> : null}</td>
119
- <td className="gpb stop"><button className={on || r.core ? 'ghost' : 'primary'} disabled={r.core || r.status === 'stopping' || r.status === 'unknown' || (on && !r.canStop)}
120
- title={stopTitle} onClick={() => void m.processAct(r.id, on ? 'stop' : 'start')}>{on || r.core ? 'stop' : 'start'}</button></td>
121
- <td className="gpb open">{r.url != null && !r.core ? <button className="ghost" onClick={() => void m.openProcess(r.id)} title={r.status === 'up' ? `open ${r.url} ${via}` : `start ${r.label}, then open ${r.url} ${via}`}>open</button> : null}</td>
122
- </tr>
123
- {note == null ? null : <tr className={`gpline ${r.dot}`}><td colSpan={span} className="gpnote hnote" title={note}>{note}</td></tr>}
124
- </>
128
+ </>
129
+ )}
130
+ </td>
131
+ <td className="gpver">{r.version ?? ''}</td>
132
+ <td className="gppid">{r.pid == null ? null : <button className={copied ? 'gpcopy on' : 'gpcopy'} title={`copy pid ${r.pid}`} onClick={() => void m.copyPid(r.pid!)}>{copied ? 'copied' : r.pid}</button>}</td>
133
+ <StateCell m={m} r={r} />
134
+ {cols.act == null ? null : <td className="gpb act">{r.actions.map((a) => (
135
+ <button key={a.id} className="ghost hsm" disabled={r.running != null} onClick={() => void m.processAct(r.id, 'action', a.id)}>{r.running === a.id ? <span className="spin" /> : null}{a.label}</button>
136
+ ))}</td>}
137
+ {!cols.take ? null : <td className="gpb take">{r.takeOver?.ok === true ? <button className="ghost hsm" onClick={() => void m.processAct(r.id, 'takeover')} title={`stop the copy of ${r.label} started elsewhere (the process group listening on its port) and start one pm supervises, in one go`}>take over</button> : null}</td>}
138
+ </tr>
125
139
  )
126
140
  })
127
141
 
128
- const OPENERS: { how: OpenHow; d: string; title: string }[] = [
129
- { how: 'browser', d: ICON.browser, title: 'open opens the default browser' },
130
- { how: 'app', d: ICON.window, title: 'open opens a shipkit app window' },
131
- ]
132
-
133
- /** how every open button and url of the table opens, kept per browser */
134
- const OpenHowPick = observer(({ m }: { m: MachineSt }) => (
135
- <span className="seg hopen" role="group" aria-label="how open opens">
136
- {OPENERS.map((o) => (
137
- <button key={o.how} className={m.openHow === o.how ? 'on' : undefined} aria-pressed={m.openHow === o.how} title={o.title} onClick={() => m.setOpenHow(o.how)}><Icon d={o.d} /></button>
138
- ))}
139
- </span>
140
- ))
141
-
142
142
  /** the repo's icon and plain name, as on the activity page; a plain click shows it in place */
143
143
  const ProcessRepo = observer(({ app, name, icon }: { app: HomeSt; name: string; icon: string | null }) => (
144
144
  <a className="acrepo" href={repoPage(name)} onClick={(e) => {
@@ -154,35 +154,33 @@ const ProcessRepo = observer(({ app, name, icon }: { app: HomeSt; name: string;
154
154
  /** the processes as one table in the activity page's style: shipkit's own
155
155
  * first, then a row group per declaring repo in the project list's order,
156
156
  * its name in the first column, at the top of its group, then one line per
157
- * process. The header row ends with the new window button and the open
158
- * choice */
157
+ * process. The url's header carries the new window button */
159
158
  const ProcessTable = observer(({ app, m }: { app: HomeSt; m: MachineSt }) => {
160
159
  const groups = m.processGroups
161
160
  const rows = groups.flatMap((g) => g.rows)
162
161
  // wide enough for the longest set of declared actions, in the button font
163
162
  const act = Math.max(0, ...rows.map((r) => r.actions.reduce((n, a) => n + a.label.length + 3, 0)))
164
163
  const cols: BtnCols = { act: act === 0 ? null : `${act}ch`, take: rows.some((r) => r.takeOver?.ok === true) }
165
- const btns = 3 + (cols.act == null ? 0 : 1) + (cols.take ? 1 : 0)
166
164
  return (
167
165
  <div className="gpwrap">
168
166
  <table className="actable gptable">
169
167
  <colgroup>
170
- <col className="c-name" /><col className="c-gpname" /><col className="c-gphost" /><col className="c-gpver" /><col className="c-gppid" /><col className="c-gpstate" />
168
+ <col className="c-name" /><col className="c-gpname" /><col className="c-gpb stop" /><col className="c-gpb restart" /><col className="c-gphost" /><col className="c-gpver" /><col className="c-gppid" /><col className="c-gpstate" />
171
169
  {cols.act == null ? null : <col className="c-gpb act" style={{ width: cols.act }} />}
172
170
  {cols.take ? <col className="c-gpb take" /> : null}
173
- <col className="c-gpb restart" /><col className="c-gpb stop" /><col className="c-gpb open" />
174
171
  </colgroup>
175
172
  <thead><tr>
176
- <th>repo</th><th>process</th><th className="gphost">url</th><th className="gpver">version</th><th className="gppid">pid</th><th className="gpstate">state</th>
177
- <th colSpan={btns} className="gphead"><div><button className="ghost hsm" title="open one more window on this page" onClick={() => m.newWindow()}>new window</button><OpenHowPick m={m} /></div></th>
173
+ <th>repo</th><th>process</th><th colSpan={2} />
174
+ <th className="gphost gphead"><div><span>url</span><span className="sp" /><button className="ghost hsm" title="open one more window on this page" onClick={() => m.newWindow()}>new window</button></div></th>
175
+ <th className="gpver">version</th><th className="gppid">pid</th><th className="gpstate">state</th>
176
+ {cols.act == null ? null : <th />}{cols.take ? <th /> : null}
178
177
  </tr></thead>
179
178
  {groups.map((g) => {
180
179
  const icon = g.core ? null : app.projects.list.find((p) => p.name === g.repo)?.icon ?? null
181
- const lines = g.rows.length + g.rows.filter((r) => r.note != null || r.takeOver?.ok === false).length
182
- const head = <td rowSpan={lines} className="acname">{g.core ? <span className="acrepo gpcore"><Icon d={ICON.shipkit} /><span>{g.repo}</span></span> : <ProcessRepo app={app} name={g.repo} icon={icon} />}</td>
180
+ const head = <td rowSpan={g.rows.length} className="acname">{g.core ? <span className="acrepo gpcore"><Icon d={ICON.shipkit} /><span>{g.repo}</span></span> : <ProcessRepo app={app} name={g.repo} icon={icon} />}</td>
183
181
  return (
184
182
  <tbody key={g.core ? ':core' : g.repo} className="acgrp">
185
- {g.rows.map((r, i) => <ProcessLine key={r.id} m={m} r={r} repoIcon={icon} head={i === 0 ? head : null} cols={cols} span={5 + btns} />)}
183
+ {g.rows.map((r, i) => <ProcessLine key={r.id} m={m} r={r} repoIcon={icon} head={i === 0 ? head : null} cols={cols} />)}
186
184
  </tbody>
187
185
  )
188
186
  })}
@@ -191,7 +189,10 @@ const ProcessTable = observer(({ app, m }: { app: HomeSt; m: MachineSt }) => {
191
189
  )
192
190
  })
193
191
 
194
- /** every house repo's widgets: the html the daemon last rendered, each under its title */
192
+ /** every house repo's widgets: the html the daemon last rendered, each under
193
+ * its title. An error is a mark on the title, its words on hover, so a card
194
+ * keeps its lines; a card with no html yet says it where the html goes. A
195
+ * widget file that failed to load is one mark after the cards */
195
196
  const Monitor = observer(({ m }: { m: MachineSt }) => {
196
197
  const w = m.widgets
197
198
  if (w == null || (w.widgets.length === 0 && w.errors.length === 0)) return null
@@ -205,14 +206,18 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
205
206
  <h3>
206
207
  {x.title}
207
208
  {head == null ? null : <span className="hdim"> · <span dangerouslySetInnerHTML={{ __html: head }} /></span>}
209
+ {x.error != null && x.html != null ? <i className="gpwarn" title={x.error} /> : null}
208
210
  </h3>
209
- {x.error != null ? <p className="hnote">{x.error}</p> : null}
210
- {x.html == null ? <p className="twait"><span className="spin" /></p> : <div dangerouslySetInnerHTML={{ __html: body }} />}
211
+ {x.html != null ? <div dangerouslySetInnerHTML={{ __html: body }} />
212
+ : x.error != null ? <div className="hwerr">{x.error}</div>
213
+ : <p className="twait"><span className="spin" /></p>}
211
214
  </section>
212
215
  )
213
216
  })}
217
+ {w.errors.length === 0 ? null : (
218
+ <span className="hwerr" title={w.errors.join('\n')}><i className="gpwarn" />{w.errors.length} widget file{w.errors.length === 1 ? '' : 's'} failed</span>
219
+ )}
214
220
  </div>
215
- {w.errors.map((e) => <p key={e} className="hnote">{e}</p>)}
216
221
  </section>
217
222
  )
218
223
  })
@@ -248,10 +253,11 @@ const ShownRepo = observer(({ app, name }: { app: HomeSt; name: string }) => {
248
253
  * well: the strip states trouble, never health */
249
254
  const MachineAlerts = observer(({ m }: { m: MachineSt }) => (
250
255
  <>
251
- {(m.pm?.children ?? []).filter((c) => c.status === 'crashed' || c.status === 'down').map((c) => (
256
+ {/* pm silent, its last answer is kept for the table and says nothing here */}
257
+ {(m.pmDown !== '' ? [] : m.pm?.children ?? []).filter((c) => c.status === 'crashed' || c.status === 'down').map((c) => (
252
258
  <span key={c.id} className="halert" title={c.note ?? c.tail.at(-1) ?? ''}><Dot s={c.status} />{c.id} {STATUS_WORD[c.status]}</span>
253
259
  ))}
254
- {(m.pm?.resources ?? []).filter((r) => r.status === 'crashed').map((r) => <ResourceChip key={r.id} m={m} r={r} />)}
260
+ {(m.pmDown !== '' ? [] : m.pm?.resources ?? []).filter((r) => r.status === 'crashed').map((r) => <ResourceChip key={r.id} m={m} r={r} />)}
255
261
  {m.error !== '' ? <span className="herr" title={m.error}>{m.error}</span> : null}
256
262
  </>
257
263
  ))
@@ -273,22 +279,23 @@ export const HomeStrip = observer(({ app }: { app: HomeSt }) => {
273
279
  <MachineAlerts m={m} />
274
280
  {live == null ? null : <RepoOpeners app={live} />}
275
281
  </div>
276
- <MachineBar app={app} />
282
+ <MachineToasts app={app} />
277
283
  </>
278
284
  )
279
285
  })
280
286
 
281
- /** the daemon or pm not answering, on one line at the bottom of the window.
282
- * The line is always there, empty while all is well, so an alert never
283
- * moves the page. Only a failed probe sets down or pmDown: before the first
284
- * answer the line stays empty */
285
- export const MachineBar = observer(({ app }: { app: HomeSt }) => {
287
+ /** the daemon or pm not answering, as toasts floating over the bottom right
288
+ * corner of the page column, just above the dock. They take no room, so
289
+ * one coming or going moves nothing, and nothing is drawn while all is
290
+ * well. Only a failed probe sets down or pmDown: before the first answer
291
+ * there is none */
292
+ export const MachineToasts = observer(({ app }: { app: HomeSt }) => {
286
293
  const m = app.machine
287
- if (m == null) return null
294
+ if (m == null || (m.down === '' && m.pmDown === '')) return null
288
295
  return (
289
- <div className="hbar">
290
- {m.down !== '' ? <span className="hdown" role="alert" title={m.down}><b>daemon down</b> {m.down}</span> : null}
291
- {m.pmDown !== '' ? <span className="hdown" role="alert" title={m.pmDown}><b>pm down</b> {m.pmDown}</span> : null}
296
+ <div className="htoasts">
297
+ {m.down !== '' ? <div className="htoast" role="alert" title={m.down}><b>daemon down</b> {m.down}</div> : null}
298
+ {m.pmDown !== '' ? <div className="htoast" role="alert" title={m.pmDown}><b>pm down</b> {m.pmDown}</div> : null}
292
299
  </div>
293
300
  )
294
301
  })
@@ -358,7 +365,10 @@ export const ArchivedView = observer(({ app }: { app: HomeSt }) => {
358
365
  export const Overview = observer(({ app }: { app: HomeSt }) => {
359
366
  const m = app.machine
360
367
  if (m == null) return null
361
- if (m.data == null) return <div className="hwait"><p className="twait"><span className="spin" />{m.down !== '' ? 'the daemon does not answer yet' : 'loading…'}</p></div>
368
+ // the table waits for pm's first word and for the list's order, so its
369
+ // rows never arrive or reorder after it paints
370
+ const p = app.projects
371
+ if (m.data == null || (m.pm == null && m.pmDown === '') || (p.list.length === 0 && !p.fresh && p.error === '')) return <div className="hwait"><p className="twait"><span className="spin" />{m.down !== '' ? 'the daemon does not answer yet' : 'loading…'}</p></div>
362
372
  return (
363
373
  <div className="hall">
364
374
  <div className="hstack">
@@ -11,6 +11,7 @@ import { observer } from 'mobx-react-lite'
11
11
  import { StrictMode, useEffect, useLayoutEffect, useRef } from 'react'
12
12
  import { createRoot } from 'react-dom/client'
13
13
  import { ColCard, ColGrid, StatusCard } from './col-card.tsx'
14
+ import { AskTable, CheckTable } from './col-tables-view.tsx'
14
15
  import { WIKIS, WikisView } from './wikis-view.tsx'
15
16
  import { ComfyOverlay, ComfyView } from './comfy-view.tsx'
16
17
  import type { Filter, RepoSt } from './repo-st.ts'
@@ -84,7 +85,12 @@ const Main = observer(({ app }: { app: RepoSt }) => {
84
85
  : <div className="stagewrap"><ColCard app={app} col={app.sel.col} id={app.sel.id} /></div>}
85
86
  </main>
86
87
  )
87
- if (app.sel?.t === 'col') return <main>{errs}<ColGrid app={app} id={app.sel.id} /></main>
88
+ if (app.sel?.t === 'col') return (
89
+ <main>
90
+ {errs}
91
+ {app.sel.id === 'checks' ? <CheckTable app={app} id={app.sel.id} /> : app.sel.id === 'asks' ? <AskTable app={app} id={app.sel.id} /> : <ColGrid app={app} id={app.sel.id} />}
92
+ </main>
93
+ )
88
94
  if (app.sel?.t === 'tests') return <main>{errs}<div className="stagewrap"><TestsPage app={app} check={app.sel.check} /></div></main>
89
95
  if (app.sel?.t === 'admin') return <main>{errs}<div className="stagewrap"><AdminPage app={app} /></div></main>
90
96
  if (app.sel?.t === 'status') return <main>{errs}<div className="stagewrap"><StatusCard app={app} /></div></main>
@@ -1,7 +1,7 @@
1
1
  // Wire types the hub app reads — mirrors of the server shapes
2
2
  // (studio-server.ts, collection-types.ts). Kept structural on purpose: the
3
3
  // page renders what the API sends, the file is the truth.
4
- import type { ResourceType, SuiteFacts, TestFacts, TestRun } from '../../collections/collection-types.ts'
4
+ import type { AskFacts, ResourceType, SuiteFacts, TestFacts, TestRun } from '../../collections/collection-types.ts'
5
5
 
6
6
  export type SideAsset = {
7
7
  id: string
@@ -62,7 +62,7 @@ export type Artifact = { copy?: string; url?: string; steps?: string[] }
62
62
  * The list lives where the providers that emit it live, so a kind can never
63
63
  * be known on one side of the wire only. Type only, so nothing server side
64
64
  * reaches the bundle */
65
- export type { ResourceType, SuiteFacts, TestFacts, TestRun }
65
+ export type { AskFacts, ResourceType, SuiteFacts, TestFacts, TestRun }
66
66
 
67
67
  export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip'
68
68
 
@@ -73,6 +73,7 @@ export type RecentRun = { at: number; ms: number; status: CheckStatus; replayed:
73
73
  // engine reaches the bundle: two declarations of one vocabulary is how a
74
74
  // switch ends up writing a word the other half does not know
75
75
  import type { WhenAt, WhenSource } from '../../core/when.ts'
76
+ import type { Line } from '../../core/wire.ts'
76
77
  import type { CheckFrom } from '../../check.ts'
77
78
  export type { CheckFrom, WhenAt, WhenSource }
78
79
 
@@ -154,7 +155,7 @@ export type CheckEvent = {
154
155
  replayed: boolean
155
156
  blocking: boolean | null
156
157
  advisory: boolean
157
- lines: { kind: string; text: string }[]
158
+ lines: Line[]
158
159
  }
159
160
  /** the colour of the tree label: red broken · warn taste pending · ok
160
161
  * passing on a check that ran · idle the neutral default · quiet archived or out of play */
@@ -186,6 +187,8 @@ export type Resource = {
186
187
  check?: CheckFacts
187
188
  /** test files only: where it runs and what it last cost */
188
189
  test?: TestFacts
190
+ /** asks only: kind, dates, what it blocks, its probe */
191
+ ask?: AskFacts
189
192
  }
190
193
  export type ColMeta = { id: string; title: string; media: string }
191
194
  export type Col = ColMeta & { instances: Resource[] }
@@ -38,7 +38,8 @@ a,a:visited{color:var(--accent)}
38
38
  .pjpanel,.appcol,.pside,.pmain{height:100%;min-width:0}
39
39
  .apptop,.pdock{height:100%;min-height:0}
40
40
  .pjpanel{display:flex}
41
- .appcol{display:flex;flex-direction:column}
41
+ /* the box the machine toasts float in (home-strip.css .htoasts) */
42
+ .appcol{position:relative;display:flex;flex-direction:column}
42
43
  /* one region per repo visited, the shown one alone displayed */
43
44
  .repo{flex:1;min-height:0;display:flex;flex-direction:column}
44
45
  .repo[hidden]{display:none}
@@ -628,6 +629,27 @@ a.tok{color:var(--accent)}
628
629
  .cghead{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);
629
630
  display:flex;gap:var(--s2);align-items:baseline;margin:0 0 var(--s3)}
630
631
  .cghead .cnt{font-size:11px;color:var(--dim);letter-spacing:0;text-transform:none}
632
+ /* the checks and asks pages: one dense table each (col-tables-view.tsx), on
633
+ the activity table (.actable), a group header per state that folds */
634
+ .ctpage{flex:1;min-height:0;overflow-y:auto;padding:var(--s3) var(--s4);font-size:12.5px;container-type:inline-size}
635
+ .cttable{font-variant-numeric:tabular-nums}
636
+ .cttable td{padding:var(--s0) var(--s2);vertical-align:baseline}
637
+ .ctgrp td{padding:var(--s2) var(--s2) var(--s1);color:var(--dim);font-size:11.5px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none}
638
+ .ctgrp:hover td{color:var(--ink)}
639
+ .ctgrp.g-fail td{color:var(--red)}
640
+ .ctgrp.g-warn td{color:var(--warn)}
641
+ .ctcaret{display:inline-block;width:14px}
642
+ .ctrow{cursor:pointer}
643
+ .ctrow:hover{background:var(--panel2)}
644
+ .ctdot{font-size:10px}
645
+ .ctid,.ctkind{font:11.5px ui-monospace,Menlo,monospace}
646
+ .ctfind{font:11.5px ui-monospace,Menlo,monospace;color:var(--dim)}
647
+ .ctdur{text-align:right;font:11.5px ui-monospace,Menlo,monospace}
648
+ .ctbudget,.ctplan,.ctblocks{color:var(--dim)}
649
+ .ctprobe{font-size:11.5px}
650
+ .cttable .c-dot{width:22px}.cttable .c-id{width:140px}.cttable .c-title{width:16%}.cttable .c-age{width:72px}.cttable .c-dur{width:152px}.cttable .c-plan{width:88px}
651
+ .cttable .c-kind{width:92px}.cttable .c-blocks{width:32%}.cttable .c-probe{width:84px}
652
+ @container (max-width:760px){.cttable .c-title{width:0}.cttable .cttitle{display:none}.cttable .c-plan{width:0}.cttable .ctplan{display:none}}
631
653
  .cgcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:var(--s3)}
632
654
  .cgcard{background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:var(--s2) var(--s3);cursor:pointer;min-width:0}
633
655
  .cgcard:hover{border-color:var(--accent)}
@@ -694,7 +716,7 @@ a.mint{color:var(--accent);font-size:12px;text-decoration:none}
694
716
  a.mint:hover{text-decoration:underline}
695
717
  .colcard h3 .stchip,.colcard h3 a.mint{margin-left:var(--s2)}
696
718
 
697
- /* ── the cmd+k picker ── */
719
+ /* ── the cmd+j picker ── */
698
720
  .palov{position:fixed;inset:0;background:var(--scrim);z-index:100;display:flex;justify-content:center;align-items:flex-start;
699
721
  padding-top:14vh}
700
722
  .pal{width:min(560px,90vw);background:var(--panel);border:1px solid var(--line);border-radius:3px;
@@ -27,6 +27,10 @@ export const ICON = {
27
27
  // the VS Code mark, its ribbon drawn as one outline
28
28
  vscode: 'M14 2.5l3.5 1.7v11.6L14 17.5zM14 2.5 4.5 12.3 2.5 11V9l2-1.3L14 17.5M14 6.8 10.2 10l3.8 3.2',
29
29
  sort: 'M6 3.5v13M3 13.5l3 3 3-3M14 16.5v-13M11 6.5l3-3 3 3',
30
+ // a process's own switches on the overview
31
+ play: 'M6.5 4.5v11l9-5.5z',
32
+ stop: 'M5.5 5.5h9v9h-9z',
33
+ restart: 'M15.5 10a5.5 5.5 0 1 1-1.6-3.9M14.2 3v3.3h-3.3',
30
34
  } as const
31
35
 
32
36
  export const Icon = ({ d }: { d: string }) => (
@@ -1,6 +1,6 @@
1
1
  // the page's command keys, read the same on every layout: ⌘1 to ⌘9 select the
2
2
  // nth repo of the project list, then the home row under the right hand: ⌘J
3
- // the overview, ⌘K the picker, ⌘L the activity, ⌘' the plan. ⌘B cycles the
3
+ // the picker, ⌘K the overview, ⌘L the activity, ⌘' the plan. ⌘B cycles the
4
4
  // dock at the bottom of the page (dock-st.ts): the line, slim, open.
5
5
  // A digit and the quote are read from the physical key (`code`), so an
6
6
  // AZERTY keyboard, where the key under 1 types `&`, selects the same repo. Ctrl stands in for
@@ -17,17 +17,17 @@ export function pageKey(e: KeyLike): PageKey | null {
17
17
  if (e.shiftKey) return null
18
18
  if (e.code === 'Quote' || e.key === "'") return { t: 'planned' }
19
19
  const k = e.key.toLowerCase()
20
- if (k === 'j') return { t: 'home' }
21
- if (k === 'k') return { t: 'picker' }
20
+ if (k === 'j') return { t: 'picker' }
21
+ if (k === 'k') return { t: 'home' }
22
22
  if (k === 'l') return { t: 'activity' }
23
23
  if (k === 'b') return { t: 'dock' }
24
24
  return null
25
25
  }
26
26
 
27
27
  /** the shortcut each place of the home carries on its button */
28
- export const PLACE_KEY = { overview: '⌘J', planned: "⌘'", activity: '⌘L' } as const
29
- /** the picker's shortcut, on the second button, after home */
30
- export const PICKER_KEY = '⌘K'
28
+ export const PLACE_KEY = { overview: '⌘K', planned: "⌘'", activity: '⌘L' } as const
29
+ /** the picker's shortcut, on the first button, before home */
30
+ export const PICKER_KEY = '⌘J'
31
31
  /** the dock's shortcut, on the dock's own header */
32
32
  export const DOCK_KEY = '⌘B'
33
33
 
@@ -8,7 +8,7 @@
8
8
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
9
9
  import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
10
10
  import type { WorkerView } from '../../home/hubs.ts'
11
- import type { ResourceView } from '../../home/resources.ts'
11
+ import type { OpenHow, ResourceView } from '../../home/resources.ts'
12
12
  import type { PmAnswer, Role } from '../../home/pm.ts'
13
13
  import { processGroups, type ProcessGroup } from '../../home/process-rows.ts'
14
14
  import { ClockSvc } from './clock-svc.ts'
@@ -27,18 +27,6 @@ export const MACHINE_KEY = 'hub:machine'
27
27
  /** how long a copied pid says so */
28
28
  export const COPIED_MS = 1_200
29
29
 
30
- /** how open opens a global process's url, kept per browser */
31
- export const OPEN_HOW_KEY = 'hub:open-how'
32
- export type OpenHow = 'browser' | 'app'
33
-
34
- function loadOpenHow(): OpenHow {
35
- try {
36
- return globalThis.localStorage?.getItem(OPEN_HOW_KEY) === 'app' ? 'app' : 'browser'
37
- } catch {
38
- return 'browser'
39
- }
40
- }
41
-
42
30
  type Kept = { data: HomeAnswer | null; pm: PmAnswer | null; widgets: WidgetsAnswer | null }
43
31
 
44
32
  function loadKept(): Kept | null {
@@ -51,7 +39,12 @@ function loadKept(): Kept | null {
51
39
  }
52
40
 
53
41
  /** the poll failed because nothing behind the page answers, not on an error of an answer */
54
- class DownError extends Error {}
42
+ /** the web (no answer at all) or the daemon behind it (the web says so) */
43
+ class DownError extends Error {
44
+ constructor(msg: string, readonly who: 'web' | 'daemon') {
45
+ super(msg)
46
+ }
47
+ }
55
48
 
56
49
  export type MachineOpts = { reload?: () => void; timeoutMs?: number }
57
50
 
@@ -67,9 +60,10 @@ export class MachineSt {
67
60
  error = ''
68
61
  /** where open sends a global process's url: the default browser, or a
69
62
  * shipkit app window */
70
- openHow: OpenHow = loadOpenHow()
71
63
  /** why the daemon did not answer the last poll, or '' while it does */
72
64
  down = ''
65
+ /** which one did not answer: the web itself, or the daemon behind it */
66
+ downWho: 'web' | 'daemon' = 'daemon'
73
67
  /** the daemon answered once since the load: until then data, pm and
74
68
  * widgets are what this browser kept, shown but never acted on */
75
69
  fresh = false
@@ -115,7 +109,8 @@ export class MachineSt {
115
109
  * the project list's order, one row per process (process-rows.ts, what
116
110
  * `shipkit global-processes` prints) */
117
111
  get processGroups(): ProcessGroup[] {
118
- return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null })
112
+ const silent = { ...(this.down === '' ? {} : { [this.downWho]: this.down }), ...(this.pmDown === '' ? {} : { pm: this.pmDown }) }
113
+ return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null, silent })
119
114
  }
120
115
 
121
116
  resource(id: string): ResourceView | null {
@@ -162,10 +157,8 @@ export class MachineSt {
162
157
  this.pmDown = ''
163
158
  })
164
159
  } catch (e) {
165
- runInAction(() => {
166
- this.pm = null
167
- this.pmDown = (e as Error).message
168
- })
160
+ // the last answer stays, its rows unknown (processGroups): the table keeps its lines
161
+ runInAction(() => { this.pmDown = (e as Error).message })
169
162
  }
170
163
  }
171
164
 
@@ -197,9 +190,9 @@ export class MachineSt {
197
190
  try {
198
191
  r = await fetch('/api/home.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
199
192
  } catch (e) {
200
- throw new DownError(`the home does not answer: ${(e as Error).message}`)
193
+ throw new DownError(`the home does not answer: ${(e as Error).message}`, 'web')
201
194
  }
202
- if (r.headers.get('x-shipkit-daemon') === 'down') throw new DownError(await r.text())
195
+ if (r.headers.get('x-shipkit-daemon') === 'down') throw new DownError(await r.text(), 'daemon')
203
196
  if (!r.ok) throw new Error(`/api/home.json answered ${r.status}: ${await r.text()}`)
204
197
  const j = (await r.json()) as HomeAnswer
205
198
  const back = this.wasDown
@@ -222,7 +215,10 @@ export class MachineSt {
222
215
  const msg = (e as Error).message
223
216
  if (e instanceof DownError) {
224
217
  this.wasDown = true
225
- runInAction(() => { this.down = msg })
218
+ runInAction(() => {
219
+ this.down = msg
220
+ this.downWho = e.who
221
+ })
226
222
  } else {
227
223
  this.pollError = true
228
224
  runInAction(() => {
@@ -278,16 +274,16 @@ export class MachineSt {
278
274
  }
279
275
 
280
276
  /** a global process: start, stop, restart, take over the copy started
281
- * elsewhere, open its url in the default browser or as an app window
282
- * (started first when it is down), or run one of its actions */
283
- processAct(id: string, act: 'start' | 'stop' | 'restart' | 'takeover' | 'open' | 'app' | 'action', action?: string): Promise<boolean> {
277
+ * elsewhere, open its url (started first when it is down), or run one
278
+ * of its actions */
279
+ processAct(id: string, act: 'start' | 'stop' | 'restart' | 'takeover' | 'open' | 'chrome' | 'app' | 'action', action?: string): Promise<boolean> {
284
280
  return this.post('/api/pm/resource', { id, act, action })
285
281
  }
286
282
 
287
- /** the url of a global process, the way this browser picked (started
288
- * first when it is down) */
289
- openProcess(id: string): Promise<boolean> {
290
- return this.processAct(id, this.openHow === 'app' ? 'app' : 'open')
283
+ /** the url of a global process in the default browser, in Chrome, or as
284
+ * its own app (started first when it is down) */
285
+ openProcess(id: string, how: OpenHow): Promise<boolean> {
286
+ return this.processAct(id, how === 'browser' ? 'open' : how)
291
287
  }
292
288
 
293
289
  /** a process's pid to the clipboard, and the "copied" flash on it; a
@@ -305,13 +301,6 @@ export class MachineSt {
305
301
  this.copiedTimer = setTimeout(() => runInAction(() => { this.copiedPid = null }), COPIED_MS)
306
302
  }
307
303
 
308
- setOpenHow(how: OpenHow): void {
309
- this.openHow = how
310
- try {
311
- globalThis.localStorage?.setItem(OPEN_HOW_KEY, how)
312
- } catch { /* private window or blocked site data: the pick lasts the tab */ }
313
- }
314
-
315
304
  /** restart the daemon or the web through pm */
316
305
  restartChild(id: Role): Promise<boolean> {
317
306
  return this.post('/api/pm/child', { id, act: 'restart' })