rvlib-shipkit 2.6.0 → 2.8.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 +13 -0
- package/package.json +11 -3
- package/src/assets/gen/comfyts-gen.ts +56 -25
- package/src/assets/kits/identity-kit.ts +23 -4
- package/src/cli/cmd_Review.ts +8 -0
- package/src/collections/review.ts +20 -5
- package/src/fleet/scan-fleet.ts +4 -1
- package/src/review/model.ts +7 -4
- package/src/review/threads.ts +4 -3
- package/src/serve/hub-app/home-st.ts +17 -2
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/hub-app/layout-st.ts +17 -2
- package/src/serve/hub-app/project-list.css +8 -3
- package/src/serve/hub-app/project-list.tsx +17 -6
- package/src/serve/hub-app/projects-svc.ts +21 -8
- package/src/serve/hub-app/review/review-st.ts +5 -4
- package/src/serve/project-gutter-route.ts +18 -1
- package/src/shot/clip-encoder.ts +29 -5
- package/src/trailer/score/synth.ts +15 -7
- package/src/trailer/sound/comfy.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 2.8.0
|
|
2
|
+
|
|
3
|
+
- **A brand's `icon` draws the favicon family at each size.** `icon: canvas(draw)` in `.shipkit/identity/brand.ts` draws `favicon.png` (512), `favicon-32.png`, `favicon-16.png` and `apple-touch-icon.png` (180, flattened on `palette.bg`), each at its own size, `asset.id` naming the file, so a 16 px tab reads a shape made for it, never a shrunk logo. Without one the favicon is the logo master resized, as before.
|
|
4
|
+
- **comfy-ts is an optional peer, no longer a dependency.** A repo that never declares comfy no longer installs it (npm and bun both skip an optional peer). The toolkit names comfy-ts in no import, a type import included, so importing it, `sk ci`, `sk serve`, the hub and `sk assets identity` never load it. It is loaded at the first workflow load, from the repo's copy. A repo with comfy kinds or `.cflow.ts` modules installs it itself: `bun add -d comfy-ts`. Without it, a workflow load fails loud with that line, and `sk sound` fails loud when no `comfy-ts` is on PATH. `LoadedWorkflow.wf` is typed by the toolkit's own `CflowWorkflow`, the slice of comfy-ts it drives, held to comfy-ts's types by a test.
|
|
5
|
+
- **`og.png` is written small.** The identity writer encodes the og card as a 256 colour palette png at maximum compression: invisible at preview size, and a photo card drops about 3x (pkm-multi's banner card: 970 KB → 280 KB), under the size where some chat apps stop showing a preview. The file name and format stay `og.png`.
|
|
6
|
+
|
|
7
|
+
## 2.7.0
|
|
8
|
+
|
|
9
|
+
- **What waits on the owner is a pill on every repo's row of the project list.** Beside the open asks badge, a pill in the review hue counts the cards that wait on the owner's verdict or answer; zero draws none, and folded it rides the icon's top left corner. A click opens that repo's review page on its first waiting card, the list on the owner's move. One count, `waitingCards` (`collections/review.ts`), read by the tree's review row, `sk needs`, the review page and the list; `/api/projects.json` reads it on every request, so a new card shows within one poll and a verdict at once. Below 768 px the list is always the rail, so the pills show on a phone.
|
|
10
|
+
- **A trailer's score is the game's waveform, not only its level.** A note starts on its time's nearest sample frame, as the game's synth now rounds it (a time a hair past a frame, 14.450000000000001, no longer starts a frame late); a noise note's playback rate is held per 128 frame block, the browser's k-rate, its sweep starting at the next block (risers and whooshes were another noise). pkm-multi's main4: 38 of 38 sections within 0.1 dB and every one the same waveform, the difference at least 40 dB under it (four were about 10 dB). Every score renders again once.
|
|
11
|
+
- **`sk fleet` skips a linked worktree.** A dir whose `.git` is a file is its main checkout again, as the home already reads it: `--update` no longer dirties a checkout that only follows main (a `-live` one) and blocks its own updater.
|
|
12
|
+
- **A « fix » or a « no » the agent read is still owed.** It stays `claimed` and active (« hide handled » no longer hides it) until `sk review done`, `wontfix` or a new version; `sk review inbox` lists it under `owed`.
|
|
13
|
+
|
|
1
14
|
## 2.6.0
|
|
2
15
|
|
|
3
16
|
- **A trailer's score sounds as the game's browser synth, section for section.** A note that starts inside a 128 frame block takes the phase the browser gives it (it runs at the default 440 Hz until its own frame of that block), so overlapping notes of one pitch, a timpani roll, add or cancel as in the browser; `osc: 'square'` is the browser's own square wave, no longer a sine; the master's compressor and limiter are the browser's kernel (32 frame divisions, adaptive release, the sine warp on the gain). pkm-multi's main4 score: 38 of 38 sections within 0.1 dB of the game's render (were 18), each octave band within 0.15 dB. Every score renders again once.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"@types/bun": "^1.3.14",
|
|
26
26
|
"@types/react": "^19.2.17",
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
|
-
"@types/sanitize-html": "^2.16.1"
|
|
28
|
+
"@types/sanitize-html": "^2.16.1",
|
|
29
|
+
"comfy-ts": "^2.9.0"
|
|
29
30
|
},
|
|
30
31
|
"overrides": {
|
|
31
32
|
"mobx": "^7.0.0",
|
|
@@ -38,7 +39,6 @@
|
|
|
38
39
|
"@floating-ui/react": "^0.27.20",
|
|
39
40
|
"@napi-rs/canvas": "^1.1.0",
|
|
40
41
|
"clipanion": "^4.0.0-rc.4",
|
|
41
|
-
"comfy-ts": "^2.9.0",
|
|
42
42
|
"ink": "^7.1.1",
|
|
43
43
|
"marked": "^18.0.7",
|
|
44
44
|
"mobx": "^7.0.0",
|
|
@@ -48,5 +48,13 @@
|
|
|
48
48
|
"react-resizable-panels": "^4.13.3",
|
|
49
49
|
"sanitize-html": "^2.17.6",
|
|
50
50
|
"sharp": "^0.35.3"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"comfy-ts": "^2.9.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"comfy-ts": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
51
59
|
}
|
|
52
60
|
}
|
|
@@ -4,16 +4,40 @@
|
|
|
4
4
|
// must disconnectAll() in a finally, or the host websocket keeps bun alive.
|
|
5
5
|
// Workflows are discriminated by duck-typing, never instanceof: a consumer's
|
|
6
6
|
// .cflow.ts may hold a second copy of comfy-ts in its module graph.
|
|
7
|
+
// comfy-ts is an optional peer: this file is the one place that loads it, at
|
|
8
|
+
// the first workflow load, and never names it in an import, so a repo without
|
|
9
|
+
// it imports and typechecks the toolkit (tests/comfy-optional.test.ts).
|
|
7
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
11
|
import { basename, join } from 'path'
|
|
9
12
|
import { pathToFileURL } from 'url'
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
// the slice of comfy-ts the toolkit drives, held to comfy-ts's own types by
|
|
15
|
+
// tests/comfy-optional.test.ts
|
|
16
|
+
export type CflowVar = { readonly kind: string; parse(raw: string): boolean; loadJSON(value: unknown): unknown; reset(): unknown }
|
|
17
|
+
export type CflowImage = { readonly ComfyNode: { class_type?: string } | null | undefined; readonly metadata: { type?: string }; readonly buffer: Uint8Array }
|
|
18
|
+
export type CflowExecution = { readonly status: string | null | undefined; data: { error?: unknown }; images: CflowImage[]; imageErrors: unknown[] }
|
|
19
|
+
export type CflowHost = {
|
|
20
|
+
data: { id: string }
|
|
21
|
+
schema: { readonly size: number }
|
|
22
|
+
getServerHostHTTP(): string
|
|
23
|
+
disconnect(): void
|
|
24
|
+
interrupt(): Promise<void>
|
|
25
|
+
}
|
|
26
|
+
export type CflowWorkflow = {
|
|
27
|
+
spec: { id?: string }
|
|
28
|
+
readonly vars: { [key: string]: CflowVar }
|
|
29
|
+
host: CflowHost
|
|
30
|
+
entries(): [string, CflowVar][]
|
|
31
|
+
build(): Promise<{ problems: { title: string }[]; readonly size: number }>
|
|
32
|
+
run(settings?: { onProgress?: (p: { percent: number; nodeName: string | null }) => void }): Promise<CflowExecution>
|
|
33
|
+
}
|
|
34
|
+
type ComfyTsModule = { ComfyTS?: { create(o: { rootPath: string }): unknown } }
|
|
11
35
|
|
|
12
36
|
export type GenProgress = { value: number; max: number; node: string | null }
|
|
13
37
|
export type OnProgress = (p: GenProgress) => void
|
|
14
38
|
|
|
15
39
|
/** root: the repo the workflow belongs to, which holds its drafts */
|
|
16
|
-
export type LoadedWorkflow = { id: string; root: string; file: string; wf:
|
|
40
|
+
export type LoadedWorkflow = { id: string; root: string; file: string; wf: CflowWorkflow }
|
|
17
41
|
export type ComfyTsRuntime = { workflows: Map<string, LoadedWorkflow>; heal?: string }
|
|
18
42
|
|
|
19
43
|
// function declaration, not const — TS only narrows on never-calls to
|
|
@@ -22,35 +46,39 @@ function fail(msg: string): never { throw new Error(msg) }
|
|
|
22
46
|
|
|
23
47
|
// same duck predicate the comfy-ts TUI and serve bind to: a module valid
|
|
24
48
|
// for shipkit is valid for `bunx comfy-ts tui` and vice versa
|
|
25
|
-
function findDefinedWorkflow(mod: Record<string, unknown>):
|
|
26
|
-
const isDefined = (x: unknown): x is
|
|
49
|
+
function findDefinedWorkflow(mod: Record<string, unknown>): CflowWorkflow | null {
|
|
50
|
+
const isDefined = (x: unknown): x is CflowWorkflow =>
|
|
27
51
|
typeof x === 'object' && x != null && 'spec' in x &&
|
|
28
|
-
typeof (x as
|
|
29
|
-
typeof (x as
|
|
52
|
+
typeof (x as CflowWorkflow).run === 'function' &&
|
|
53
|
+
typeof (x as CflowWorkflow).entries === 'function'
|
|
30
54
|
return [mod.default, ...Object.values(mod)].find(isDefined) ?? null
|
|
31
55
|
}
|
|
32
56
|
|
|
33
|
-
const varsOf = (wf: DefinedWorkflow): Record<string, AnyVar> => wf.vars as Record<string, AnyVar>
|
|
34
|
-
|
|
35
57
|
/** true when the image belongs in a roll. Preview-family nodes (PreviewImage,
|
|
36
58
|
* PreviewBridge, …) emit raw temp images that comfy-ts downloads like any
|
|
37
59
|
* output, in completion order, and MediaImage drops the server-side
|
|
38
60
|
* 'temp' marker, so the producing node's class_type is the only signal left.
|
|
39
61
|
* no node json (SaveImageWebsocket frames) = deliberate output, kept. */
|
|
40
|
-
export const isRollImage = (img: Pick<
|
|
62
|
+
export const isRollImage = (img: Pick<CflowImage, 'ComfyNode'>): boolean =>
|
|
41
63
|
!/preview/i.test(img.ComfyNode?.class_type ?? '')
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
const COMFY_TS = 'comfy-ts'
|
|
66
|
+
const INSTALL_COMFY_TS = 'Install it in this repo: bun add -d comfy-ts'
|
|
67
|
+
|
|
68
|
+
/** the ComfyTS class of the comfy-ts a bare import from `dir` reaches, else
|
|
69
|
+
* the toolkit's own; loud when neither is installed */
|
|
70
|
+
async function comfyTsOf(dir: string): Promise<NonNullable<ComfyTsModule['ComfyTS']>> {
|
|
71
|
+
for (const from of [dir, import.meta.dir]) {
|
|
72
|
+
let path: string
|
|
73
|
+
try {
|
|
74
|
+
path = Bun.resolveSync(COMFY_TS, from)
|
|
75
|
+
} catch {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const mod = (await import(path)) as ComfyTsModule
|
|
79
|
+
if (typeof mod.ComfyTS?.create === 'function') return mod.ComfyTS
|
|
51
80
|
}
|
|
52
|
-
|
|
53
|
-
return typeof mod.ComfyTS?.create === 'function' ? mod.ComfyTS : ComfyTS
|
|
81
|
+
fail(`comfy-ts is not installed, and ${dir}/ holds comfy-ts workflow modules. ${INSTALL_COMFY_TS}`)
|
|
54
82
|
}
|
|
55
83
|
|
|
56
84
|
/** dir = the flat folder holding the <id>.cflow.ts workflow modules.
|
|
@@ -74,7 +102,10 @@ export async function loadCflowWorkflows(root: string, dir: string): Promise<Map
|
|
|
74
102
|
for (const f of readdirSync(dir).filter((x) => x.endsWith('.cflow.ts')).sort()) {
|
|
75
103
|
const file = join(dir, f)
|
|
76
104
|
const id = basename(f, '.cflow.ts')
|
|
77
|
-
const mod = (await import(pathToFileURL(file).href))
|
|
105
|
+
const mod = (await import(pathToFileURL(file).href).catch((e: Error) => {
|
|
106
|
+
if (/Cannot find (package|module) 'comfy-ts'/.test(e.message)) fail(`${file} imports comfy-ts, which this repo does not install. ${INSTALL_COMFY_TS}`)
|
|
107
|
+
throw e
|
|
108
|
+
})) as Record<string, unknown>
|
|
78
109
|
const wf = findDefinedWorkflow(mod)
|
|
79
110
|
if (wf == null) fail(`${file}: exports no DefinedWorkflow — export default host.defineWorkflow({...})`)
|
|
80
111
|
// the file name IS the id: it is also the comfy-ts draft folder key,
|
|
@@ -96,7 +127,7 @@ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workf
|
|
|
96
127
|
: workflows.size === 1
|
|
97
128
|
? [...workflows.values()][0]!
|
|
98
129
|
: fail(`asset '${ref.assetId ?? '?'}' names no workflow and ${workflows.size} are loaded (${[...workflows.keys()].join(', ')}) — set workflow on the kind`)
|
|
99
|
-
const vars =
|
|
130
|
+
const vars = lw.wf.vars
|
|
100
131
|
if (vars.prompt?.kind !== 'prompt' || vars.seed?.kind !== 'seed')
|
|
101
132
|
fail(`kind '${ref.kind ?? '?'}' drives workflow '${lw.id}', which must declare vars.prompt (v.prompt) and vars.seed (v.seed) — the atelier writes both`)
|
|
102
133
|
return lw
|
|
@@ -105,8 +136,8 @@ export function workflowFor(workflows: Map<string, LoadedWorkflow>, ref: { workf
|
|
|
105
136
|
/** Apply a name→value record onto a workflow's vars: strings go through
|
|
106
137
|
* parse (validated), everything else through loadJSON (the drafts setter).
|
|
107
138
|
* Unknown names throw listing what the workflow has. */
|
|
108
|
-
export function applyVarValues(wf:
|
|
109
|
-
const defs =
|
|
139
|
+
export function applyVarValues(wf: CflowWorkflow, vars: Record<string, unknown>, source: string): void {
|
|
140
|
+
const defs = wf.vars
|
|
110
141
|
for (const [name, value] of Object.entries(vars)) {
|
|
111
142
|
const varDef = defs[name]
|
|
112
143
|
if (varDef == null) fail(`${source}: unknown var '${name}' on workflow '${wf.spec.id ?? '?'}' (has: ${wf.entries().map(([n, d]) => `${n}:${d.kind}`).join(', ')})`)
|
|
@@ -167,7 +198,7 @@ export async function generateComfyTs(
|
|
|
167
198
|
// names no kind. It is what the tune button edits
|
|
168
199
|
const draftId = args.draftId ?? args.assetId
|
|
169
200
|
ensureAssetDraft(lw, draftId, args.prompt)
|
|
170
|
-
const defs =
|
|
201
|
+
const defs = lw.wf.vars
|
|
171
202
|
// modules are import-cached: a previous asset's values must not leak
|
|
172
203
|
for (const [, varDef] of lw.wf.entries()) varDef.reset()
|
|
173
204
|
if (args.vars != null) applyVarValues(lw.wf, args.vars, `asset '${args.assetId}' kit vars`)
|
|
@@ -192,7 +223,7 @@ export async function generateComfyTs(
|
|
|
192
223
|
defs.seed!.parse(`= ${args.seed}`)
|
|
193
224
|
mkdirSync(args.destDir, { recursive: true })
|
|
194
225
|
const healHint = rt.heal != null ? ` — is the backend up? (${rt.heal})` : ''
|
|
195
|
-
let execution:
|
|
226
|
+
let execution: CflowExecution
|
|
196
227
|
try {
|
|
197
228
|
execution = await lw.wf.run({
|
|
198
229
|
onProgress: args.onProgress == null ? undefined : (p) => args.onProgress!({ value: Math.round(p.percent), max: 100, node: p.nodeName }),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// kits/identity — favicon / logo / OG as asset kinds. One render path
|
|
2
2
|
// (renderIdentityFromMark: mark png → logo master 1024², favicon 512², og
|
|
3
|
-
// card
|
|
3
|
+
// card; a brand's `icon` draws the favicon family at each size instead), two
|
|
4
|
+
// ways to get the mark:
|
|
4
5
|
// - canvas mark: deterministic, instant — `shipkit assets identity` draws it
|
|
5
6
|
// headlessly and renders the set.
|
|
6
7
|
// - comfy mark: roll-and-pick in the studio; the kind carries a BUILT-IN ship
|
|
@@ -28,6 +29,12 @@ export type Brand = {
|
|
|
28
29
|
/** comfy marks: the FULL generation prompt for identity-logo rolls
|
|
29
30
|
* (style included — the studio shows/edits exactly this) */
|
|
30
31
|
markPrompt?: string
|
|
32
|
+
/** the small mark, when the logo does not read at 16 px: favicon.png,
|
|
33
|
+
* favicon-32.png, favicon-16.png and apple-touch-icon.png (180², flattened
|
|
34
|
+
* on palette.bg) are each drawn at their own size; `asset.id` names the
|
|
35
|
+
* file (apple-touch-icon fills its square: iOS rounds it). Absent, the
|
|
36
|
+
* favicon is the logo master resized */
|
|
37
|
+
icon?: CanvasGen
|
|
31
38
|
/** og layout hook — replaces the WHOLE default card (bg + mark left +
|
|
32
39
|
* name/tagline right); the hook owns compositing the mark if it wants one */
|
|
33
40
|
drawOg?: (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: number }) => void
|
|
@@ -42,6 +49,8 @@ export const FAVICON_SIZE = 512
|
|
|
42
49
|
export const OG_W = 1200
|
|
43
50
|
export const OG_H = 630
|
|
44
51
|
export const LOGO_SIZE = 1024
|
|
52
|
+
/** what a brand's `icon` draws, each at its own size */
|
|
53
|
+
export const ICON_FILES = [['favicon', FAVICON_SIZE], ['favicon-32', 32], ['favicon-16', 16], ['apple-touch-icon', 180]] as const
|
|
45
54
|
|
|
46
55
|
/** the one next step when a comfy mark has no picked roll yet */
|
|
47
56
|
export const NO_PICK_FIX = 'roll + pick in the studio: bun run hub → /assets/#identity-logo'
|
|
@@ -110,13 +119,23 @@ export async function renderIdentityFromMark(brand: Brand, root: string, markPng
|
|
|
110
119
|
.resize(LOGO_SIZE, LOGO_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
111
120
|
.png().toBuffer()
|
|
112
121
|
await write('logo.png', logoPng)
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
if (brand.icon != null) {
|
|
123
|
+
for (const [id, px] of ICON_FILES) {
|
|
124
|
+
const c = createCanvas(px, px)
|
|
125
|
+
await brand.icon.draw(c.getContext('2d'), { id, prompt: '' })
|
|
126
|
+
const png = sharp(c.toBuffer('image/png'))
|
|
127
|
+
await write(`${id}.png`, await (id === 'apple-touch-icon' ? png.flatten({ background: brand.palette.bg }) : png).png().toBuffer())
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// favicon = logo master resized to 512² (browsers derive the rest)
|
|
131
|
+
await write('favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
|
|
132
|
+
}
|
|
115
133
|
const og = createCanvas(OG_W, OG_H)
|
|
116
134
|
const ctx = og.getContext('2d')
|
|
117
135
|
if (brand.drawOg != null) brand.drawOg(ctx, brand, { w: OG_W, h: OG_H })
|
|
118
136
|
else defaultOg(ctx, brand, { w: OG_W, h: OG_H }, await loadImage(logoPng))
|
|
119
|
-
|
|
137
|
+
// a link preview is small: a 256 colour palette is invisible there and cuts a photo card about 3x (some chat apps drop a preview past ~300 KB)
|
|
138
|
+
await write('og.png', await sharp(og.toBuffer('image/png')).png({ palette: true, quality: 90, effort: 10, compressionLevel: 9 }).toBuffer())
|
|
120
139
|
return out
|
|
121
140
|
}
|
|
122
141
|
|
package/src/cli/cmd_Review.ts
CHANGED
|
@@ -436,6 +436,14 @@ export class ReviewInboxCommand extends Command {
|
|
|
436
436
|
if (t) printed.add(t.head.item.id)
|
|
437
437
|
h.out(`${t ? threadDetail(t, h.dir, false, h.say) : detail(x, h.dir, false, false, h.say)}\n`)
|
|
438
438
|
}
|
|
439
|
+
// a « fix » or a « no » an agent read and has not done: owed, never an unseen answer again
|
|
440
|
+
const owed = threads.filter((t) => of(t.head) && !printed.has(t.head.item.id) && t.head.item.origin.by === 'agent' && verdictOf(t.head)?.seen === true && stateOf(t.head, false).state === 'claimed')
|
|
441
|
+
const owedLine = (x: (typeof all.items)[number]): string => {
|
|
442
|
+
const fix = verdictOf(x)?.status === 'approved-with-fix'
|
|
443
|
+
const close = fix ? `sk review done ${x.item.id} "<what changed>"` : `--supersedes ${x.item.id} on its new version`
|
|
444
|
+
return ` ${x.item.id} ${fix ? 'ok with a fix' : 'rejected'} "${x.item.title}": ${close}, or sk review wontfix ${x.item.id} --note "<why>"`
|
|
445
|
+
}
|
|
446
|
+
if (owed.length > 0) h.out(`owed, read and not done:\n${owed.map((t) => owedLine(t.head)).join('\n')}\n`)
|
|
439
447
|
const watchers = liveWatchers(h.dir)
|
|
440
448
|
const untaken = all.items.flatMap((x) => {
|
|
441
449
|
const why = untakenOf(x, followed.has(x.item.id), watchers, new Date(), h.say.spec.dispatcher)
|
|
@@ -4,12 +4,27 @@
|
|
|
4
4
|
// Each collection exists only where the repo has its folder, so a repo with none draws the section greyed
|
|
5
5
|
import { existsSync } from 'node:fs'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
|
-
import { STATUS_LABEL, isFrame, stateOf } from '../review/model.ts'
|
|
8
|
-
import { readAllCached, storeDir } from '../review/store.ts'
|
|
9
|
-
import { statusOf, waitingOnOwner } from '../review/threads.ts'
|
|
7
|
+
import { STATUS_LABEL, isFrame, stateOf, type ReviewItem } from '../review/model.ts'
|
|
8
|
+
import { readAllCached, storeDir, storeStamp } from '../review/store.ts'
|
|
9
|
+
import { statusOf, waitingOnOwner, type Thread } from '../review/threads.ts'
|
|
10
10
|
import { cutsOf, trailerIds } from '../review/trailer-desk.ts'
|
|
11
11
|
import { REVIEW_COL, TRAILER_COL, type Collection, type Instance } from './collection-types.ts'
|
|
12
12
|
|
|
13
|
+
const waiting = new Map<string, { stamp: string; threads: Thread[]; items: ReviewItem[] }>()
|
|
14
|
+
|
|
15
|
+
/** what waits on the owner in a repo, the ONE count every surface shows (the tree's review row, the project list's pill,
|
|
16
|
+
* `sk needs`): read again only when the store's stamp moved */
|
|
17
|
+
export function waitingCards(root: string): { threads: Thread[]; items: ReviewItem[] } {
|
|
18
|
+
const dir = storeDir(root)
|
|
19
|
+
const stamp = storeStamp(dir)
|
|
20
|
+
const kept = waiting.get(dir)
|
|
21
|
+
if (kept?.stamp === stamp) return kept
|
|
22
|
+
const items = stamp === 'none' ? [] : readAllCached(dir).items
|
|
23
|
+
const fresh = { stamp, threads: waitingOnOwner(items), items }
|
|
24
|
+
waiting.set(dir, fresh)
|
|
25
|
+
return fresh
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
export function reviewCollection(root: string): Collection | null {
|
|
14
29
|
const dir = storeDir(root)
|
|
15
30
|
if (!existsSync(dir)) return null
|
|
@@ -19,8 +34,8 @@ export function reviewCollection(root: string): Collection | null {
|
|
|
19
34
|
kind: 'card',
|
|
20
35
|
media: 'image',
|
|
21
36
|
list: async () => {
|
|
22
|
-
const { items } =
|
|
23
|
-
return
|
|
37
|
+
const { threads, items } = waitingCards(root)
|
|
38
|
+
return threads.map(({ head: x }): Instance => ({
|
|
24
39
|
id: `card:${x.item.id}`,
|
|
25
40
|
text: `${x.item.title} (${STATUS_LABEL[statusOf(x, items)]})`,
|
|
26
41
|
label: x.item.title,
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import { isArchived } from '../core/archived.ts'
|
|
16
|
+
import { isLinkedWorktree } from '../core/git-root.ts'
|
|
16
17
|
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
17
18
|
import { isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
|
|
18
19
|
|
|
@@ -97,7 +98,9 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
|
|
|
97
98
|
continue
|
|
98
99
|
}
|
|
99
100
|
const pkg = readPkg(dir)
|
|
100
|
-
|
|
101
|
+
// a linked worktree is its main checkout again: an update dirties a
|
|
102
|
+
// tree that only follows main and blocks that tree's own updater
|
|
103
|
+
if (pkg == null || isArchived(dir) || isLinkedWorktree(dir)) continue
|
|
101
104
|
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
102
105
|
const names = Object.keys(all).filter((n) => n.startsWith(prefix))
|
|
103
106
|
if (names.length === 0) continue
|
package/src/review/model.ts
CHANGED
|
@@ -444,12 +444,12 @@ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
|
|
|
444
444
|
let s: ItemState = startOf(i)
|
|
445
445
|
// what his latest verdict left: a released or superseded rework of an agent's card goes back to it
|
|
446
446
|
let given: ItemState = s
|
|
447
|
-
const read = readBy(x, 'agent')
|
|
448
447
|
for (const m of x.thread) {
|
|
449
448
|
const keep = s.topic ? { topic: s.topic } : {}
|
|
450
449
|
if (m.verdict !== undefined) {
|
|
451
450
|
const v = m.verdict
|
|
452
|
-
|
|
451
|
+
// « fix » and « no » owe the lane work: reading the verdict is never doing it (done, wontfix or a new version closes it)
|
|
452
|
+
if (v === 'approved' || v === 'archived') s = { ...keep, state: 'closed', why: v }
|
|
453
453
|
else s = { ...keep, state: 'claimed', ...(lane ? { worker: lane } : {}) }
|
|
454
454
|
given = s
|
|
455
455
|
continue
|
|
@@ -502,8 +502,9 @@ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
|
|
|
502
502
|
break
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
|
-
// the card that redoes it landed: the
|
|
506
|
-
|
|
505
|
+
// the card that redoes it landed: the work its verdict owed is over
|
|
506
|
+
const owed = verdictOf(x)?.status
|
|
507
|
+
if (superseded && i.origin.by === 'agent' && s.state === 'claimed' && owed !== undefined) return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: owed }
|
|
507
508
|
if (verdictOf(x) === null) {
|
|
508
509
|
if (withdrawnOf(x) !== undefined) return { state: 'closed', why: 'withdrawn' }
|
|
509
510
|
if (superseded && s.state !== 'closed') return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: 'superseded' }
|
|
@@ -612,6 +613,8 @@ export function untakenOf(x: ReviewItem, superseded: boolean, watchers: readonly
|
|
|
612
613
|
if (s.state === 'open') return `raised${s.topic ? ` on ${s.topic}` : ''}, nobody claimed it`
|
|
613
614
|
if (s.state !== 'claimed') return null
|
|
614
615
|
if (s.since !== undefined) return quiet > STALE_CLAIM_MS ? `${s.worker ?? 'its'} claim went stale` : null
|
|
616
|
+
// an agent read the verdict: the work it owes is taken, only not done yet
|
|
617
|
+
if (verdictOf(x)?.seen) return null
|
|
615
618
|
return listenersOf([x], watchers).length === 0 ? `the owner's verdict went back to ${s.worker ?? 'its author'}, and no watch reads it` : null
|
|
616
619
|
}
|
|
617
620
|
|
package/src/review/threads.ts
CHANGED
|
@@ -124,8 +124,9 @@ export const isFollowed = (id: string, items: readonly ReviewItem[]): boolean =>
|
|
|
124
124
|
export const threadOfItem = (id: string, threads: readonly Thread[]): Thread | null =>
|
|
125
125
|
threads.find((t) => t.head.item.id === id) ?? threads.find((t) => membersOf(t).some((x) => x.item.id === id)) ?? null
|
|
126
126
|
|
|
127
|
-
/** a thread still needs someone: its head pending, or answered
|
|
128
|
-
*
|
|
127
|
+
/** a thread still needs someone: its head pending, or answered with work owed (« fix » until `done` or a new version,
|
|
128
|
+
* « no » until a new version or `wontfix`; reading the verdict is never doing it; an approval needs nothing), or a
|
|
129
|
+
* lane reworking it; what he or a player raised, until it is closed */
|
|
129
130
|
export function isActive(t: Thread, now = new Date()): boolean {
|
|
130
131
|
if (t.versions.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
|
|
131
132
|
if (t.answers.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
|
|
@@ -133,7 +134,7 @@ export function isActive(t: Thread, now = new Date()): boolean {
|
|
|
133
134
|
if (reworkOf(t.head, false, now)) return true
|
|
134
135
|
const f = verdictOf(t.head)
|
|
135
136
|
if (!f) return withdrawnOf(t.head) === undefined
|
|
136
|
-
return
|
|
137
|
+
return stateOf(t.head, false).state !== 'closed'
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/** the thread's conversation: every version's and every answered item's replies, oldest first, each with the item it
|
|
@@ -36,6 +36,9 @@ export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/acti
|
|
|
36
36
|
/** where a repo's hub answers on the home */
|
|
37
37
|
export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
|
|
38
38
|
/** the page of a repo on the home */
|
|
39
|
+
/** the review list's address on the cards whose move is the owner's */
|
|
40
|
+
const WAITING = 'whose=owner'
|
|
41
|
+
|
|
39
42
|
export const repoPage = (name: string): string => `${repoBase(name)}/`
|
|
40
43
|
|
|
41
44
|
/** where a check's own page answers on the home */
|
|
@@ -112,6 +115,7 @@ export class HomeSt {
|
|
|
112
115
|
const m = this.machine
|
|
113
116
|
if (m != null) {
|
|
114
117
|
this.projects.onSelect = (name) => this.select(name)
|
|
118
|
+
this.projects.onReview = (name, card) => this.openReview(name, card)
|
|
115
119
|
this.projects.onPlace = (p) => this.navigate(PLACE_PATH[p])
|
|
116
120
|
this.projects.onPicker = () => this.palette.toggle()
|
|
117
121
|
this.projects.pickerOpen = () => this.palette.open
|
|
@@ -121,6 +125,10 @@ export class HomeSt {
|
|
|
121
125
|
} else {
|
|
122
126
|
this.projects.onSelect = (name) => void this.openInHome(name)
|
|
123
127
|
const own = this.repo('')
|
|
128
|
+
this.projects.onReview = (name, card) => {
|
|
129
|
+
if (this.projects.list.some((p) => p.name === name && p.current)) own.showReview(card, WAITING)
|
|
130
|
+
else void this.openInHome(name, `#${hashFor({ t: 'review', id: card, q: WAITING })}`)
|
|
131
|
+
}
|
|
124
132
|
own.markSeen()
|
|
125
133
|
this.selected = own.key
|
|
126
134
|
}
|
|
@@ -212,6 +220,13 @@ export class HomeSt {
|
|
|
212
220
|
this.show('repo', repo, 'push')
|
|
213
221
|
}
|
|
214
222
|
|
|
223
|
+
/** the cards that wait on the owner in a repo: its review page in place, on the
|
|
224
|
+
* first one, the list on the owner's move */
|
|
225
|
+
openReview(repo: string, card: string | null): void {
|
|
226
|
+
this.repo(repo).showReview(card, WAITING)
|
|
227
|
+
this.show('repo', repo, 'push')
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
/** a link to a page of the home: the same page, in place */
|
|
216
231
|
navigate(path: string): void {
|
|
217
232
|
const u = new URL(path, 'http://home.invalid')
|
|
@@ -303,9 +318,9 @@ export class HomeSt {
|
|
|
303
318
|
|
|
304
319
|
/** a list click on a hub serving on its own: that repo's page on the
|
|
305
320
|
* home when one serves, else the folder in VS Code */
|
|
306
|
-
async openInHome(name: string): Promise<void> {
|
|
321
|
+
async openInHome(name: string, hash = ''): Promise<void> {
|
|
307
322
|
const home = await fetch('/api/home-url').then((r) => r.json() as Promise<{ port: number; up: boolean }>).catch(() => null)
|
|
308
323
|
if (home?.up !== true) return this.projects.open(name)
|
|
309
|
-
window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}`
|
|
324
|
+
window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}${hash}`
|
|
310
325
|
}
|
|
311
326
|
}
|
|
@@ -304,7 +304,7 @@ const App = observer(() => {
|
|
|
304
304
|
const dockRef = usePanelRef()
|
|
305
305
|
const listRef = usePanelRef()
|
|
306
306
|
// ⌘B folds the list to its rail and back; a drag that already did it finds it there
|
|
307
|
-
const rail = l.
|
|
307
|
+
const rail = l.folded
|
|
308
308
|
const railW = l.railPx
|
|
309
309
|
useLayoutEffect(() => {
|
|
310
310
|
const p = listRef.current
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// value can hand the page a list wider than LIST.max. The list also folds to
|
|
7
7
|
// its icon rail: a drag below its min or ⌘B, kept per browser too, its icons
|
|
8
8
|
// small, medium or large (RAIL_ICONS), the rail as wide as they need.
|
|
9
|
-
import { makeAutoObservable } from 'mobx'
|
|
9
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
10
10
|
|
|
11
11
|
export type Bounds = { key: string; min: number; max: number; def: number }
|
|
12
12
|
|
|
@@ -62,16 +62,31 @@ function loadRail(): boolean {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** a page narrower than this draws the list as its rail, whatever the pick: a list squeezed under its min hides its pills */
|
|
66
|
+
export const NARROW_PX = 768
|
|
67
|
+
|
|
65
68
|
export class LayoutSt {
|
|
66
69
|
listWidth = loadWidth(LIST)
|
|
67
|
-
/** the list shows its icons only */
|
|
70
|
+
/** the owner's pick: the list shows its icons only */
|
|
68
71
|
listRail = loadRail()
|
|
72
|
+
/** the page is narrower than NARROW_PX, read live */
|
|
73
|
+
narrow = false
|
|
69
74
|
/** the rail's icon size */
|
|
70
75
|
railIcon: RailIcon = loadRailIcon()
|
|
71
76
|
sidebarWidth = loadWidth(SIDEBAR)
|
|
72
77
|
|
|
73
78
|
constructor() {
|
|
74
79
|
makeAutoObservable(this)
|
|
80
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
81
|
+
const mq = window.matchMedia(`(max-width: ${NARROW_PX - 1}px)`)
|
|
82
|
+
this.narrow = mq.matches
|
|
83
|
+
mq.addEventListener('change', (e) => runInAction(() => (this.narrow = e.matches)))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** the list as drawn: the rail when he picked it or the page is narrow */
|
|
88
|
+
get folded(): boolean {
|
|
89
|
+
return this.listRail || this.narrow
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
/** a drag of the list's separator ended at this width */
|
|
@@ -74,9 +74,11 @@
|
|
|
74
74
|
/* ⌘1 to ⌘9 select the first nine rows (keys.ts): the hint sits at the row's end */
|
|
75
75
|
.pjkey{flex:none;font:10px/1 ui-monospace,Menlo,monospace;color:var(--dim);opacity:.6;background:none;border:0;padding:0}
|
|
76
76
|
.pjrow:hover .pjkey,.pjrow.cur .pjkey,.pjpl:hover .pjkey,.pjpl.cur .pjkey{opacity:1}
|
|
77
|
-
/*
|
|
78
|
-
.pga{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
|
|
77
|
+
/* what waits on the owner, at the end of the row: the review cards (a click opens them), then the open asks */
|
|
78
|
+
.pga,.pgr{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
|
|
79
79
|
font-size:10px;font-weight:700;line-height:16px;text-align:center;color:var(--bg);background:var(--warn)}
|
|
80
|
+
.pgr{background:var(--review);cursor:pointer}
|
|
81
|
+
.pgr:hover{box-shadow:0 0 0 2px var(--review)}
|
|
80
82
|
/* Claude sessions running: a thin edge on the left that breathes */
|
|
81
83
|
.pgs{position:absolute;left:0;top:5px;bottom:5px;width:2px;border-radius:1px;background:var(--accent);pointer-events:none;animation:pgpulse 2.4s ease-in-out infinite}
|
|
82
84
|
/* the home only: the repo's hub state (starting, crashed) */
|
|
@@ -102,6 +104,7 @@
|
|
|
102
104
|
/* NEEDS YOU: a warn toned bullet, set apart from the facts */
|
|
103
105
|
.pgcneeds{margin-top:var(--s2);padding:var(--s1) var(--s2);border-left:2px solid var(--warn);background:var(--panel2);border-radius:0 3px 3px 0}
|
|
104
106
|
.pgcneed{display:flex;gap:var(--s1);min-width:0;color:var(--warn)}
|
|
107
|
+
.pgcneed.rev{color:var(--review)}
|
|
105
108
|
.pgcneed.red{color:var(--red);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
106
109
|
.pgcask{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
107
110
|
.pgcmore{flex:none}
|
|
@@ -134,7 +137,9 @@
|
|
|
134
137
|
/* the app icon is a rounded plate already (core/app-icon.ts) */
|
|
135
138
|
.pjlist.rail .pjrow img.pjapp{border-radius:0}
|
|
136
139
|
.pjsize{font:600 10px/1 ui-monospace,Menlo,monospace}
|
|
137
|
-
.pjlist.rail .pga{position:absolute;top:0;
|
|
140
|
+
.pjlist.rail .pga,.pjlist.rail .pgr{position:absolute;top:0;min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
|
|
141
|
+
.pjlist.rail .pga{right:calc(50% - var(--ri,24px) / 2 - 4px)}
|
|
142
|
+
.pjlist.rail .pgr{left:calc(50% - var(--ri,24px) / 2 - 4px)}
|
|
138
143
|
.pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s0);right:calc(50% - var(--ri,24px) / 2 - 2px)}
|
|
139
144
|
.pjlist.rail .pjfoot{flex-direction:column;height:auto;padding:var(--s1) 0}
|
|
140
145
|
.pjlist.rail .pjfoot button{justify-content:center;width:28px;padding:0}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// down the left of the page, a small icon and the repo's name on one line,
|
|
3
3
|
// the shown repo's row marked in its identity colour. Its width is the
|
|
4
4
|
// panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
|
|
5
|
-
// three things and no more (faceOf):
|
|
5
|
+
// three things and no more (faceOf): what waits on the owner as pills (the review
|
|
6
|
+
// cards, a click away from them, then the open asks), a dim
|
|
6
7
|
// name (never the icon) while VS Code does not have the folder open, a thin edge breathing
|
|
7
8
|
// while Claude sessions run in it; on the home, a dot while its hub starts
|
|
8
9
|
// or crashed and a mark while a global process it uses is off. Everything
|
|
@@ -15,8 +16,8 @@
|
|
|
15
16
|
// (names only, no ⌘ number, a row opens the archived page, nothing loads),
|
|
16
17
|
// the foot never moving. ⌘B (or the foot's fold button, or a drag of the
|
|
17
18
|
// separator below the list's min) folds the list to its icon rail: the
|
|
18
|
-
// names, the ⌘ hints and the step hint hide, the
|
|
19
|
-
//
|
|
19
|
+
// names, the ⌘ hints and the step hint hide, the pills ride the icon's
|
|
20
|
+
// corners, the hover card still names the row; a phone width is always the rail. On the home a section of four lines on top, in the
|
|
20
21
|
// repo rows' style, opens the picker, the overview, the activity and the
|
|
21
22
|
// plan, each with its shortcut at the line's end, the page shown lit. It
|
|
22
23
|
// renders from the first paint with the list this browser kept, so a load
|
|
@@ -34,7 +35,7 @@ import { RAIL_ICONS, type RailIcon } from './layout-st.ts'
|
|
|
34
35
|
|
|
35
36
|
/** what the list needs of its page: the hub and the home both carry one;
|
|
36
37
|
* the layout, when the page can fold the list to its rail */
|
|
37
|
-
export type ListHost = { projects: ProjectsSvc; layout?: {
|
|
38
|
+
export type ListHost = { projects: ProjectsSvc; layout?: { folded: boolean; toggleRail(): void; railIcon: RailIcon; cycleRailIcon(): void }; theme?: { pick: ThemePick; cycle(): void } }
|
|
38
39
|
|
|
39
40
|
/** the lines on top of the list, in their order: the picker, the overview,
|
|
40
41
|
* then the places, each with its shortcut (keys.ts) */
|
|
@@ -96,10 +97,14 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
|
|
|
96
97
|
},
|
|
97
98
|
onDragEnd: () => s.dragEnd(),
|
|
98
99
|
})}>
|
|
99
|
-
{app.layout?.
|
|
100
|
+
{app.layout?.folded === true && p.appIcon != null
|
|
100
101
|
? <img className="pjapp" src={p.appIcon} alt="" width={ROW_ICON} height={ROW_ICON} draggable={false} />
|
|
101
102
|
: <Face p={p} px={ROW_ICON} />}
|
|
102
103
|
<span className="pjname">{p.name}</span>
|
|
104
|
+
{face.cards != null ? (
|
|
105
|
+
<b className="pgr" aria-label={`${face.cards} review card${face.cards === 1 ? '' : 's'} wait on you: open them`}
|
|
106
|
+
onClick={(e) => { e.stopPropagation(); s.review(p.name) }} onDoubleClick={(e) => e.stopPropagation()}>{face.cards}</b>
|
|
107
|
+
) : null}
|
|
103
108
|
{face.badge != null ? <b className="pga">{face.badge}</b> : null}
|
|
104
109
|
{face.busy ? <i className="pgs" /> : null}
|
|
105
110
|
{dot != null ? <i className={`pgd ${dot}`} /> : null}
|
|
@@ -138,6 +143,12 @@ export const Card = ({ p, now }: { p: Project; now: number }) => {
|
|
|
138
143
|
{m.head.description == null ? null : <div className="pgcdesc">{m.head.description}</div>}
|
|
139
144
|
{m.needs == null ? null : (
|
|
140
145
|
<div className="pgcneeds">
|
|
146
|
+
{m.needs.cards == null ? null : (
|
|
147
|
+
<div className="pgcneed rev">
|
|
148
|
+
<span className="pgcask">{m.needs.cards.count} card{m.needs.cards.count === 1 ? '' : 's'} to review: {m.needs.cards.titles.join(' · ')}</span>
|
|
149
|
+
{m.needs.cards.more > 0 ? <span className="pgcmore">+{m.needs.cards.more}</span> : null}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
141
152
|
{m.needs.asks == null ? null : (
|
|
142
153
|
<div className="pgcneed">
|
|
143
154
|
<span className="pgcask">{m.needs.asks.count} ask{m.needs.asks.count === 1 ? '' : 's'}: {m.needs.asks.titles.join(' · ')}</span>
|
|
@@ -165,7 +176,7 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
|
165
176
|
useEffect(() => app.projects.start(), [app])
|
|
166
177
|
const s = app.projects
|
|
167
178
|
const lay = app.layout
|
|
168
|
-
const rail = lay?.
|
|
179
|
+
const rail = lay?.folded === true
|
|
169
180
|
return (
|
|
170
181
|
<nav className={`pjlist${rail ? ' rail' : ''}`} aria-label="projects" style={rail ? ({ '--ri': `${RAIL_ICONS[lay!.railIcon]}px` } as CSSProperties) : undefined}>
|
|
171
182
|
{s.onPlace == null && s.onPicker == null && s.error === '' ? null : (
|
|
@@ -62,7 +62,8 @@ const keptSort = (): SortMode | null => {
|
|
|
62
62
|
export function loadList(): Project[] {
|
|
63
63
|
try {
|
|
64
64
|
const v = JSON.parse(globalThis.localStorage?.getItem(LIST_KEY) ?? '[]') as unknown
|
|
65
|
-
|
|
65
|
+
// a list kept before a row carried its cards reads as none waiting until the first poll
|
|
66
|
+
return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]).map((p) => ({ ...p, cards: p.cards ?? [] })) : []
|
|
66
67
|
} catch {
|
|
67
68
|
return []
|
|
68
69
|
}
|
|
@@ -133,12 +134,13 @@ export function sortNames(list: Project[], mode: SortMode): string[] {
|
|
|
133
134
|
* its hub starts or crashed. A hub that is up is the normal case and draws none */
|
|
134
135
|
export type HubDot = 'starting' | 'crashed' | null
|
|
135
136
|
|
|
136
|
-
/** what the icon face shows, and nothing more:
|
|
137
|
-
*
|
|
138
|
-
* does not have the folder open (the
|
|
139
|
-
* while Claude sessions run */
|
|
140
|
-
export type Face = { badge: number | null; dim: boolean; busy: boolean }
|
|
137
|
+
/** what the icon face shows, and nothing more: what waits on the owner as
|
|
138
|
+
* two pills (the review cards waiting on a verdict, the open asks waiting
|
|
139
|
+
* on hands), a dim name while VS Code does not have the folder open (the
|
|
140
|
+
* icon stays whole), and a pulsing edge while Claude sessions run */
|
|
141
|
+
export type Face = { cards: number | null; badge: number | null; dim: boolean; busy: boolean }
|
|
141
142
|
export const faceOf = (p: Project): Face => ({
|
|
143
|
+
cards: p.cards.length > 0 ? p.cards.length : null,
|
|
142
144
|
badge: p.asks > 0 ? p.asks : null,
|
|
143
145
|
dim: p.vscode === false,
|
|
144
146
|
busy: p.sessions != null && p.sessions > 0,
|
|
@@ -149,7 +151,7 @@ export const faceOf = (p: Project): Face => ({
|
|
|
149
151
|
* human, and label and value rows. Each part leaves out an unknown fact */
|
|
150
152
|
export type CardModel = {
|
|
151
153
|
head: { name: string; current: boolean; icon: string | null; version: string | null; branch: string | null; description: string | null }
|
|
152
|
-
needs: { asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
|
|
154
|
+
needs: { cards: { count: number; titles: string[]; more: number } | null; asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
|
|
153
155
|
facts: { k: 'release' | 'live' | 'activity'; v: string; tone?: 'warn' }[]
|
|
154
156
|
}
|
|
155
157
|
|
|
@@ -159,6 +161,8 @@ export const CARD_NAMES = 3
|
|
|
159
161
|
export function cardModel(p: Project, now: number): CardModel {
|
|
160
162
|
const branch = p.branch == null ? null : p.dirty != null && p.dirty > 0 ? `${p.branch} · ${p.dirty} uncommitted` : p.branch
|
|
161
163
|
const asks = p.asks > 0 ? { count: p.asks, titles: p.askTitles.slice(0, CARD_NAMES), more: Math.max(0, p.asks - CARD_NAMES) } : null
|
|
164
|
+
const n = p.cards.length
|
|
165
|
+
const cards = n > 0 ? { count: n, titles: p.cards.slice(0, CARD_NAMES).map((c) => c.title), more: Math.max(0, n - CARD_NAMES) } : null
|
|
162
166
|
const reds = p.gate != null && p.gate !== 'green' ? p.gate.reds : []
|
|
163
167
|
const gate = reds.length === 0 ? null : `gate red: ${reds.slice(0, CARD_NAMES).join(', ')}${reds.length > CARD_NAMES ? ` +${reds.length - CARD_NAMES}` : ''}`
|
|
164
168
|
const facts: CardModel['facts'] = []
|
|
@@ -177,7 +181,7 @@ export function cardModel(p: Project, now: number): CardModel {
|
|
|
177
181
|
if (act.length > 0) facts.push({ k: 'activity', v: act.join(' · ') })
|
|
178
182
|
return {
|
|
179
183
|
head: { name: p.name, current: p.current, icon: p.icon, version: p.version, branch, description: p.description },
|
|
180
|
-
needs: asks == null && gate == null ? null : { asks, gate },
|
|
184
|
+
needs: cards == null && asks == null && gate == null ? null : { cards, asks, gate },
|
|
181
185
|
facts,
|
|
182
186
|
}
|
|
183
187
|
}
|
|
@@ -216,6 +220,9 @@ export class ProjectsSvc {
|
|
|
216
220
|
/** what a single click does: the home shows that repo in place, a hub
|
|
217
221
|
* of its own opens the home on it. Default: the folder in VS Code */
|
|
218
222
|
onSelect: (name: string) => void = (name) => void this.open(name)
|
|
223
|
+
/** a click on a row's review pill: that repo's review page on its first
|
|
224
|
+
* waiting card. The home and a hub of its own each set it */
|
|
225
|
+
onReview: (name: string, card: string | null) => void = (name) => this.select(name)
|
|
219
226
|
/** the two places at the top of the list, the overview and the plan:
|
|
220
227
|
* the home sets it, a hub of its own has none */
|
|
221
228
|
onPlace: ((p: Place) => void) | null = null
|
|
@@ -281,6 +288,12 @@ export class ProjectsSvc {
|
|
|
281
288
|
this.onSelect(name)
|
|
282
289
|
}
|
|
283
290
|
|
|
291
|
+
/** the review pill: the cards that wait on the owner in that repo */
|
|
292
|
+
review(name: string): void {
|
|
293
|
+
this.selected = name
|
|
294
|
+
this.onReview(name, this.list.find((p) => p.name === name)?.cards[0]?.id ?? null)
|
|
295
|
+
}
|
|
296
|
+
|
|
284
297
|
|
|
285
298
|
/** the repo above (-1) or below (+1) the selected one, round the ends;
|
|
286
299
|
* with none selected, down starts at the top and up at the bottom */
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type Side,
|
|
29
29
|
type Spec,
|
|
30
30
|
} from '../../../review/model.ts'
|
|
31
|
-
import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
|
|
31
|
+
import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waitingOnOwner, waits, type Thread } from '../../../review/threads.ts'
|
|
32
32
|
import { reach } from '../reach.ts'
|
|
33
33
|
import type { RepoSt } from '../repo-st.ts'
|
|
34
34
|
import type { Role } from '../../../review/role.ts'
|
|
@@ -450,9 +450,9 @@ export class ReviewSt {
|
|
|
450
450
|
return orderedThreads(this.items)
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
/** what waits on
|
|
453
|
+
/** what waits on the owner, the one count of every surface (collections/review.ts waitingCards) */
|
|
454
454
|
get pendingCount(): number {
|
|
455
|
-
return
|
|
455
|
+
return waitingOnOwner(this.items).length
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
get activeCount(): number {
|
|
@@ -908,8 +908,9 @@ export class ReviewSt {
|
|
|
908
908
|
store.put(`${this.prefix}draft.${id}`, null)
|
|
909
909
|
})
|
|
910
910
|
void this.refresh()
|
|
911
|
-
// the tree's count of what waits on
|
|
911
|
+
// the tree's count of what waits on the owner moves at once, and so does the project list's pill
|
|
912
912
|
void this.st.cols.poll(true)
|
|
913
|
+
void this.st.home.projects.load()
|
|
913
914
|
} catch (e) {
|
|
914
915
|
runInAction(() => (this.error = (e as Error).message))
|
|
915
916
|
} finally {
|
|
@@ -23,11 +23,21 @@ import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
|
23
23
|
import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
|
|
24
24
|
import { writeIdentity } from '../core/identity.ts'
|
|
25
25
|
import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
|
|
26
|
+
import { waitingCards } from '../collections/review.ts'
|
|
26
27
|
import { writeArchived } from '../core/archived.ts'
|
|
27
28
|
|
|
28
29
|
export type { Gate }
|
|
29
30
|
|
|
30
31
|
export const POLL_MS = 5_000
|
|
32
|
+
|
|
33
|
+
/** a repo's waiting cards as its row carries them; a store that cannot be read waits on nobody, and its hub says why */
|
|
34
|
+
function cardsOf(path: string): ProjectRow['cards'] {
|
|
35
|
+
try {
|
|
36
|
+
return waitingCards(path).threads.map((t) => ({ id: t.head.item.id, title: t.head.item.title }))
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
31
41
|
/** the app icon preview: sharp on a 2x screen at the admin page's 128px */
|
|
32
42
|
const PREVIEW_PX = 256
|
|
33
43
|
/** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
|
|
@@ -59,6 +69,8 @@ export type ProjectRow = {
|
|
|
59
69
|
asks: number
|
|
60
70
|
/** their titles, in file order */
|
|
61
71
|
askTitles: string[]
|
|
72
|
+
/** the review cards that wait on the owner (collections/review.ts waitingCards), oldest first: read on every request */
|
|
73
|
+
cards: { id: string; title: string }[]
|
|
62
74
|
/** package.json `description`, else the README's first line of prose */
|
|
63
75
|
description: string | null
|
|
64
76
|
/** package.json `version` */
|
|
@@ -338,6 +350,7 @@ export function projectGutter(d: GutterDeps) {
|
|
|
338
350
|
sessions: counts?.get(r.path) ?? null,
|
|
339
351
|
asks: asks.length,
|
|
340
352
|
askTitles: asks,
|
|
353
|
+
cards: cardsOf(r.path),
|
|
341
354
|
description,
|
|
342
355
|
version: versionOf(pkg),
|
|
343
356
|
branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
|
|
@@ -386,7 +399,11 @@ export function projectGutter(d: GutterDeps) {
|
|
|
386
399
|
|
|
387
400
|
/** null when the path is not this route's */
|
|
388
401
|
async function handle(req: Request, path: string): Promise<Response | null> {
|
|
389
|
-
if (path === '/api/projects.json')
|
|
402
|
+
if (path === '/api/projects.json') {
|
|
403
|
+
// the measure holds 5 s, the cards never: a verdict he just gave shows on the next ask
|
|
404
|
+
const a = await answer()
|
|
405
|
+
return Response.json({ ...a, projects: a.projects.map((p) => ({ ...p, cards: cardsOf(p.path) })) })
|
|
406
|
+
}
|
|
390
407
|
if (path.startsWith('/api/projects/icon/')) {
|
|
391
408
|
if (repos.length === 0) await answer()
|
|
392
409
|
const r = findAny(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
|
package/src/shot/clip-encoder.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// `CLIP_INIT` run IN the page, so they import nothing and read only their
|
|
9
9
|
// argument and the document. A WebGL canvas keeps no drawing buffer after it
|
|
10
10
|
// presents, so the clip's page is opened with CLIP_INIT, which asks every
|
|
11
|
-
// WebGL context for `preserveDrawingBuffer
|
|
11
|
+
// WebGL context for `preserveDrawingBuffer`; a WebGPU canvas read on a frame
|
|
12
|
+
// it did not draw gives an older drawing, so it is read only after a draw.
|
|
12
13
|
|
|
13
14
|
/** what the page films: seconds of its largest canvas at fps, H.264 at a fixed quantizer */
|
|
14
15
|
export type PageClipAsk = { seconds: number; fps: number; quantizer: number; keyEvery: number }
|
|
@@ -16,13 +17,22 @@ export type PageClipAsk = { seconds: number; fps: number; quantizer: number; key
|
|
|
16
17
|
/** what the page says once the film ran */
|
|
17
18
|
export type PageClipDone = { frames: number; repeated: number; width: number; height: number; canvasOfViewport: string }
|
|
18
19
|
|
|
19
|
-
/** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents
|
|
20
|
+
/** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents;
|
|
21
|
+
* a WebGPU canvas counts its drawings (window.__skDraws): read on a frame it did not draw, it gives an older drawing */
|
|
20
22
|
export const CLIP_INIT = `(() => {
|
|
21
23
|
const get = HTMLCanvasElement.prototype.getContext
|
|
22
24
|
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
|
|
23
25
|
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true })
|
|
26
|
+
if (type === 'webgpu') window.__skDraws ??= 0
|
|
24
27
|
return get.call(this, type, attrs)
|
|
25
28
|
}
|
|
29
|
+
if (typeof GPUCanvasContext !== 'undefined') {
|
|
30
|
+
const current = GPUCanvasContext.prototype.getCurrentTexture
|
|
31
|
+
GPUCanvasContext.prototype.getCurrentTexture = function () {
|
|
32
|
+
window.__skDraws = (window.__skDraws ?? 0) + 1
|
|
33
|
+
return current.call(this)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
26
36
|
})()`
|
|
27
37
|
|
|
28
38
|
type ClipState = {
|
|
@@ -70,25 +80,37 @@ export async function installClip(a: PageClipAsk): Promise<string | null> {
|
|
|
70
80
|
const total = Math.round(a.seconds * a.fps)
|
|
71
81
|
const step = 1000 / a.fps
|
|
72
82
|
const rect = { x: 0, y: 0, width, height }
|
|
83
|
+
// a WebGPU canvas is read only on a frame it drew (CLIP_INIT counts them), else its last drawing is filmed again
|
|
84
|
+
const gpu = w as unknown as { __skDraws?: number }
|
|
73
85
|
st.done = new Promise<PageClipDone>((ok, fail) => {
|
|
74
86
|
let n = 0
|
|
75
87
|
let repeated = 0
|
|
76
88
|
let t0 = -1
|
|
89
|
+
let last: VideoFrame | null = null
|
|
90
|
+
let seen = -1
|
|
77
91
|
const tick = (t: number): void => {
|
|
78
92
|
try {
|
|
79
93
|
if (st.failed) throw new Error(st.failed)
|
|
80
94
|
if (t0 < 0) t0 = t
|
|
95
|
+
const draws = gpu.__skDraws
|
|
96
|
+
const fresh = last == null || draws === undefined || draws !== seen
|
|
97
|
+
if (fresh) {
|
|
98
|
+
last?.close()
|
|
99
|
+
last = new VideoFrame(canvas, { timestamp: 0, visibleRect: rect })
|
|
100
|
+
seen = draws ?? -1
|
|
101
|
+
}
|
|
81
102
|
// a frame for every step of real time passed: a slow page repeats its last drawing, so the clip keeps its length
|
|
82
103
|
let made = 0
|
|
83
104
|
while (n < total && n * step <= t - t0) {
|
|
84
|
-
const frame = new VideoFrame(
|
|
105
|
+
const frame = new VideoFrame(last!, { timestamp: Math.round(n * step * 1000), duration: Math.round(step * 1000) })
|
|
85
106
|
enc.encode(frame, { keyFrame: n % a.keyEvery === 0, avc: { quantizer: a.quantizer } } as VideoEncoderEncodeOptions)
|
|
86
107
|
frame.close()
|
|
87
|
-
if (made++ > 0) repeated++
|
|
108
|
+
if (made++ > 0 || !fresh) repeated++
|
|
88
109
|
n++
|
|
89
110
|
}
|
|
90
111
|
if (n < total) requestAnimationFrame(tick)
|
|
91
|
-
else
|
|
112
|
+
else {
|
|
113
|
+
last?.close()
|
|
92
114
|
void enc
|
|
93
115
|
.flush()
|
|
94
116
|
.then(() => {
|
|
@@ -97,7 +119,9 @@ export async function installClip(a: PageClipAsk): Promise<string | null> {
|
|
|
97
119
|
ok({ frames: n, repeated, width, height, canvasOfViewport })
|
|
98
120
|
})
|
|
99
121
|
.catch(fail)
|
|
122
|
+
}
|
|
100
123
|
} catch (e) {
|
|
124
|
+
last?.close()
|
|
101
125
|
if (enc.state !== 'closed') enc.close()
|
|
102
126
|
fail(e)
|
|
103
127
|
}
|
|
@@ -253,15 +253,21 @@ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, t
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
/** a noise buffer looped at a moving playback rate, added into `buf
|
|
257
|
-
|
|
256
|
+
/** a noise buffer looped at a moving playback rate, added into `buf`. A browser buffer source's playbackRate is k-rate:
|
|
257
|
+
* held for each 128 frame block at the block's start. In the block the note starts inside (`lead` frames in) it is the
|
|
258
|
+
* default 1; the note's events take hold at the next block, so its sweep starts there and still ends at its own time */
|
|
259
|
+
function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch, lead: number): void {
|
|
260
|
+
const first = (QUANTUM - lead) % QUANTUM
|
|
261
|
+
const sweep: Pitch = { from: p.from, to: p.to, over: p.over - first / rate }
|
|
258
262
|
let pos = 0
|
|
263
|
+
let inc = 1
|
|
259
264
|
const len = src.length
|
|
260
265
|
for (let i = 0; i < n; i++) {
|
|
266
|
+
if (i >= first && (i - first) % QUANTUM === 0) inc = pitchAt(sweep, t0 + (i - first) / rate)
|
|
261
267
|
const j = pos | 0
|
|
262
268
|
const v = src[j % len]!
|
|
263
269
|
buf[i] = buf[i]! + v + (src[(j + 1) % len]! - v) * (pos - j)
|
|
264
|
-
pos +=
|
|
270
|
+
pos += inc
|
|
265
271
|
if (pos >= len) pos -= len
|
|
266
272
|
}
|
|
267
273
|
}
|
|
@@ -285,18 +291,20 @@ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNo
|
|
|
285
291
|
if (peak <= 0) return
|
|
286
292
|
const pts = envPoints(inst.env, note.dur, peak)
|
|
287
293
|
const length = (pts[pts.length - 1]?.[0] ?? note.dur) + 0.02
|
|
288
|
-
|
|
289
|
-
const
|
|
294
|
+
// on its sample frame, as the game rounds a note's time: a hair past one (a sum's float noise) is that frame
|
|
295
|
+
const start = Math.round(note.time * rate)
|
|
296
|
+
const i0 = Math.max(0, start)
|
|
297
|
+
const i1 = Math.min(bus.l.length, Math.ceil((start / rate + length) * rate))
|
|
290
298
|
const n = i1 - i0
|
|
291
299
|
if (n <= 0) return
|
|
292
300
|
if (scratch.buf.length < n) scratch.buf = new Float32Array(n)
|
|
293
301
|
const buf = scratch.buf
|
|
294
302
|
buf.fill(0, 0, n)
|
|
295
|
-
const t0 = i0 / rate
|
|
303
|
+
const t0 = (i0 - start) / rate
|
|
296
304
|
if (inst.osc === 'noise') {
|
|
297
305
|
const r0 = Math.max(0.05, Math.min(8, hz / NOISE_REF_HZ))
|
|
298
306
|
const pitch: Pitch = inst.sweep ? { from: r0, to: Math.max(0.02, r0 * 2 ** (inst.sweep.semis / 12)), over: Math.max(1e-4, inst.sweep.time) } : { from: r0, to: r0, over: 0 }
|
|
299
|
-
noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch)
|
|
307
|
+
noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch, i0 % QUANTUM)
|
|
300
308
|
} else {
|
|
301
309
|
const voices = Math.max(1, Math.min(4, inst.unison ?? (inst.detune ? 2 : 1)))
|
|
302
310
|
const spread = inst.detune ?? 0
|
|
@@ -12,6 +12,7 @@ export function comfyModule(comfy: string | undefined, name: string): string {
|
|
|
12
12
|
const COMFY_TIMEOUT_S = 15 * 60
|
|
13
13
|
|
|
14
14
|
export async function comfyRun(module: string, vars: Record<string, string | number | boolean | undefined>, out: string): Promise<void> {
|
|
15
|
+
if (Bun.which('comfy-ts') == null) throw new Error(`comfy-ts is not on PATH, and ${module} is a comfy-ts module. Install it: bun add -g comfy-ts`)
|
|
15
16
|
const args = ['comfy-ts', 'run', module]
|
|
16
17
|
for (const [k, v] of Object.entries(vars)) if (v !== undefined) args.push(`--${k}`, String(v))
|
|
17
18
|
args.push('--out', out, '--json')
|