rvlib-shipkit 2.4.0 → 2.5.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.
- package/CHANGELOG.md +8 -0
- package/package.json +2 -2
- package/src/cli/cmd_Commands.ts +26 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +16 -1
- package/src/cli/cmd_Trailer.ts +4 -1
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -135
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/review/model.ts +19 -0
- package/src/review/paste.ts +30 -0
- package/src/review/routes.ts +10 -3
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/review/review-answer.tsx +16 -37
- package/src/serve/hub-app/review/review-page.tsx +8 -1
- package/src/serve/hub-app/review/review-st.ts +51 -3
- package/src/serve/hub-app/review/review-views.tsx +1 -1
- package/src/serve/hub-app/review.css +4 -4
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +8 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/trailer/build.ts +13 -5
- package/src/trailer/ffmpeg.ts +21 -1
package/src/home/process-rows.ts
CHANGED
|
@@ -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
|
}
|
package/src/home/run-daemon.ts
CHANGED
|
@@ -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, () => {
|
package/src/review/model.ts
CHANGED
|
@@ -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}`
|
package/src/review/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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 |
|
|
22
|
-
| recent changes and why |
|
|
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 |
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
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
|
|
81
|
-
* rest; no id row: the url carries it.
|
|
82
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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)}
|
|
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
|
|
129
|
-
<button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send} title={
|
|
130
|
-
<span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {
|
|
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}
|
|
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{
|
|
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}
|