kaizen-agent 1.34.2 → 1.34.4

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.
@@ -43,7 +43,7 @@ function card(c) {
43
43
  el.dataset.k = k;
44
44
  el.setAttribute("role", "button");
45
45
  const st = ICON[c.status] || "idea",
46
- idt = c.kind === "idea" ? "idea" : c.text;
46
+ idt = c.kind === "idea" ? "idea" : c.text.replace(/-/g, " ");
47
47
  // The status icon carries the state; what a waiting run needs is its tooltip.
48
48
  const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
49
49
  // Footer: words on the left (project, what it needs), icon-only marks on the right.
@@ -73,6 +73,7 @@ function card(c) {
73
73
  // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
74
74
  // the icebox); its chip names the source.
75
75
  const g = c.notes && /^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);
76
+ if (c.branch) chips.push(`<span class="chip" title="Built on its own branch, ${esc(c.branch)}">branch</span>`);
76
77
  if (g) chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);
77
78
  else if (c.notes) chips.push(`<span class="chip">notes</span>`);
78
79
  // Information only: the panel's button is where it is committed and pushed.
@@ -506,9 +507,10 @@ export function render() {
506
507
  if (ci === 0 && store.mode === "new") {
507
508
  const f = document.createElement("li");
508
509
  f.className = "inl";
509
- f.innerHTML = `${store.all ? `<div class="combo" id="ndc"><button class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add idea</button></div>`;
510
+ f.innerHTML = `${store.all ? `<div class="combo" id="ndc"><button class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="att" id="newatt"></div><input type="file" id="newfile" multiple hidden><div class="row"><button class="btn ghost" id="newpick">Attach</button><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add idea</button></div>`;
510
511
  ul.appendChild(f);
511
- const inp = f.querySelector("#newidea");
512
+ const inp = f.querySelector("#newidea"),
513
+ files = [];
512
514
  draft(inp, "f:new:0");
513
515
  draft(f.querySelector("textarea"), "f:new:1");
514
516
  const save = async () => {
@@ -518,9 +520,44 @@ export function render() {
518
520
  store.mode = null;
519
521
  const dir = store.all ? newDir : null;
520
522
  const r = await post("/inbox", dir ? { op: "add", dir, text, notes } : { op: "add", text, notes });
521
- if (r.ok) toast("Idea added");
523
+ if (r.ok) {
524
+ toast("Idea added");
525
+ // The idea exists first: a failed upload leaves it as plain text.
526
+ if (files.length) {
527
+ const enc = await Promise.all(
528
+ files.map(
529
+ (x) =>
530
+ new Promise((res) => {
531
+ const rd = new FileReader();
532
+ rd.onload = () => res({ name: x.name, data: rd.result });
533
+ rd.readAsDataURL(x);
534
+ }),
535
+ ),
536
+ );
537
+ const n = await post("/note", { dir, for: text, text: "", files: enc });
538
+ if (!n.ok) toast("Idea added, files not attached");
539
+ }
540
+ }
522
541
  load();
523
542
  };
543
+ const add = (x) => {
544
+ files.push(x);
545
+ f.querySelector("#newatt").textContent = files.map((y) => y.name).join(" · ");
546
+ };
547
+ f.querySelector("#newpick").onclick = () => f.querySelector("#newfile").click();
548
+ f.querySelector("#newfile").onchange = (e) => {
549
+ [...e.target.files].forEach(add);
550
+ e.target.value = "";
551
+ };
552
+ for (const el of [inp, f.querySelector("textarea")])
553
+ el.onpaste = (e) => {
554
+ for (const it of e.clipboardData.items)
555
+ if (it.kind === "file") {
556
+ const x = it.getAsFile();
557
+ add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
558
+ e.preventDefault();
559
+ }
560
+ };
524
561
  if (store.all) ideaCombo(f);
525
562
  f.querySelector("#ns").onclick = save;
526
563
  f.querySelector("#nc").onclick = () => {
@@ -162,7 +162,7 @@ export async function openSources() {
162
162
  : s.last.ok
163
163
  ? `<span class="src-badge ok"><svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`
164
164
  : `<span class="src-badge warn"><svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke="currentColor" fill="none" stroke-width="1.8"/><path d="M8 5v3.5M8 11v.1" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>Failed: ${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`;
165
- return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st === "social" ? esc(PLAT[s.social] || "social") : st}">${fav(s.url, st === "sheet" ? "sheet" : st === "doc" ? "doc" : st === "social" ? "at" : "link", s.social)}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts">${s.social ? `<button class="btn ghost sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn ? "Read this source with your ChatGPT plan" : "Open your agent to run /kaizen gather for this project"}">${ico("refresh")} Gather</button>` : `<button class="btn ghost sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check this source now">${ico("refresh")} Check</button>`}<button class="btn ghost danger sm" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-url"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(s.url)}</span>${ico("ext")}</a></div><div class="src-status">${statusHtml}</div><div class="src-dest"><span>Category</span><input class="sel" id="src-ct-${i}" data-ct="${i}" data-k="${esc(s.dir + " " + s.url)}" value="${esc(s.category || "")}" placeholder="e.g. Feedback" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></div><div class="src-dest"><span>Project</span><select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir + " " + s.url)}" aria-label="Project of ${esc(title)}">${opts(s.dir, s.project)}</select><button class="btn sm" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></div></div>`;
165
+ return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st === "social" ? esc(PLAT[s.social] || "social") : st}">${fav(s.url, st === "sheet" ? "sheet" : st === "doc" ? "doc" : st === "social" ? "at" : "link", s.social)}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts">${s.social ? `<button class="btn ghost sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn ? "Read this source with your ChatGPT plan" : "Open your agent to run /kaizen gather for this project"}">${ico("refresh")} Gather</button>` : `<button class="btn ghost sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check this source now">${ico("refresh")} Check</button>`}<button class="btn ghost danger sm" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-url"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(s.url)}</span>${ico("ext")}</a></div><div class="src-status">${statusHtml}</div><div class="src-dest"><span>Category</span><input class="sel" id="src-ct-${i}" data-ct="${i}" data-k="${esc(s.dir + " " + s.url)}" value="${esc(s.category || "")}" placeholder="e.g. Feedback" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></div>${st === "sheet" || /github\.com/.test(s.url) ? `<div class="src-dest"><span>Write back</span><label class="hint" style="margin:0"><input type="checkbox" id="src-wb-${i}" data-wb="${i}"${s.writeback ? " checked" : ""}> ${st === "sheet" ? "Put Done in the row's Status column" : "Comment and close the issue"} when its run is done</label></div>` : ""}<div class="src-dest"><span>Project</span><select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir + " " + s.url)}" aria-label="Project of ${esc(title)}">${opts(s.dir, s.project)}</select><button class="btn sm" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></div></div>`;
166
166
  })
167
167
  .join("");
168
168
  const fails = ((d && d.failed) || [])
@@ -188,7 +188,7 @@ export async function openSources() {
188
188
  srcKey = (it) => it.dir + " " + it.source;
189
189
  // Filters narrow in turn: project (the board's own picker, all projects only), then
190
190
  // category, then source; each chip counts what the filters before it leave.
191
- const catOf = (it) => (it.status === "dismissed" ? "Dismissed" : it.category || "Uncategorised"),
191
+ const catOf = (it) => (it.status === "dismissed" ? "Dismissed" : it.status === "done" ? "Done" : it.category || "Uncategorised"),
192
192
  count = (xs, f) => {
193
193
  const m = new Map();
194
194
  xs.forEach((it) => {
@@ -215,7 +215,7 @@ export async function openSources() {
215
215
  if (iceForm && !keys.has(iceForm)) iceForm = null;
216
216
  const groups = new Map();
217
217
  ice.forEach((it, i) => {
218
- if (!inProj(it) || (iceCat ? catOf(it) !== iceCat : it.status === "dismissed") || (iceSrc && srcKey(it) !== iceSrc)) return;
218
+ if (!inProj(it) || (iceCat ? catOf(it) !== iceCat : it.status === "dismissed" || it.status === "done") || (iceSrc && srcKey(it) !== iceSrc)) return;
219
219
  const k = catOf(it);
220
220
  groups.has(k) || groups.set(k, []);
221
221
  groups.get(k).push(i);
@@ -236,7 +236,8 @@ export async function openSources() {
236
236
  const pick = (id, label, xs) =>
237
237
  `<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
238
238
  const chips =
239
- (byCat.size > 1
239
+ // Dismissed and Done are hidden until picked, so even a lone one is a choice.
240
+ (byCat.size > 1 || byCat.has("Dismissed") || byCat.has("Done")
240
241
  ? pick("ice-fc", "Category", [
241
242
  opt("", !iceCat, "All categories"),
242
243
  ...[...byCat.keys()].map((k) => opt(k, iceCat === k, k)),
@@ -248,7 +249,7 @@ export async function openSources() {
248
249
  ...[...bySrc].map(([k, { it }]) => opt(k, iceSrc === k, srcName(it))),
249
250
  ])
250
251
  : "");
251
- const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" ? `${order.length} dismissed` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
252
+ const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" || iceCat === "Done" ? `${order.length} ${iceCat.toLowerCase()}` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
252
253
  const bulk = iceSel.size
253
254
  ? `<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Each becomes its own idea, titled as gathered (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`
254
255
  : "";
@@ -277,7 +278,7 @@ export async function openSources() {
277
278
  k = iceKey(it),
278
279
  open = iceForm === k;
279
280
  return (
280
- `<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button>${it.status === "dismissed" ? `<button class="btn ghost sm" id="ice-rs-${i}" data-rs="${i}" title="Restore to waiting" aria-label="Restore: ${esc(it.text)}">Restore</button>` : `<button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button>`}</span></span></div>` +
281
+ `<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.status === "done" && it.written ? `<span title="Written back ${esc(ago(it.written))}">written back</span>` : ""}${it.status === "done" && it.writeError ? `<span title="${esc(it.writeError)}">write-back failed</span>` : ""}${it.status === "promoted" ? `<span title="Promoted to Ideas: ${esc(it.idea || "")}">promoted</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra">${it.status === "promoted" || it.status === "done" ? "" : `<button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button>`}${it.status === "promoted" || it.status === "done" ? "" : it.status === "dismissed" ? `<button class="btn ghost sm" id="ice-rs-${i}" data-rs="${i}" title="Restore to waiting" aria-label="Restore: ${esc(it.text)}">Restore</button>` : `<button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button>`}</span></span></div>` +
281
282
  (open
282
283
  ? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}${(it.images || []).filter((u) => /^https:\/\//.test(u)).length ? `<details class="ice-imgs"><summary>Show images (${it.images.filter((u) => /^https:\/\//.test(u)).length}) — loads them from the source's site</summary>${it.images.filter((u) => /^https:\/\//.test(u)).map((u, n) => { const src = `/ice-image?dir=${encodeURIComponent(it.dir)}&id=${encodeURIComponent(it.id)}&i=${n}`; return `<a href="${esc(src)}" target="_blank" rel="noopener noreferrer"><img data-src="${esc(src)}" alt="" referrerpolicy="no-referrer"></a>`; }).join("")}</details>` : ""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post ? ` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${form(it)}</div>`
283
284
  : "")
@@ -293,7 +294,7 @@ export async function openSources() {
293
294
  // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
294
295
  const drawer = srcShow ?? !list.length,
295
296
  slide = drawer && !$("src-sec")?.classList.contains("open");
296
- const sec = `<div class="src-scrim${drawer && !slide ? " on" : ""}" id="src-scrim"></div><aside class="src-sec${drawer && !slide ? " open" : ""}" id="src-sec" aria-label="Sources"${drawer ? "" : " inert"}><header><div class="t"><b>Sources</b><div class="sub">${d && d.every && d.every !== "off" ? `Auto-check every ${esc(d.every)}` : "Auto-check off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a> · ${G.connected ? `ChatGPT${G.email ? " " + esc(G.email) : ""} <a id="cg-out" role="button" tabindex="0">Disconnect</a>` : `<a id="cg-in" role="button" tabindex="0">Connect ChatGPT</a>`}</div></div><div class="src-hda">${list.length ? `<button class="btn sm" id="src-new" aria-expanded="${addOn}" aria-controls="src-addbox">${ico("plus")} Add</button>` : ""}<button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></div></header><div class="body">${addBox}${list.length ? `<div class="src-list">${cards}</div>` : emptyState}</div></aside>`;
297
+ const sec = `<div class="src-scrim${drawer && !slide ? " on" : ""}" id="src-scrim"></div><aside class="src-sec${drawer && !slide ? " open" : ""}" id="src-sec" aria-label="Sources"${drawer ? "" : " inert"}><header><div class="t"><b>Sources</b><div class="sub">${d && d.every && d.every !== "off" ? `Auto-check every ${esc(d.every)}` : "Auto-check off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a> · ${G.connected ? `ChatGPT${G.email ? " " + esc(G.email) : ""} <a id="cg-out" role="button" tabindex="0">Disconnect</a>` : `<a id="cg-in" role="button" tabindex="0">Connect ChatGPT</a>`} · ${(d.google || {}).connected ? `Google <a id="gg-out" role="button" tabindex="0">Disconnect</a>` : `<a id="gg-in" role="button" tabindex="0">Connect Google</a>`}</div></div><div class="src-hda">${list.length ? `<button class="btn sm" id="src-new" aria-expanded="${addOn}" aria-controls="src-addbox">${ico("plus")} Add</button>` : ""}<button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></div></header><div class="body">${addBox}${list.length ? `<div class="src-list">${cards}</div>` : emptyState}</div></aside>`;
297
298
  // First visit: offer the ChatGPT plan for social sources, or keep the agent.
298
299
  const onb =
299
300
  G.use === "ask" && !G.connected
@@ -335,6 +336,32 @@ export async function openSources() {
335
336
  }
336
337
  };
337
338
  for (const id of ["cg-go", "cg-in"]) if ($(id)) $(id).onclick = connect;
339
+ // Google needs the user's own OAuth client (Desktop app, Sheets API on); asked once.
340
+ if ($("gg-in"))
341
+ $("gg-in").onclick = async () => {
342
+ if (!(d.google || {}).hasClient) {
343
+ toast("Add your Google client in Settings first");
344
+ store.setQ = "google";
345
+ go("settings", true);
346
+ return;
347
+ }
348
+ const w = window.open("", "_blank");
349
+ const j = await post("/google", { op: "connect" });
350
+ if (j.ok && w) w.location = j.url;
351
+ else {
352
+ w?.close();
353
+ if (j.ok) location.href = j.url;
354
+ else toast(j.error || "Could not start Google sign-in");
355
+ }
356
+ };
357
+ if ($("gg-out"))
358
+ $("gg-out").onclick = async () => {
359
+ if (!confirm("Disconnect Google? Done requests stop being written to sheets.")) return;
360
+ if ((await post("/google", { op: "disconnect" })).ok) {
361
+ toast("Google disconnected");
362
+ openSources();
363
+ }
364
+ };
338
365
  if ($("cg-skip"))
339
366
  $("cg-skip").onclick = async () => {
340
367
  if ((await post("/chatgpt", { op: "skip" })).ok) {
@@ -433,6 +460,25 @@ export async function openSources() {
433
460
  toast(`Source moved. Requests already gathered stay in ${s.project || "the old project"}.`),
434
461
  );
435
462
  });
463
+ $("pbody")
464
+ .querySelectorAll("[data-wb]")
465
+ .forEach((m) => {
466
+ const s = list[+m.dataset.wb];
467
+ m.onchange = () => {
468
+ if (m.checked && s.social === undefined && /docs\.google\.com/.test(s.url) && !(d.google || {}).connected) {
469
+ alert("Connect Google first (Sources header), so kaizen can write to the sheet.");
470
+ m.checked = false;
471
+ return;
472
+ }
473
+ if (m.checked && !confirm("When a promoted request's run is done, kaizen will write that back to this source. A closed issue or a changed cell is visible to others. Turn on?")) {
474
+ m.checked = false;
475
+ return;
476
+ }
477
+ act({ op: "writeback", dir: s.dir, url: s.url, on: m.checked }, () =>
478
+ toast(m.checked ? "Done requests will be written back" : "Write back off"),
479
+ );
480
+ };
481
+ });
436
482
  // The category saves on its button or Enter, never per keystroke.
437
483
  $("pbody")
438
484
  .querySelectorAll("[data-ct]")
@@ -138,6 +138,7 @@ export async function openRun(c, refresh, again) {
138
138
  pt.hidden = !c.pty;
139
139
  pt.classList.toggle("on", store.tab === "term");
140
140
  pt.setAttribute("aria-pressed", store.tab === "term");
141
+ pt.title = pt.ariaLabel = store.tab === "term" ? "Close terminal" : "Terminal";
141
142
  pt.onclick = () => {
142
143
  store.tab = store.tab === "term" ? store.lastTab : "term";
143
144
  openRun(c, true);
@@ -40,6 +40,9 @@ export async function openSettings() {
40
40
  const cfg = await fetch("/settings")
41
41
  .then((r) => r.json())
42
42
  .catch(() => null);
43
+ const gg = await fetch("/google")
44
+ .then((r) => r.json())
45
+ .catch(() => ({}));
43
46
  if (gen !== store.pageGen) return;
44
47
  $("psub").textContent = "Theme applies to this browser. Everything else is saved to your kaizen settings.";
45
48
  $("psub").title = cfg ? cfg.file : "";
@@ -69,7 +72,7 @@ export async function openSettings() {
69
72
  const ntHelp =
70
73
  "A desktop notification when a run starts waiting on you." +
71
74
  (np ? (np === "denied" ? " Blocked in this browser's site settings." : "") : " Not supported in this browser.");
72
- const names = [...(cfg ? groups(cfg.rows).map(([g]) => g) : []), "Browser"];
75
+ const names = [...(cfg ? groups(cfg.rows).map(([g]) => g) : []), "Google", "Browser"];
73
76
  $("pbody").innerHTML =
74
77
  `<div class="sq"><input class="sel" id="sq" type="search" placeholder="Search settings" aria-label="Search settings" spellcheck="false" autocomplete="off" value="${esc(store.setQ)}"><div class="sjump" role="group" aria-label="Jump to section">${names.map((g) => `<button data-g="${esc(g)}">${esc(g)}</button>`).join("")}</div></div>
75
78
  ${
@@ -97,6 +100,9 @@ export async function openSettings() {
97
100
  .join("")
98
101
  : `<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`
99
102
  }
103
+ <h3 class="sg">Google</h3>${row("OAuth client id", `<input class="sel" id="gg-id" placeholder="${gg.hasClient ? "saved; type to replace" : "from Google Cloud, Desktop app"}" spellcheck="false" autocomplete="off">`, "Kaizen writes Done into a sheet through your own Google client: create an OAuth client of type Desktop app in Google Cloud and enable the Sheets API. Nothing is shared with other installs.", "gg-id", "google oauth client id sheets write back")}
104
+ ${row("OAuth client secret", `<span class="fpick"><input class="sel" id="gg-secret" type="password" placeholder="${gg.hasClient ? "saved; type to replace" : ""}" spellcheck="false" autocomplete="off"><button class="btn" id="gg-save" type="button">Save</button></span>`, "Kept in ~/.kaizen/google.json, readable only by you. Saving a new client signs Google out.", "gg-secret", "google oauth client secret")}
105
+ ${row("Google account", `<button class="btn${gg.connected ? "" : " pri"}" id="gg-conn" type="button"${gg.hasClient || gg.connected ? "" : " disabled"}>${gg.connected ? "Disconnect" : "Connect"}</button>`, gg.connected ? "Connected. Sources with Write back on put Done in the sheet row once their run is done." : "Connect after saving the client. Needed only for Write back on sheet sources.", "gg-conn", "google account connect sheets write back")}
100
106
  <h3 class="sg">Browser</h3>${row("Theme", ctl("th", ["system", "light", "dark"], cur), "Light or dark for this browser only; system follows your device.", "th", "theme light dark system browser")}
101
107
  ${row("Notifications", `<button class="chk${np === "granted" ? " on" : ""}" id="nt" role="switch" aria-checked="${np === "granted"}"${np && np !== "denied" ? "" : " disabled"}></button>`, ntHelp, "nt", "notifications " + ntHelp + " browser")}
102
108
  <p class="snone" id="snone" hidden></p>`;
@@ -156,6 +162,25 @@ export async function openSettings() {
156
162
  f.focus();
157
163
  f.setSelectionRange(f.value.length, f.value.length);
158
164
  }
165
+ $("gg-save").onclick = async () => {
166
+ const j = await post("/google", { op: "client", id: $("gg-id").value, secret: $("gg-secret").value });
167
+ if (j.ok) toast("Saved");
168
+ else toast(j.error || "Enter both the client id and the secret");
169
+ openSettings();
170
+ };
171
+ $("gg-conn").onclick = async () => {
172
+ if (gg.connected) {
173
+ await post("/google", { op: "disconnect" });
174
+ return openSettings();
175
+ }
176
+ const w = window.open("", "_blank");
177
+ const j = await post("/google", { op: "connect" });
178
+ if (j.ok && w) w.location = j.url;
179
+ else {
180
+ w?.close();
181
+ toast(j.error || "Could not start Google sign-in");
182
+ }
183
+ };
159
184
  $("th").onchange = (e) => setTheme(e.target.value);
160
185
  $("nt").onclick = () => {
161
186
  askNotify();
package/web/board.html CHANGED
@@ -189,7 +189,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
189
189
  .notes{display:flex;flex-direction:column;gap:8px}
190
190
  #pbody img{max-width:100%;border-radius:var(--r-s)}
191
191
  /* the in-card agent: a wider panel, and the terminal filling its body */
192
- aside.wide{width:min(960px,100%)}#pbody .term{height:100%;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}
192
+ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-direction:column}#pbody .term{flex:1;min-height:0;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}.termkeys{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;padding:10px 0 6px}.termkeys button{height:56px;padding:0;border:1px solid var(--line);border-bottom-width:4px;border-radius:10px;background:var(--bg-2);color:var(--ink-hi);font:600 18px var(--mono);touch-action:manipulation}.termkeys button:active{transform:translateY(2px);border-bottom-width:2px;background:var(--hover)}.termkeys button.on{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}
193
193
  .desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
194
194
  .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
195
195
  .notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}