rvlib-shipkit 2.4.0 → 2.6.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 (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/cli/cmd_Commands.ts +26 -5
  4. package/src/cli/cmd_FleetPush.ts +47 -0
  5. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  6. package/src/cli/cmd_Review.ts +16 -1
  7. package/src/cli/cmd_Trailer.ts +4 -1
  8. package/src/cli/family-pages.ts +49 -0
  9. package/src/cli/kit/builtins.ts +1 -0
  10. package/src/cli/registry.ts +137 -0
  11. package/src/cli.ts +47 -135
  12. package/src/core/last-good.ts +217 -0
  13. package/src/core/load-probe.ts +71 -0
  14. package/src/core/reexec.ts +5 -3
  15. package/src/fleet/push-fleet.ts +80 -0
  16. package/src/home/home-routes.ts +12 -2
  17. package/src/home/lib-watch.ts +110 -20
  18. package/src/home/process-rows.ts +4 -2
  19. package/src/home/run-daemon.ts +1 -1
  20. package/src/review/model.ts +19 -0
  21. package/src/review/paste.ts +30 -0
  22. package/src/review/routes.ts +10 -3
  23. package/src/scaffold/files/agent-index.md +2 -2
  24. package/src/scaffold/project-scaffold.ts +2 -2
  25. package/src/serve/hub-app/activity-st.ts +31 -0
  26. package/src/serve/hub-app/activity-view.tsx +9 -0
  27. package/src/serve/hub-app/activity.css +6 -0
  28. package/src/serve/hub-app/home-strip.tsx +3 -1
  29. package/src/serve/hub-app/machine-st.ts +1 -1
  30. package/src/serve/hub-app/review/review-answer.tsx +16 -37
  31. package/src/serve/hub-app/review/review-page.tsx +8 -1
  32. package/src/serve/hub-app/review/review-st.ts +51 -3
  33. package/src/serve/hub-app/review/review-views.tsx +1 -1
  34. package/src/serve/hub-app/review.css +4 -4
  35. package/src/serve/run-serve.ts +25 -0
  36. package/src/serve/serve-hub.ts +8 -1
  37. package/src/serve/stale-restart.ts +19 -2
  38. package/src/trailer/build.ts +16 -7
  39. package/src/trailer/ffmpeg.ts +21 -1
  40. package/src/trailer/grammar.ts +56 -11
  41. package/src/trailer/look.ts +31 -16
  42. package/src/trailer/prune.ts +59 -12
  43. package/src/trailer/score/dsp.ts +87 -27
  44. package/src/trailer/score/pattern.ts +1 -1
  45. package/src/trailer/score/synth.ts +26 -5
  46. package/src/trailer/titles.ts +63 -34
  47. package/src/trailer/vocab.ts +65 -6
@@ -146,6 +146,8 @@ export const CORE_ABOUT = {
146
146
  export type CoreOpts = {
147
147
  /** the daemon's own version, asked of the daemon; null when it does not answer */
148
148
  daemonVersion?: string | null
149
+ /** the daemon runs older code than its tree, and why; null when it does not */
150
+ daemonHeld?: string | null
149
151
  /** where each listens, when pm does not say (an older pm, or pm down) */
150
152
  ports?: CorePorts | null
151
153
  /** one of shipkit's own not answering the page, and why: its row says
@@ -174,9 +176,9 @@ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
174
176
  const c = pm?.children.find((x) => x.id === id)
175
177
  const port = ports?.[id] ?? null
176
178
  const version = id === 'daemon' ? (o.daemonVersion ?? null) : null
177
- if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: null, tail: [], version, port, restart: false }))
179
+ if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: id === 'daemon' ? (o.daemonHeld ?? null) : null, tail: [], version, port, restart: false }))
178
180
  else {
179
- const note = [c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
181
+ const note = [id === 'daemon' ? (o.daemonHeld ?? null) : null, c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
180
182
  rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
181
183
  }
182
184
  }
@@ -115,7 +115,7 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
115
115
  // the rows every gate filed land here, one commit a day at most
116
116
  also: (now) => { for (const n of landMotherhome({ now })) io.printErr(n) },
117
117
  })
118
- const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
118
+ const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, keep: true, io })
119
119
  io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
120
120
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
121
121
  process.on(sig, () => {
@@ -186,6 +186,25 @@ export const asOptions = (i: Item): boolean => isChoice(i) || (i.views.length >=
186
186
  /** the most stars a reaction gives */
187
187
  export const STARS = 5
188
188
 
189
+ /** where a card is, for a fresh agent session in any repo: the repo as `~/dev/<repo>`, the card's url where it is open
190
+ * now, and its phone url when the repo has a public host */
191
+ export interface CardWhere {
192
+ root: string
193
+ open: string
194
+ phone?: string | null
195
+ }
196
+
197
+ /** one paste that hands a card to any agent session: what it is, where, the command that shows it whole, its links */
198
+ export function pasteBlock(i: Item, state: string, w: CardWhere): string {
199
+ return [
200
+ `review card "${i.title}" (${state}), id ${i.id}`,
201
+ `repo: ${w.root}`,
202
+ `see it whole: cd ${w.root} && sk review show ${i.id}`,
203
+ `open: ${w.open}`,
204
+ ...(w.phone ? [`phone: ${w.phone}`] : []),
205
+ ].join('\n')
206
+ }
207
+
189
208
  /** a view's name as the page and the CLI show it: its label, or « view n » */
190
209
  export const viewName = (v: ReviewView | undefined, i: number): string => v?.label || `view ${i + 1}`
191
210
 
@@ -0,0 +1,30 @@
1
+ // where a card lives, for the paste that hands it to another agent session (model.ts pasteBlock): the repo's path as
2
+ // `~/dev/<repo>`, the house page's url, the phone's url through the repo's public host. Node side: the page reads it
3
+ // from /api/review/spec
4
+ import { homedir } from 'node:os'
5
+ import { homePort } from '../home/home-paths.ts'
6
+ import { loadRepoConfig } from '../repo-config.ts'
7
+ import { defaultHubPort } from '../serve/hub-port.ts'
8
+
9
+ export interface PasteHosts {
10
+ /** the repo's path, `~` for the home folder */
11
+ root: string
12
+ /** the house page of the repo, `#page:review:<id>` goes after it */
13
+ open: string
14
+ /** the repo's hub through its public host, null when it has none */
15
+ phone: string | null
16
+ }
17
+
18
+ export async function pasteHosts(root: string): Promise<PasteHosts> {
19
+ const c = await loadRepoConfig(root)
20
+ const home = homedir()
21
+ const host = c.serve?.publicHost
22
+ return {
23
+ root: root.startsWith(`${home}/`) ? `~${root.slice(home.length)}` : root,
24
+ open: `http://localhost:${homePort()}/r/${encodeURIComponent(c.name)}/`,
25
+ phone: host ? `http://${host}:${c.serve?.port ?? defaultHubPort(c.name)}/` : null,
26
+ }
27
+ }
28
+
29
+ /** a card's url under a hub base */
30
+ export const cardUrl = (base: string, id: string): string => `${base}#page:review:${id}`
@@ -3,11 +3,12 @@
3
3
  // relative to the repo hub's base. null: not a path of these
4
4
  import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
5
  import { tmpdir } from 'node:os'
6
- import { extname, join } from 'node:path'
6
+ import { dirname, extname, join } from 'node:path'
7
7
  import { isAnimFormat, isAnimLabels, isAnimMode, pairAnimation } from './anim.ts'
8
8
  import { startAuto } from './auto.ts'
9
9
  import { bad, KEEP, NO_STORE, ranged, TYPES } from './http.ts'
10
10
  import type { Rect, Subject } from './model.ts'
11
+ import { pasteHosts } from './paste.ts'
11
12
  import { openReview, partFile, viewOfItem } from './project.ts'
12
13
  import { roleOf } from './role.ts'
13
14
  import { addMessage, imageFile, markRead, raiseItem, rateSubject, readAllCached, readItem, specOf, storeDir, storeStamp, threadImage, writeVerdict } from './store.ts'
@@ -110,8 +111,14 @@ async function reviewRoute(req: Request, path: string, dir: string, unit: Review
110
111
  }
111
112
  if (path === '/api/review/watchers' && req.method === 'GET') return Response.json(liveWatchers(dir), { headers: NO_STORE })
112
113
  // the unit's words, the dispatcher's holder: what the page draws the list's filters, labels and board with
113
- if (path === '/api/review/spec' && req.method === 'GET')
114
- return Response.json({ ...specData(specOf(dir)), role: roleOf(dir), sources: (unit?.sources ?? []).map((s) => s.id), auto: (unit?.auto ?? []).map((r) => ({ id: r.id, every: r.every })) }, { headers: NO_STORE })
114
+ if (path === '/api/review/spec' && req.method === 'GET') {
115
+ // where a card lives, for the paste that hands it to another agent session; a config that does not load leaves it out
116
+ const where = await pasteHosts(dirname(dirname(dir))).catch(() => null)
117
+ return Response.json(
118
+ { ...specData(specOf(dir)), role: roleOf(dir), sources: (unit?.sources ?? []).map((s) => s.id), auto: (unit?.auto ?? []).map((r) => ({ id: r.id, every: r.every })), where },
119
+ { headers: NO_STORE },
120
+ )
121
+ }
115
122
  if ((path === '/api/review/raise' || path === '/api/review/rate') && req.method === 'POST') {
116
123
  let img: ReturnType<typeof dataUrlFile> = null
117
124
  try {
@@ -18,8 +18,8 @@ Read the matching page before touching a surface:
18
18
 
19
19
  | read when touching… | page |
20
20
  |---|---|
21
- | any source file | `agent/coding.md` |
22
- | recent changes and why | `agent/changelog.md` |{{CLI_ROW}}
21
+ | any source file | → agent/coding.md |
22
+ | recent changes and why | → agent/changelog.md |{{CLI_ROW}}
23
23
 
24
24
  Grow this table: a subsystem, a service, a runbook each earn their own page,
25
25
  cross-linked from here.
@@ -236,7 +236,7 @@ export function installedLib(root: string): { version?: string; exports?: Record
236
236
  /** the agent index lines that load the essentials page and list the full one */
237
237
  const cliIndexVars = (name: string, cli: boolean): { CLI_INCLUDE: string; CLI_ROW: string } => ({
238
238
  CLI_INCLUDE: cli ? `\n@${name}-essentials.md` : '',
239
- CLI_ROW: cli ? `\n| every command of the project CLI | \`${cliPages(name).all}\` |` : '',
239
+ CLI_ROW: cli ? `\n| every command of the project CLI | → ${cliPages(name).all} |` : '',
240
240
  })
241
241
 
242
242
  /** an ADOPTED repo asking for the project CLI (`sk start <name> --cli`): the
@@ -257,7 +257,7 @@ export function adoptCli(root: string, opts: { name: string; print?: (line: stri
257
257
  addScripts(root, written, p, opts.name, false)
258
258
  const index = join(root, 'agent/index.md')
259
259
  const include = `@${opts.name}-essentials.md`
260
- if (existsSync(index) && !readFileSync(index, 'utf8').split('\n').includes(include)) p(`next: in agent/index.md, load the everyday commands with the line ${include} and list \`${cliPages(opts.name).all}\` in its table`)
260
+ if (existsSync(index) && !readFileSync(index, 'utf8').split('\n').includes(include)) p(`next: in agent/index.md, load the everyday commands with the line ${include} and list → ${cliPages(opts.name).all} in its table`)
261
261
  const deps = { ...(pkg as { dependencies?: object }).dependencies, ...(pkg as { devDependencies?: object }).devDependencies }
262
262
  p(`ok: project CLI written: ${written.join(', ')} — run \`bun run cli\``)
263
263
  if (!('rvlib-shipkit' in deps)) p('next: bun add -d rvlib-shipkit (the CLI imports rvlib-shipkit/cli-kit)')
@@ -10,6 +10,11 @@ import { clampCap, DEFAULT_CAP, DEFAULT_MINUTES, idleRow, WINDOW_MINUTES, type A
10
10
  export { ageShort, CAP_MAX, CAP_MIN, clampCap, DEFAULT_CAP, dirtWords, filesWords, upstreamWords, windowWords } from '../../home/activity-shape.ts'
11
11
  import type { HomeSt } from './home-st.ts'
12
12
  import { ownTrouble, reach } from './reach.ts'
13
+ import type { PushResult } from '../../fleet/push-fleet.ts'
14
+
15
+ /** the repos holding commits no remote has: ahead of their upstream, or on a branch with none */
16
+ export const unpushedCount = (rows: Pick<ActivityRow, 'ahead' | 'upstream' | 'branch'>[]): number =>
17
+ rows.filter((r) => (r.ahead ?? 0) > 0 || (r.upstream == null && r.branch != null)).length
13
18
 
14
19
  export const ACTIVITY_POLL_MS = 60_000
15
20
  const MINUTES_KEY = 'home:activity:minutes'
@@ -38,6 +43,10 @@ function keep(key: string, n: number): void {
38
43
  export class ActivitySt {
39
44
  data: ActivityAnswer | null = null
40
45
  error = ''
46
+ /** the push button is running */
47
+ pushing = false
48
+ /** what the last push said, one line a repo that needed it */
49
+ pushed: PushResult[] | null = null
41
50
  minutes: number = keptMinutes()
42
51
  /** the most commits a repo shows before a click shows the rest */
43
52
  cap: number = clampCap(kept(CAP_KEY) ?? DEFAULT_CAP)
@@ -118,6 +127,28 @@ export class ActivitySt {
118
127
  this.st.select(name)
119
128
  }
120
129
 
130
+ /** repos holding commits no remote has */
131
+ get unpushed(): number {
132
+ return unpushedCount(this.data?.rows ?? [])
133
+ }
134
+
135
+ /** push every house repo that needs it, through the daemon (fleet/push-fleet.ts), then read git again */
136
+ async pushAll(): Promise<void> {
137
+ if (this.pushing) return
138
+ runInAction(() => { this.pushing = true; this.pushed = null })
139
+ try {
140
+ const r = await reach('/api/home/push-all', { method: 'POST', signal: AbortSignal.timeout(180_000) })
141
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
142
+ const res = (await r.json()) as PushResult[]
143
+ runInAction(() => { this.pushed = res.filter((x) => x.action !== 'none'); this.error = '' })
144
+ } catch (e) {
145
+ runInAction(() => { this.error = ownTrouble(e, this.st.machine, (m) => `push every repo: ${m}`) })
146
+ } finally {
147
+ runInAction(() => { this.pushing = false })
148
+ void this.poll(true)
149
+ }
150
+ }
151
+
121
152
  /** the refresh button: a new git pass now, whatever the countdown says */
122
153
  refresh(): void {
123
154
  if (!this.loading) void this.poll(true)
@@ -189,6 +189,12 @@ export const ActivityView = observer(({ app }: { app: HomeSt }) => {
189
189
  <span className="acdim">commits</span>
190
190
  </label>
191
191
  <Next app={app} />
192
+ {st.unpushed > 0 || st.pushing ? (
193
+ <button type="button" className="acpill acpush" disabled={st.pushing} onClick={() => void st.pushAll()}
194
+ title="git push in every house repo with unpushed commits; never a force, a repo that fell behind waits for a pull (sk fleet push does the same)">
195
+ {st.pushing ? 'pushing…' : `↑ push ${st.unpushed} unpushed`}
196
+ </button>
197
+ ) : null}
192
198
  {d == null ? <span className="acdim">reading every repo…</span> : (
193
199
  <>
194
200
  <span className="acpill">{busy.length} busy</span>
@@ -198,6 +204,9 @@ export const ActivityView = observer(({ app }: { app: HomeSt }) => {
198
204
  )}
199
205
  </div>
200
206
  {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
207
+ {st.pushed == null || st.pushed.length === 0 ? null : (
208
+ <div className="acpushed">{st.pushed.map((p) => <div key={p.name} className={p.ok ? 'ok' : p.action === 'skip' ? 'skip' : 'fail'}>{p.line}</div>)}</div>
209
+ )}
201
210
  {d != null && busy.length === 0 ? <div className="acdim">nothing committed or uncommitted in the last {windowWords(st.minutes)}</div> : null}
202
211
  {busy.length + idle.length === 0 ? null : (
203
212
  <table className={`actable${st.stale ? ' stale' : ''}`}>
@@ -72,3 +72,9 @@ button.acpill:disabled{cursor:default}
72
72
  .acfork .hico{width:13px;height:13px}
73
73
  .acgrp:hover .acfork{opacity:1}
74
74
  .acfork:hover{color:var(--accent)}
75
+ .acpush{color:var(--ink);border-color:var(--accent)}
76
+ /* what the last push said: a line a repo, pushed in the ok colour, skipped dim, failed red */
77
+ .acpushed{margin:0 0 var(--s3);font:12px/1.5 ui-monospace,Menlo,monospace}
78
+ .acpushed .ok{color:var(--ok)}
79
+ .acpushed .skip{color:var(--dim)}
80
+ .acpushed .fail{color:var(--red)}
@@ -256,7 +256,7 @@ const downCard = (m: MachineSt): Card[] => {
256
256
 
257
257
  /** what is wrong with the machine itself, one card each: the machine not
258
258
  * answering, a crashed child of pm, a crashed global process (its switch
259
- * starts it again), a failed poll. While pm is silent its last answer says
259
+ * starts it again), a failed poll, the last good code running. While pm is silent its last answer says
260
260
  * nothing here */
261
261
  const machineCards = (m: MachineSt): Card[] => {
262
262
  const pm = m.pmDown !== '' ? null : m.pm
@@ -265,6 +265,8 @@ const machineCards = (m: MachineSt): Card[] => {
265
265
  ...(pm?.children ?? []).filter((c) => c.status === 'crashed' || c.status === 'down').map((c): Card => ({ key: `child:${c.id}`, tone: 'red', head: `${c.id} ${STATUS_WORD[c.status]}`, body: c.note ?? c.tail.at(-1) ?? '' })),
266
266
  ...(pm?.resources ?? []).filter((r) => r.status === 'crashed').map((r): Card => ({ key: `res:${r.id}`, tone: 'red', head: 'crashed', body: <ResourceChip m={m} r={r} /> })),
267
267
  ...(m.error !== '' ? [{ key: 'poll', tone: 'red' as const, body: m.error }] : []),
268
+ // the tree fails to load and the machine runs its last good code (core/last-good.ts)
269
+ ...(m.data?.held != null ? [{ key: 'held', tone: 'warn' as const, head: 'last good code', body: m.data.held }] : []),
268
270
  ]
269
271
  }
270
272
 
@@ -110,7 +110,7 @@ export class MachineSt {
110
110
  * `shipkit global-processes` prints) */
111
111
  get processGroups(): ProcessGroup[] {
112
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 })
113
+ return processGroups(this.pm, this.st.projects.list.map((p) => p.name), { daemonVersion: this.data?.version ?? null, daemonHeld: this.data?.held ?? null, silent })
114
114
  }
115
115
 
116
116
  resource(id: string): ResourceView | null {
@@ -2,7 +2,7 @@
2
2
  // goes to the agent, the verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply
3
3
  // box. When the page reads in one column the header sits over the views instead (review-views.tsx)
4
4
  import { observer } from 'mobx-react-lite'
5
- import { useState } from 'react'
5
+ import { Icon, ICON } from '../icons.tsx'
6
6
  import { laneOf, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus } from '../../../review/model.ts'
7
7
  import { renderMd } from '../md.ts'
8
8
  import { AutoTag, blurOnEscape, Empty, LaneTag, Listeners, REACTIONS, Rework, secs, short, SIDE_LABEL, ThumbOf, toneOf, VERDICTS } from './kit.tsx'
@@ -15,12 +15,9 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
15
15
  const r = x.item
16
16
  const draft = rv.draft
17
17
  const sent = verdictOf(x)
18
- // in one column the header and the verdicts sit over the views
19
- const headAbove = rv.headInViews
20
- const verdictsAbove = rv.stacked && !rv.phone.on
21
18
  return (
22
19
  <div className="rv-form">
23
- {headAbove ? null : <CardHead rv={rv} x={x} verdicts={false} />}
20
+ {rv.headInViews ? null : <CardHead rv={rv} x={x} />}
24
21
  <KindView app={rv} x={x} />
25
22
  <AnimPanel app={rv} x={x} />
26
23
  {rv.newer.map((y) => <Linked key={y.item.id} rv={rv} label="replaced by" id={y.item.id} item={y} />)}
@@ -30,14 +27,6 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
30
27
  {rv.history.length > 0 ? <div className="rv-divider">earlier versions</div> : null}
31
28
  {rv.history.map((y, i) => <Linked key={y.item.id} rv={rv} label={`v${rv.history.length - i}`} id={y.item.id} item={y} />)}
32
29
  <Outgoing rv={rv} />
33
- <label className="rv-label">Comment</label>
34
- <textarea className="rv-comment" rows={3} placeholder="the overall word" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
35
- {verdictsAbove ? null : <div className="rv-listenrow"><span className="rv-dim">who reads your answer</span><Listeners rv={rv} cards={[x]} /></div>}
36
- {!rv.phone.on && !verdictsAbove && !r.wip && r.origin.by === 'agent' ? (
37
- <div className="rv-verdicts">
38
- {VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} />)}
39
- </div>
40
- ) : null}
41
30
  <Messages rv={rv} />
42
31
  <div className="rv-formfoot">
43
32
  <button className="ghost" onClick={() => rv.nextPending()}>next waiting (N)</button>
@@ -77,10 +66,12 @@ function stateWords(x: ReviewItem): string {
77
66
  }
78
67
 
79
68
  /** the card's header, for every card: the title, one tight line (the state as the list's pill, the lane, when, the
80
- * commit, the id to copy), then what the agent says. No subject row: the list's row names it, a kind's facts say the
81
- * rest; no id row: the url carries it. With `verdicts`, the four verdicts under it (the page in one column) */
82
- export const CardHead = observer(({ rv, x, verdicts }: { rv: ReviewSt; x: ReviewItem; verdicts: boolean }) => {
69
+ * commit, the button that copies the card for an agent), then what the agent says. No subject row: the list's row names it, a kind's facts say the
70
+ * rest; no id row: the url carries it. Under it the four verdicts (on the phone they ride the bar under the thumb)
71
+ * and his overall comment, on every card and in every layout */
72
+ export const CardHead = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
83
73
  const r = x.item
74
+ const draft = rv.draft
84
75
  const status = rv.statusOf(x)
85
76
  const sent = verdictOf(x)
86
77
  const lane = laneOf(x)
@@ -93,41 +84,29 @@ export const CardHead = observer(({ rv, x, verdicts }: { rv: ReviewSt; x: Review
93
84
  {r.origin.by === 'auto' ? <AutoTag rule={r.origin.rule} /> : null}
94
85
  <span>{stateWords(x)}</span>
95
86
  {r.commit ? <span className="rv-mono" title={`commit ${r.commit}`}>@{r.commit.slice(0, 9)}</span> : null}
96
- <CopyId id={r.id} />
87
+ <button className="rv-copyid ghost" title={`copy this card for an agent (C): its title, its repo, sk review show ${r.id}, its link`} aria-label="copy this card for an agent" onClick={() => void rv.copyCard()}>
88
+ <Icon d={ICON.copy} />
89
+ </button>
97
90
  </div>
98
91
  <Rework rv={rv} x={x} />
99
92
  {r.views.length > 0 && r.what ? <div className="md rv-what" dangerouslySetInnerHTML={{ __html: renderMd(r.what) }} /> : null}
100
93
  {r.keeps ? <div className="rv-keeps"><b>keeps:</b> {r.keeps}</div> : null}
101
94
  {r.why ? <div className="rv-pre rv-why">another subject: {r.why}</div> : null}
102
- {verdicts && !r.wip && r.origin.by === 'agent' ? (
95
+ {!rv.phone.on && !r.wip && r.origin.by === 'agent' ? (
103
96
  <div className="rv-headverdicts">
104
- {VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} short />)}
97
+ {VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} />)}
105
98
  <Listeners rv={rv} cards={[x]} />
106
99
  </div>
107
100
  ) : null}
101
+ <textarea className="rv-comment" rows={2} placeholder="your overall word (a verdict sends it)" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
108
102
  </div>
109
103
  )
110
104
  })
111
105
 
112
- /** the id, copied on a click: an agent names a card by it */
113
- const CopyId = ({ id }: { id: string }) => {
114
- const [done, setDone] = useState(false)
115
- return (
116
- <button className="rv-copyid ghost" title={`${id}: click to copy`} onClick={() => {
117
- void navigator.clipboard?.writeText(id).then(() => {
118
- setDone(true)
119
- setTimeout(() => setDone(false), 1200)
120
- })
121
- }}>
122
- {done ? '✓ copied' : '⧉ id'}
123
- </button>
124
- )
125
- }
126
-
127
106
  /** one verdict: plain until one is sent, then the chosen one filled and the others dimmed */
128
- const VerdictButton = ({ v, sent, busy, send, short: brief }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void; short?: boolean }) => (
129
- <button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send} title={brief ? `${v.label} (${v.key})` : undefined}>
130
- <span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {brief ? v.short : v.label} ({v.key}){sent === v.status && !brief ? ' · chosen' : ''}
107
+ const VerdictButton = ({ v, sent, busy, send }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void }) => (
108
+ <button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send} title={`${v.label} (${v.key})`}>
109
+ <span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {v.short} ({v.key})
131
110
  </button>
132
111
  )
133
112
 
@@ -8,7 +8,7 @@ import { Group, Panel, Separator } from 'react-resizable-panels'
8
8
  import type { RepoSt } from '../repo-st.ts'
9
9
  import { ReviewForm, VerdictBar } from './review-answer.tsx'
10
10
  import { ReviewList } from './review-list.tsx'
11
- import type { PhonePane } from './review-st.ts'
11
+ import type { PhonePane, ReviewSt } from './review-st.ts'
12
12
  import { ReviewImage } from './review-views.tsx'
13
13
 
14
14
  const PANE_LABEL: Record<PhonePane, string> = { list: 'cards', card: 'card', answer: 'answer' }
@@ -51,9 +51,12 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
51
51
  {rv.phone.pane === 'list' ? <ReviewList rv={rv} /> : rv.phone.pane === 'card' ? <ReviewImage rv={rv} /> : <ReviewForm rv={rv} />}
52
52
  </div>
53
53
  {rv.phone.pane === 'list' ? null : <VerdictBar rv={rv} />}
54
+ <Toast rv={rv} />
54
55
  </div>
55
56
  )
56
57
  return (
58
+ <>
59
+ <Toast rv={rv} />
57
60
  <Group className="rv-cols" orientation="horizontal" elementRef={el}>
58
61
  <Panel id="review-list" defaultSize={rv.stacked ? '32%' : '20%'} minSize={160} className="rv-pane" style={PANEL}>
59
62
  <ReviewList rv={rv} />
@@ -79,6 +82,7 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
79
82
  </>
80
83
  )}
81
84
  </Group>
85
+ </>
82
86
  )
83
87
  })
84
88
 
@@ -92,5 +96,8 @@ export const PhoneTop = observer(({ app }: { app: RepoSt }) => (
92
96
  </div>
93
97
  ))
94
98
 
99
+ /** what the last copy put on the clipboard, a moment over the page */
100
+ const Toast = observer(({ rv }: { rv: ReviewSt }) => (rv.toast ? <div className="rv-toast" role="status">{rv.toast}</div> : null))
101
+
95
102
  /** a panel never scrolls itself: the column inside it does (hub-app.tsx, PANEL) */
96
103
  export const PANEL = { overflow: 'hidden' } as const
@@ -10,6 +10,7 @@ import {
10
10
  isPair,
11
11
  laneOf,
12
12
  moveOf,
13
+ pasteBlock,
13
14
  reworkOf,
14
15
  STARS,
15
16
  STATUS_LABEL,
@@ -31,6 +32,7 @@ import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive,
31
32
  import { reach } from '../reach.ts'
32
33
  import type { RepoSt } from '../repo-st.ts'
33
34
  import type { Role } from '../../../review/role.ts'
35
+ import type { PasteHosts } from '../../../review/paste.ts'
34
36
  import { ClipSt } from './clip-st.ts'
35
37
 
36
38
  interface Draft {
@@ -66,7 +68,7 @@ export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mo
66
68
 
67
69
  /** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
68
70
  /** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
69
- export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
71
+ export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[]; where?: PasteHosts | null }
70
72
 
71
73
  export const PHONE_PX = 768
72
74
  /** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
@@ -144,6 +146,26 @@ async function postJson<T>(url: string, body: unknown): Promise<T> {
144
146
  return JSON.parse(text) as T
145
147
  }
146
148
 
149
+ /** a text to the clipboard: the clipboard api, else (a page over plain http, the phone through a public host) the
150
+ * old copy command on a hidden field */
151
+ async function copyText(text: string): Promise<boolean> {
152
+ try {
153
+ await navigator.clipboard.writeText(text)
154
+ return true
155
+ } catch {
156
+ const el = document.createElement('textarea')
157
+ el.value = text
158
+ el.setAttribute('readonly', '')
159
+ el.style.position = 'fixed'
160
+ el.style.opacity = '0'
161
+ document.body.appendChild(el)
162
+ el.select()
163
+ const ok = document.execCommand('copy')
164
+ el.remove()
165
+ return ok
166
+ }
167
+ }
168
+
147
169
  /** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
148
170
  export class CompareSt {
149
171
  mode: CompareMode = 'side'
@@ -234,6 +256,9 @@ export class ReviewSt {
234
256
  unfolded: Record<string, boolean> = {}
235
257
  busy = false
236
258
  error: string | null = null
259
+ /** what the last copy put on the clipboard, in words, shown a moment */
260
+ toast: string | null = null
261
+ private toastTimer: ReturnType<typeof setTimeout> | null = null
237
262
  /** the card against its answer, his pick, kept with the prefs */
238
263
  place: CardPlace = 'auto'
239
264
  /** the page is narrower than STACK_PX */
@@ -294,7 +319,7 @@ export class ReviewSt {
294
319
  } catch {
295
320
  // a broken pref is the default
296
321
  }
297
- makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
322
+ makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix' | 'toastTimer'>(this, {
298
323
  st: false,
299
324
  clips: false,
300
325
  clipsCard: false,
@@ -303,6 +328,7 @@ export class ReviewSt {
303
328
  loadList: false,
304
329
  loadWatchers: false,
305
330
  prefix: false,
331
+ toastTimer: false,
306
332
  compare: false,
307
333
  phone: false,
308
334
  })
@@ -356,6 +382,27 @@ export class ReviewSt {
356
382
  }
357
383
  }
358
384
 
385
+ /** the open card as one paste a fresh agent session in any repo acts on: what it is, its repo, the command that shows
386
+ * it whole, its link here and on the phone (C) */
387
+ async copyCard(): Promise<void> {
388
+ const x = this.open
389
+ if (!x) return
390
+ const id = x.item.id
391
+ const w = this.spec?.where
392
+ const here = typeof location === 'undefined' ? (w?.open ?? '') : `${location.origin}${location.pathname}${location.search}`
393
+ const text = pasteBlock(x.item, STATUS_LABEL[this.statusOf(x)], {
394
+ root: w?.root ?? '<the repo>',
395
+ open: `${here}#page:review:${id}`,
396
+ phone: w?.phone ? `${w.phone}#page:review:${id}` : null,
397
+ })
398
+ const ok = await copyText(text)
399
+ runInAction(() => {
400
+ this.toast = ok ? `copied for an agent: title, repo, sk review show, ${w?.phone ? 'links' : 'link'}` : 'the browser refused the copy'
401
+ if (this.toastTimer !== null) clearTimeout(this.toastTimer)
402
+ this.toastTimer = setTimeout(() => runInAction(() => (this.toast = null)), 2600)
403
+ })
404
+ }
405
+
359
406
  setBoard(on: boolean): void {
360
407
  this.board = on
361
408
  }
@@ -871,7 +918,7 @@ export class ReviewSt {
871
918
  }
872
919
 
873
920
  /** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
874
- * A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
921
+ * A archives, N the next waiting card, C copies the card for an agent, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
875
922
  * (a sound: two seconds); on option rows 1 to 9 play that row, ⇧1 to ⇧5 score the row playing, P picks it. A digit is
876
923
  * read from its physical key (`code`), so ⇧ and an AZERTY keyboard read the same */
877
924
  key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'> & { code?: string }): boolean {
@@ -894,6 +941,7 @@ export class ReviewSt {
894
941
  else if (k === 'r') void this.submit('rejected')
895
942
  else if (k === 'a') void this.submit('archived')
896
943
  else if (k === 'n') this.nextPending()
944
+ else if (k === 'c') void this.copyCard()
897
945
  else if (k === 'm') this.toggleMarks()
898
946
  else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
899
947
  else return false
@@ -22,7 +22,7 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
22
22
  const rows = rv.rows
23
23
  return (
24
24
  <div className="rv-views">
25
- {rv.headInViews ? <CardHead rv={rv} x={x} verdicts={rv.stacked && !rv.phone.on} /> : null}
25
+ {rv.headInViews ? <CardHead rv={rv} x={x} /> : null}
26
26
  <div className="rv-viewsbar">
27
27
  {views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
28
28
  <span className="rv-viewsctl">
@@ -203,14 +203,16 @@
203
203
  .rv-head{display:flex;flex-direction:column;gap:var(--s1)}
204
204
  .rv-meta{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;font-size:12px;color:var(--dim);line-height:18px}
205
205
  .rv-meta .rv-badge{font-size:10px;line-height:18px;max-width:none}
206
- .rv-copyid{font-size:11.5px;padding:0 var(--s1);line-height:18px;color:var(--dim)}
206
+ .rv-copyid{display:inline-flex;align-items:center;padding:var(--s0);color:var(--dim)}
207
207
  .rv-copyid:hover{color:var(--ink)}
208
+ .rv-toast{position:fixed;left:50%;bottom:var(--s4);transform:translateX(-50%);z-index:40;max-width:calc(100% - 2 * var(--s4));padding:var(--s1) var(--s3);border-radius:6px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px;box-shadow:0 6px 20px var(--shadow)}
208
209
  .rv-what{font-size:13.5px;line-height:1.45;color:var(--ink)}
209
210
  .rv-what ul{margin:var(--s0) 0;padding-left:var(--s4)}
210
211
  .rv-what p{margin:var(--s0) 0}
211
212
  .rv-why{color:var(--dim);font-size:12px}
212
213
  .rv-headverdicts{display:flex;flex-wrap:wrap;gap:var(--s1);align-items:center;margin-top:var(--s1)}
213
214
  .rv-headverdicts .rv-verdict{padding:0 var(--s2);line-height:24px;font-size:12.5px}
215
+ .rv-head .rv-comment{margin-top:var(--s1);width:100%}
214
216
  .rv-keeps{color:var(--ok);font-size:13px}
215
217
  .rv-facts{display:flex;flex-direction:column}
216
218
  .rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:22px;font-size:12.5px}
@@ -227,9 +229,6 @@
227
229
  .rv-outbadge.rect{--tone:var(--warn)}
228
230
  .rv-outbadge.rect.on{--tone:var(--blue)}
229
231
  .rv-x{flex:none;padding:0 var(--s1);border:0}
230
- .rv-label{font-size:12px;font-weight:600;margin-top:var(--s1)}
231
- .rv-listenrow{display:flex;gap:var(--s2);align-items:center;font-size:11px}
232
- .rv-verdicts{display:flex;flex-direction:column;gap:var(--s1)}
233
232
  .rv-verdict{text-align:left;padding:var(--s1) var(--s3);font-size:13px;border-color:color-mix(in srgb,var(--tone) 60%,var(--line))}
234
233
  .rv-verdict .rv-vglyph{color:var(--tone);font-weight:800}
235
234
  .rv-verdict.chosen{background:var(--tone);color:var(--bg);border-color:var(--tone)}
@@ -252,6 +251,7 @@
252
251
  .rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
253
252
  .rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
254
253
  .rv-phone .rv-views{gap:var(--s3)}
254
+ .rv-phone .rv-toast{top:var(--s4);bottom:auto}
255
255
  /* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
256
256
  .rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
257
257
  .rv-phone .rv-optlabel{flex:1;max-width:none}