kaizen-agent 1.32.0 → 1.33.1

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.
@@ -0,0 +1,108 @@
1
+ import { load, post } from "./api.ts";
2
+ import { key } from "./cards.ts";
3
+ import { mic, stopDictation } from "./dictation.ts";
4
+ import { $, esc } from "./dom.ts";
5
+ import { DK, draft } from "./drafts.ts";
6
+ import { attach, brief, md } from "./markdown.ts";
7
+ import { toast } from "./toast.ts";
8
+
9
+ // Notes are a chat: one entry per message, files attached by paste or picker.
10
+ function parseNotes(src) {
11
+ const out = [];
12
+ for (const raw of (src || "").split("\n")) {
13
+ const m = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);
14
+ if (m) {
15
+ out.push({ at: m[1], text: m[2] });
16
+ continue;
17
+ }
18
+ const last = out[out.length - 1];
19
+ if (last) last.text += "\n" + raw.replace(/^\s{2}/, "");
20
+ else if (raw.trim()) out.push({ at: "", text: raw });
21
+ }
22
+ return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
23
+ }
24
+ let nfocus = null;
25
+ export function chat(c, src, ro) {
26
+ const fix = (t) => attach(t, c);
27
+ const entries = parseNotes(src);
28
+ const b = c.kind === "idea" ? brief(c.text) : { parts: [] };
29
+ let h =
30
+ (b.parts.length
31
+ ? `<section class="desc">${md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n"))}</section>`
32
+ : "") +
33
+ `<div class="chat">${entries.map((n) => `<div class="msg">${n.at ? `<time>${esc(n.at)}</time>` : ""}${md(fix(n.text))}</div>`).join("") || `<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
34
+ if (!ro)
35
+ h += `<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
36
+ $("pbody").innerHTML = h;
37
+ if (ro) return;
38
+ const ta = $("pbody").querySelector("textarea"),
39
+ files = [];
40
+ draft(ta, "n:" + key(c));
41
+ // Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
42
+ // so the note shows, and the new box takes focus back for the next note.
43
+ if (nfocus === key(c)) {
44
+ nfocus = null;
45
+ ta.focus();
46
+ }
47
+ {
48
+ const m = mic([ta]);
49
+ if (m) $("npick").before(m);
50
+ }
51
+ const show = () => {
52
+ $("natt").textContent = files.map((f) => f.name).join(" · ");
53
+ };
54
+ const add = (f) => {
55
+ files.push(f);
56
+ show();
57
+ };
58
+ $("npick").onclick = () => $("nfile").click();
59
+ $("nfile").onchange = (e) => {
60
+ [...e.target.files].forEach(add);
61
+ e.target.value = "";
62
+ };
63
+ ta.onpaste = (e) => {
64
+ for (const it of e.clipboardData.items)
65
+ if (it.kind === "file") {
66
+ const f = it.getAsFile();
67
+ add(new File([f], f.name === "image.png" ? `paste-${Date.now()}.png` : f.name, { type: f.type }));
68
+ e.preventDefault();
69
+ }
70
+ };
71
+ const send = async () => {
72
+ stopDictation(true);
73
+ const text = ta.value.trim();
74
+ if (!text && !files.length) return;
75
+ const enc = await Promise.all(
76
+ files.map(
77
+ (f) =>
78
+ new Promise((r) => {
79
+ const rd = new FileReader();
80
+ rd.onload = () => r({ name: f.name, data: rd.result });
81
+ rd.readAsDataURL(f);
82
+ }),
83
+ ),
84
+ );
85
+ const body =
86
+ c.kind === "run" ? { dir: c.state, id: c.id, text, files: enc } : { dir: c.state, for: c.text, text, files: enc };
87
+ const r = await post("/note", body);
88
+ if (r.ok) {
89
+ files.length = 0;
90
+ ta.value = "";
91
+ try {
92
+ sessionStorage.removeItem(DK + "n:" + key(c));
93
+ } catch (e) {}
94
+ toast("Noted");
95
+ nfocus = key(c);
96
+ ta.blur();
97
+ load();
98
+ }
99
+ };
100
+ $("nsend").onclick = send;
101
+ ta.onkeydown = (e) => {
102
+ if (e.key === "Enter" && !e.shiftKey) {
103
+ e.preventDefault();
104
+ send();
105
+ }
106
+ };
107
+ $("pbody").scrollTop = 0;
108
+ }
@@ -0,0 +1,300 @@
1
+ import { ico } from "./dom.ts";
2
+ import { toast } from "./toast.ts";
3
+
4
+ // ---- dictation: Whisper in the page. Audio never leaves the browser; the model is
5
+ // fetched once from huggingface.co and cached. Library and model are pinned here.
6
+ const TFJS = "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0",
7
+ ASR_MODELS = {
8
+ tiny: "onnx-community/whisper-tiny",
9
+ base: "onnx-community/whisper-base",
10
+ small: "onnx-community/whisper-small",
11
+ "large-v3-turbo": "onnx-community/whisper-large-v3-turbo",
12
+ };
13
+ // The model is a setting (dictation.model); the worker is rebuilt when it changes.
14
+ let asrModel = null;
15
+ // Inference lives in a module Worker so a slow chunk never freezes the board.
16
+ const WORKER_SRC = `import{pipeline}from"${TFJS}";let asr=null;
17
+ onmessage=async e=>{const m=e.data;try{
18
+ if(m.type==="load"){const prog=p=>{if(p.status==="progress")postMessage({type:"progress",file:p.file,pct:p.progress||0})};
19
+ // WebGPU first, with the encoder in fp16 only where the adapter has shader-f16; any GPU failure falls back to the CPU.
20
+ const gpu=m.webgpu?{device:"webgpu",dtype:{encoder_model:m.f16?"fp16":"fp32",decoder_model_merged:"q4"}}:null,cpu={device:"wasm",dtype:"q8"};
21
+ try{if(!gpu)throw 0;asr=await pipeline("automatic-speech-recognition",m.model,{...gpu,progress_callback:prog})}
22
+ catch(e){if(gpu)postMessage({type:"fallback",why:String(e&&e.message||e)});asr=await pipeline("automatic-speech-recognition",m.model,{...cpu,progress_callback:prog})}
23
+ postMessage({type:"ready"})}
24
+ if(m.type==="run"){const r=await asr(m.audio,{chunk_length_s:30,return_timestamps:false});postMessage({type:"text",sid:m.sid,seq:m.seq,text:(r.text||"").trim()})}
25
+ }catch(err){postMessage({type:"error",sid:m.sid,seq:m.seq,error:String(err&&err.message||err)})}}`;
26
+ // The worklet only forwards 128-frame blocks; chunking and silence detection stay on the main thread.
27
+ const WORKLET_SRC = `class T extends AudioWorkletProcessor{process(i){const c=i[0]&&i[0][0];if(c)this.port.postMessage(c.slice(0));return true}}registerProcessor("kz-tap",T)`;
28
+ const HAS_MIC = !!(
29
+ navigator.mediaDevices &&
30
+ navigator.mediaDevices.getUserMedia &&
31
+ window.AudioWorkletNode &&
32
+ window.WebAssembly &&
33
+ window.Worker
34
+ );
35
+ // `active` is the one session listening; `sessions` also holds stopped ones still waiting on a chunk.
36
+ let asrWorker = null,
37
+ asrReady = null,
38
+ asrProgress = () => {},
39
+ active = null,
40
+ starting = false;
41
+ const sessions = new Set();
42
+ // One worker for the page; loading is awaited by whoever pressed first, later presses join.
43
+ // Loads the model named in settings; a model changed since the last load replaces the worker.
44
+ async function loadAsr() {
45
+ let want = "small";
46
+ try {
47
+ const cfg = await fetch("/settings").then((r) => r.json());
48
+ want = (cfg.rows.find((r) => r.key === "dictation.model") || {}).value || want;
49
+ } catch (e) {}
50
+ if (!ASR_MODELS[want]) want = "small";
51
+ if (asrReady && asrModel !== want) {
52
+ try {
53
+ asrWorker.terminate();
54
+ } catch (e) {}
55
+ asrWorker = null;
56
+ asrReady = null;
57
+ sessions.clear();
58
+ active = null;
59
+ }
60
+ if (asrReady) return asrReady;
61
+ asrModel = want;
62
+ asrReady = new Promise(async (res, rej) => {
63
+ asrWorker = new Worker(URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" })), {
64
+ type: "module",
65
+ });
66
+ const files = {};
67
+ asrWorker.onmessage = (e) => {
68
+ const m = e.data;
69
+ if (m.type === "progress") {
70
+ files[m.file] = m.pct;
71
+ const v = Object.values(files);
72
+ asrProgress(Math.round(v.reduce((a, b) => a + b, 0) / v.length));
73
+ } else if (m.type === "ready") res();
74
+ else if (m.type === "fallback") toast("GPU could not run the model, using the CPU: " + m.why);
75
+ else if (m.type === "error" && m.seq == null) rej(new Error(m.error));
76
+ for (const s of sessions) s.onmsg(m);
77
+ };
78
+ asrWorker.onerror = (e) => rej(new Error(e.message || "worker failed"));
79
+ // Ask the adapter what it can do: no adapter means the CPU path; no shader-f16 means an fp32 encoder.
80
+ let adapter = null;
81
+ try {
82
+ adapter = navigator.gpu && (await navigator.gpu.requestAdapter());
83
+ } catch (e) {}
84
+ if (!adapter) toast("No WebGPU: dictation will run on the CPU and be slower");
85
+ asrWorker.postMessage({
86
+ type: "load",
87
+ model: ASR_MODELS[asrModel],
88
+ webgpu: !!adapter,
89
+ f16: !!(adapter && adapter.features.has("shader-f16")),
90
+ });
91
+ });
92
+ asrReady.catch(() => {
93
+ asrReady = null;
94
+ try {
95
+ asrWorker.terminate();
96
+ } catch (e) {}
97
+ asrWorker = null;
98
+ });
99
+ return asrReady;
100
+ }
101
+ // After a re-render only a listener whose field is gone is stopped; closing the panel stops any.
102
+ export function stopDictation(all) {
103
+ if (active && (all || !active.field.isConnected)) active.stop();
104
+ }
105
+ // mic(fields): a button that dictates into the last-focused of `fields` at its caret.
106
+ export function mic(fields) {
107
+ if (!HAS_MIC) return null;
108
+ const b = document.createElement("button");
109
+ b.className = "ib sm mic";
110
+ b.title = "Dictate";
111
+ b.setAttribute("aria-label", "Dictate");
112
+ b.type = "button";
113
+ const idle = () => {
114
+ b.innerHTML = ico("mic");
115
+ b.classList.remove("on", "busy");
116
+ };
117
+ idle();
118
+ let target = fields[0];
119
+ fields.forEach((f) => f.addEventListener("focus", () => (target = f)));
120
+ b.onclick = async (e) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ if (active) {
124
+ const mine = active.btn === b;
125
+ active.stop();
126
+ if (mine) return;
127
+ }
128
+ // One press at a time: the model load and the permission prompt are both awaits a second press could slip through.
129
+ if (starting) return;
130
+ starting = true;
131
+ b.classList.add("busy");
132
+ asrProgress = (pct) => {
133
+ b.innerHTML = `<i>${pct}%</i>`;
134
+ };
135
+ try {
136
+ await loadAsr();
137
+ } catch (err) {
138
+ idle();
139
+ asrProgress = () => {};
140
+ starting = false;
141
+ toast("Dictation model could not load: " + err.message);
142
+ return;
143
+ }
144
+ asrProgress = () => {};
145
+ b.innerHTML = ico("mic");
146
+ if (!b.isConnected) {
147
+ idle();
148
+ starting = false;
149
+ return;
150
+ }
151
+ let stream;
152
+ try {
153
+ stream = await navigator.mediaDevices.getUserMedia({
154
+ audio: { channelCount: 1, noiseSuppression: true, echoCancellation: true },
155
+ });
156
+ } catch (err) {
157
+ idle();
158
+ starting = false;
159
+ toast("Microphone not available: " + (err.name === "NotAllowedError" ? "permission denied" : err.message));
160
+ return;
161
+ }
162
+ if (active) active.stop();
163
+ const ctx = new AudioContext({ sampleRate: 16000 });
164
+ await ctx.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET_SRC], { type: "text/javascript" })));
165
+ const src = ctx.createMediaStreamSource(stream),
166
+ tap = new AudioWorkletNode(ctx, "kz-tap");
167
+ src.connect(tap);
168
+ // Text lands where the caret is when each chunk arrives (or after the last insertion when the
169
+ // field is not focused), so typing while the mic is on is kept.
170
+ const f = target;
171
+ let at = f.selectionStart ?? f.value.length;
172
+ const vu = document.createElement("span");
173
+ vu.className = "vu";
174
+ vu.setAttribute("aria-hidden", "true");
175
+ vu.innerHTML = [0.5, 0.9, 1.3, 0.8, 0.45].map((w) => `<i style="--w:${w}"></i>`).join("");
176
+ let buf = [],
177
+ len = 0,
178
+ voiced = 0,
179
+ quiet = 0,
180
+ seq = 0,
181
+ done = 0,
182
+ pending = new Map(),
183
+ stopping = false;
184
+ const SR = 16000,
185
+ MAX = 6 * SR,
186
+ SIL = 0.8 * SR,
187
+ MIN = 1 * SR;
188
+ const insert = (t) => {
189
+ if (!t || !f.isConnected) return;
190
+ const v = f.value;
191
+ if (document.activeElement === f) at = f.selectionStart;
192
+ at = Math.min(at, v.length);
193
+ const head = v.slice(0, at),
194
+ sep = head && !/\s$/.test(head) ? " " : "";
195
+ f.value = head + sep + t + v.slice(at);
196
+ at = head.length + sep.length + t.length;
197
+ f.selectionStart = f.selectionEnd = at;
198
+ f.dispatchEvent(new Event("input", { bubbles: true }));
199
+ };
200
+ const flush = () => {
201
+ if (!len || voiced < MIN) {
202
+ buf = [];
203
+ len = 0;
204
+ voiced = 0;
205
+ quiet = 0;
206
+ return;
207
+ }
208
+ const a = new Float32Array(len);
209
+ let o = 0;
210
+ for (const c of buf) {
211
+ a.set(c, o);
212
+ o += c.length;
213
+ }
214
+ buf = [];
215
+ len = 0;
216
+ voiced = 0;
217
+ quiet = 0;
218
+ pending.set(seq, null);
219
+ asrWorker.postMessage({ type: "run", sid, seq, audio: a }, [a.buffer]);
220
+ seq++;
221
+ b.classList.add("busy");
222
+ };
223
+ const drain = () => {
224
+ while (pending.has(done) && pending.get(done) !== null) {
225
+ insert(pending.get(done));
226
+ pending.delete(done);
227
+ done++;
228
+ }
229
+ if (!pending.size) {
230
+ b.classList.remove("busy");
231
+ if (stopping) {
232
+ idle();
233
+ sessions.delete(sess);
234
+ }
235
+ }
236
+ };
237
+ tap.port.onmessage = (e) => {
238
+ const c = e.data;
239
+ buf.push(c);
240
+ len += c.length;
241
+ let s = 0;
242
+ for (let i = 0; i < c.length; i++) s += c[i] * c[i];
243
+ const rms = Math.sqrt(s / c.length);
244
+ if (rms > 0.01) {
245
+ voiced += c.length;
246
+ quiet = 0;
247
+ } else quiet += c.length;
248
+ {
249
+ const l = Math.min(1, rms * 8).toFixed(2);
250
+ f.style.setProperty("--lvl", l);
251
+ vu.style.setProperty("--lvl", l);
252
+ }
253
+ if (len >= MAX || (voiced >= MIN && quiet >= SIL)) flush();
254
+ };
255
+ // Chunk seqs are per session, so a result is matched by session id too.
256
+ const sid = Math.random().toString(36).slice(2);
257
+ const sess = {
258
+ btn: b,
259
+ field: f,
260
+ onmsg: (m) => {
261
+ if (m.sid !== sid) return;
262
+ if (m.type === "text") {
263
+ pending.set(m.seq, m.text);
264
+ drain();
265
+ } else if (m.type === "error" && m.seq != null) {
266
+ pending.set(m.seq, "");
267
+ toast("Dictation failed: " + m.error);
268
+ drain();
269
+ }
270
+ },
271
+ stop: () => {
272
+ if (stopping) return;
273
+ stopping = true;
274
+ b.classList.remove("on");
275
+ f.classList.remove("dictating");
276
+ f.style.removeProperty("--lvl");
277
+ b.innerHTML = ico("mic");
278
+ try {
279
+ tap.port.onmessage = null;
280
+ src.disconnect();
281
+ tap.disconnect();
282
+ stream.getTracks().forEach((t) => t.stop());
283
+ ctx.close();
284
+ } catch (e) {}
285
+ active = null;
286
+ flush();
287
+ drain();
288
+ },
289
+ };
290
+ sessions.add(sess);
291
+ active = sess;
292
+ starting = false;
293
+ b.classList.remove("busy");
294
+ b.classList.add("on");
295
+ f.classList.add("dictating");
296
+ b.replaceChildren(vu);
297
+ f.focus();
298
+ };
299
+ return b;
300
+ }
@@ -0,0 +1,5 @@
1
+ // Small DOM helpers every part of the board uses.
2
+ export const $ = <T extends HTMLElement = any>(id: string) => document.getElementById(id) as T;
3
+ export const esc = (s) =>
4
+ String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
5
+ export const ico = (n) => `<svg><use href="#i-${n}"/></svg>`;
@@ -0,0 +1,21 @@
1
+ // Half-typed text outlives a redraw and a page reload. Per tab, so two tabs cannot
2
+ // overwrite each other; form drafts are under "f:", notes under "n:".
3
+ export const DK = "kz-d:";
4
+ export function draft(el, k) {
5
+ try {
6
+ const v = sessionStorage.getItem(DK + k);
7
+ if (v != null) el.value = v;
8
+ } catch (e) {}
9
+ el.addEventListener("input", () => {
10
+ try {
11
+ sessionStorage.setItem(DK + k, el.value);
12
+ } catch (e) {}
13
+ });
14
+ }
15
+ export function undraft(p) {
16
+ try {
17
+ Object.keys(sessionStorage).forEach((k) => {
18
+ if (k.startsWith(DK + p)) sessionStorage.removeItem(k);
19
+ });
20
+ } catch (e) {}
21
+ }
@@ -0,0 +1,90 @@
1
+ import { store } from "./store.ts";
2
+ import { where } from "./agent-term.ts";
3
+ import { post } from "./api.ts";
4
+ import { key, via } from "./cards.ts";
5
+ import { $ } from "./dom.ts";
6
+ import { md } from "./markdown.ts";
7
+ import { openRun } from "./panel.ts";
8
+ import { askNotify, toast } from "./toast.ts";
9
+
10
+ // Fix picked findings, under the review: open ones ticked, closed and sent ones ticked but
11
+ // locked. Unticked and sent findings live outside the form, which every refresh rebuilds,
12
+ // keyed "run:number"; sent ones also survive a reload of this tab. A finding is one line,
13
+ // so md() gives one paragraph; its wrapper is dropped to sit inline in the label.
14
+ export const SK = "kz-sent",
15
+ fixOff = new Set(),
16
+ fixSent = new Set(
17
+ (() => {
18
+ try {
19
+ return JSON.parse(sessionStorage.getItem(SK)) || [];
20
+ } catch (e) {
21
+ return [];
22
+ }
23
+ })(),
24
+ );
25
+ export function fixForm(c) {
26
+ const k = (n) => c.id + ":" + n,
27
+ sent = (f) => fixSent.has(k(f.n)),
28
+ fs = store.runDoc.findings || [],
29
+ open = fs.filter((f) => !f.done && !sent(f));
30
+ if (
31
+ c.archived ||
32
+ !fs.some((f) => !f.done) ||
33
+ !(c.status === "done" || c.awaiting === "findings" || c.awaiting === "approvals.review")
34
+ )
35
+ return;
36
+ const el = document.createElement("div");
37
+ el.className = "inl fix";
38
+ el.innerHTML =
39
+ `<div class="q">Fix findings</div><div class="q dim">Opens your agent in this project, told to fix the ticked findings and recheck them.</div>${c.status === "waiting" ? `<div class="q dim">A session is already waiting on this run in a terminal; this opens another one.</div>` : ""}` +
40
+ fs
41
+ .map((f) => {
42
+ const x = f.done ? " (closed)" : sent(f) ? " (sent)" : "";
43
+ return `<label class="${x ? "off" : ""}"><input type="checkbox" data-n="${f.n}" ${x ? "checked disabled" : fixOff.has(k(f.n)) ? "" : "checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g, "")}${x}</span></label>`;
44
+ })
45
+ .join("") +
46
+ (open.length
47
+ ? `<div class="row"><button class="btn ghost" data-a="all"></button><button class="btn pri" data-a="s"></button></div>`
48
+ : "");
49
+ if (!open.length) return $("pbody").appendChild(el);
50
+ const go = el.querySelector("[data-a=s]"),
51
+ allb = el.querySelector("[data-a=all]"),
52
+ picks = () => open.filter((f) => !fixOff.has(k(f.n))).map((f) => f.n);
53
+ const sync = () => {
54
+ const n = picks().length;
55
+ go.textContent = n === open.length ? "Fix all " + n : "Fix " + n + " selected";
56
+ go.disabled = !n;
57
+ allb.textContent = n === open.length ? "Select none" : "Select all";
58
+ el.querySelectorAll("input:not(:disabled)").forEach((i) => (i.checked = !fixOff.has(k(i.dataset.n))));
59
+ };
60
+ el.onchange = (e) => {
61
+ const n = k(e.target.dataset.n);
62
+ e.target.checked ? fixOff.delete(n) : fixOff.add(n);
63
+ sync();
64
+ };
65
+ allb.onclick = () => {
66
+ const none = picks().length === open.length;
67
+ open.forEach((f) => (none ? fixOff.add(k(f.n)) : fixOff.delete(k(f.n))));
68
+ sync();
69
+ };
70
+ go.onclick = async () => {
71
+ const nums = picks();
72
+ go.disabled = true;
73
+ askNotify();
74
+ const r = await post("/fix", { dir: c.state, id: c.id, nums });
75
+ // Sent findings lock as sent, so a second press cannot open a second agent on them.
76
+ if (r.ok) {
77
+ toast(
78
+ "Fixing " + nums.length + " finding" + (nums.length === 1 ? "" : "s") + ", " + where(r) + ": " + via(r.agent),
79
+ );
80
+ if (r.pty) store.wantTerm = key(c);
81
+ nums.forEach((n) => fixSent.add(k(n)));
82
+ try {
83
+ sessionStorage.setItem(SK, JSON.stringify([...fixSent]));
84
+ } catch (e) {}
85
+ openRun(c, true);
86
+ } else sync();
87
+ };
88
+ sync();
89
+ $("pbody").appendChild(el);
90
+ }
@@ -0,0 +1,76 @@
1
+ import { store } from "./store.ts";
2
+ import { post } from "./api.ts";
3
+ import { key } from "./cards.ts";
4
+ import { $, esc } from "./dom.ts";
5
+ import { commitMsg } from "./markdown.ts";
6
+ import { openRun } from "./panel.ts";
7
+ import { toast } from "./toast.ts";
8
+
9
+ // Commit and push, for a finished run in a project with a git remote. What the board
10
+ // did is recorded per run, so the settled label outlives later changes to the tree.
11
+ export function gitBtn(c) {
12
+ const g = store.runDoc.git,
13
+ k = store.runDoc.commit;
14
+ if (c.archived || !g || !g.repo || !(c.status === "done" || c.awaiting === "approvals.review")) return "";
15
+ // A run already pushed keeps saying so; work changed since is offered beside it.
16
+ if (k && k.pushed)
17
+ return (
18
+ `<button class="btn" disabled title="${esc(k.sha)}">Committed &amp; pushed</button>` +
19
+ (g.files.length
20
+ ? `<button class="btn" data-a="commit">Commit new changes</button>`
21
+ : g.ahead > 0
22
+ ? `<button class="btn" data-a="push">Push</button>`
23
+ : "")
24
+ );
25
+ if (g.files.length) return `<button class="btn pri" data-a="commit">Commit &amp; push</button>`;
26
+ if (g.ahead > 0) return `<button class="btn pri" data-a="push">Push</button>`;
27
+ // None of its files changed and level with the remote: it was committed and pushed
28
+ // somewhere else. With no record and no report, nothing says it was ever committed.
29
+ return k || store.runDoc.impl ? `<button class="btn" disabled>Committed &amp; pushed</button>` : "";
30
+ }
31
+ export function confirmCommit(c, msg) {
32
+ const g = store.runDoc.git,
33
+ foot = $("pfoot");
34
+ foot.innerHTML = `<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${esc(g.branch || "this branch")} and push to ${esc(g.remote)}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${esc(g.files.join("\n"))}</pre>${g.shared && g.shared.length ? `<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${esc(g.shared.join("\n"))}</pre>` : ""}<textarea aria-label="Commit message" placeholder="feat(scope): what changed">${esc(msg || commitMsg(store.runDoc.impl) || c.title || c.text)}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Commit &amp; push</button></div></div>`;
35
+ const inp = foot.querySelector("textarea"),
36
+ go = foot.querySelector("[data-a=s]");
37
+ inp.focus();
38
+ // Tall enough to show the whole message, up to half the window.
39
+ const fit = () => {
40
+ inp.style.height = "auto";
41
+ inp.style.height = Math.min(inp.scrollHeight + 2, innerHeight / 2) + "px";
42
+ $("clong").hidden = inp.value.split("\n")[0].length <= 72;
43
+ };
44
+ inp.oninput = fit;
45
+ fit();
46
+ foot.querySelector("[data-a=c]").onclick = () => {
47
+ foot.innerHTML = "";
48
+ openRun(c, true);
49
+ };
50
+ go.onclick = () => {
51
+ if (!inp.value.trim()) {
52
+ toast("A commit message is required");
53
+ return;
54
+ }
55
+ commitPush(c, inp.value, go);
56
+ };
57
+ inp.onkeydown = (e) => {
58
+ if (e.key === "Enter" && !e.shiftKey) {
59
+ e.preventDefault();
60
+ go.click();
61
+ }
62
+ };
63
+ }
64
+ export async function commitPush(c, message, b) {
65
+ b.disabled = true;
66
+ b.dataset.busy = 1;
67
+ b.textContent = "Pushing…";
68
+ const r = await post("/commit", { dir: c.state, id: c.id, message, files: store.runDoc.git.files });
69
+ if (r.ok) toast("Committed & pushed " + r.sha);
70
+ $("pfoot").innerHTML = "";
71
+ if (store.openKey !== key(c)) return;
72
+ await openRun(c, false);
73
+ // A refused message comes back for another try, beside the current file list.
74
+ if (!r.ok && message && store.openKey === key(c) && store.runDoc.git.files && store.runDoc.git.files.length)
75
+ confirmCommit(c, message);
76
+ }