kaizen-agent 1.38.1 → 1.40.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.
@@ -9,8 +9,10 @@ import { brief } from "./markdown.ts";
9
9
  import { open, toggleArchive } from "./panel.ts";
10
10
  import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
+ import { resumable, resume } from "./approvals.ts";
12
13
 
13
14
  let filter = null;
15
+ let query = ""; // the header filter box, matched against a card's title and request
14
16
  // A column shows this many cards, then a "more" line; Done is where it matters.
15
17
  const FOLD = 6;
16
18
  const unfolded = new Set();
@@ -39,6 +41,12 @@ const nested = (c) =>
39
41
  store.S.cards.some((p) => p.kind === "run" && p.id === c.parent && p.state === c.state && !p.archived && p.column !== 4);
40
42
  const kidsOpen = new Set();
41
43
 
44
+ // What opening a waiting run does, in words: the card's button and its menu say the same.
45
+ const actionOf = (c) =>
46
+ c.status !== "waiting"
47
+ ? "Open"
48
+ : { "approvals.plan": "Review plan", findings: "See findings", "approvals.review": "Review work" }[c.awaiting] || "Open";
49
+
42
50
  function card(c) {
43
51
  const k = key(c),
44
52
  el = document.createElement("div");
@@ -51,7 +59,7 @@ function card(c) {
51
59
  el.dataset.k = k;
52
60
  el.setAttribute("role", "button");
53
61
  const st = ICON[c.status] || "idea",
54
- idt = c.kind === "idea" ? "idea" : c.text.replace(/-/g, " ");
62
+ idt = c.kind === "idea" ? "" : c.text.replace(/-/g, " ");
55
63
  // The status icon carries the state; what a waiting run needs is its tooltip.
56
64
  const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
57
65
  // Footer: words on the left (project, what it needs), icon-only marks on the right.
@@ -76,7 +84,9 @@ function card(c) {
76
84
  marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
77
85
  else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
78
86
  }
79
- if (c.pty && c.pty.alive)
87
+ if (c.pty && c.pty.alive && c.pty.asking)
88
+ marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
89
+ else if (c.pty && c.pty.alive)
80
90
  marks.push(mk("i-term", "The agent is running in this card: open it, terminal button at the top", " run"));
81
91
  // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
82
92
  // the icebox); its chip names the source.
@@ -98,10 +108,22 @@ function card(c) {
98
108
  );
99
109
  if (c.open) marks.push(mk("i-bug", `${c.open} open finding${c.open === 1 ? "" : "s"} to fix`, " warn"));
100
110
  const pick = picking && !archive && c.column === 4;
111
+ // Done cards are one line on the board; the panel still shows everything.
112
+ if (c.column === 4 && !archive) el.classList.add("compact");
113
+ // The one thing a card asks of you, on its face: what a waiting run needs, or resuming a stalled one.
114
+ const act =
115
+ c.kind === "run" && !archive && !pick
116
+ ? c.status === "waiting"
117
+ ? `<button class="btn sm pri act" data-a="open">${actionOf(c)}</button>`
118
+ : c.status === "stalled" && resumable(c)
119
+ ? `<button class="btn sm act" data-a="resume">Resume</button>`
120
+ : ""
121
+ : "";
101
122
  el.innerHTML =
102
123
  (pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
103
124
  `<div class="c-id"><span class="st d-${st}" role="img" title="${esc(sttl)}" aria-label="${esc(sttl)}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind === "run" ? age(c.moved || store.S.now, store.S.now) : ""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k === store.menuKey}">${ico("more")}</button></div>
104
125
  <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${esc(c.text)}"` : ""}>${esc(c.kind === "idea" ? brief(c.text).head : c.title || c.id)}</div>
126
+ ${act}
105
127
  ${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
106
128
  const kids = kidsOf(c);
107
129
  if (kids.length) {
@@ -160,17 +182,7 @@ function card(c) {
160
182
  ["delete", "Remove", ico("trash"), "danger"],
161
183
  );
162
184
  } else {
163
- const openLabel =
164
- c.status === "waiting"
165
- ? c.awaiting === "approvals.plan"
166
- ? "Review plan"
167
- : c.awaiting === "findings"
168
- ? "See findings"
169
- : c.awaiting === "approvals.review"
170
- ? "Review work"
171
- : "Open"
172
- : "Open";
173
- items.push(["open", openLabel, ico("open")]);
185
+ items.push(["open", actionOf(c), ico("open")]);
174
186
  items.push(["archive", "Archive", ico("archive")]);
175
187
  if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
176
188
  items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
@@ -194,6 +206,10 @@ function card(c) {
194
206
  open(c);
195
207
  return;
196
208
  }
209
+ if (a === "resume") {
210
+ resume(c, bt);
211
+ return;
212
+ }
197
213
  if (a === "archive" || a === "restore") {
198
214
  toggleArchive(c, a === "archive");
199
215
  return;
@@ -383,6 +399,8 @@ async function startRun(c, kind) {
383
399
  load();
384
400
  }
385
401
 
402
+ const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built here", 3: "Finished builds are checked here" };
403
+
386
404
  export function render() {
387
405
  // A closed form leaves no draft behind; an open one is reopened after a reload.
388
406
  // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
@@ -422,6 +440,10 @@ export function render() {
422
440
  `<button class="${archive ? "on" : ""}" data-f="__archive" title="${cs.filter((c) => c.kind === "idea").length} ideas · ${cs.filter((c) => c.kind === "run").length} runs on the board">${archive ? "Back to board" : "Archive " + nArch}</button>`,
423
441
  );
424
442
  $("strip").innerHTML = strip.join("");
443
+ // The phone's status button: amber dot while a run waits, accent while a filter or the archive is on.
444
+ $("fbtn").hidden = false;
445
+ $("fbtn").classList.toggle("on", !!filter || archive);
446
+ $("fbtn").classList.toggle("wait", !filter && !archive && n("waiting") > 0);
425
447
  $("strip")
426
448
  .querySelectorAll("button")
427
449
  .forEach(
@@ -468,12 +490,21 @@ export function render() {
468
490
  }
469
491
  COLS.forEach((name, ci) => {
470
492
  const s = document.createElement("section");
471
- const list = cs.filter((c) => c.column === ci && (!filter || c.status === filter) && (archive || !nested(c)));
493
+ const q = query.trim().toLowerCase();
494
+ const list = cs.filter(
495
+ (c) =>
496
+ c.column === ci &&
497
+ (!filter || c.status === filter) &&
498
+ (archive || !nested(c)) &&
499
+ (!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
500
+ );
472
501
  // Runs age by their last move, ideas by inbox position (new ones are appended).
473
502
  if (ci < 4) {
474
503
  const at = (c) => (c.kind === "run" ? c.moved || 0 : store.S.cards.indexOf(c)),
475
504
  old = order[ci] === "old";
476
- list.sort((a, b) => (old ? at(a) - at(b) : at(b) - at(a)));
505
+ // Runs waiting on you go first, then the column's own order.
506
+ const w = (c) => (c.status === "waiting" ? 1 : 0);
507
+ list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) : at(b) - at(a)));
477
508
  }
478
509
  const clearable = ci === 4 && !archive && list.length;
479
510
  const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
@@ -652,12 +683,51 @@ export function render() {
652
683
  };
653
684
  ul.appendChild(li);
654
685
  }
686
+ // An empty middle column says what lands in it, so it does not look broken.
687
+ if (!list.length && ci >= 1 && ci <= 3 && cs.length && !filter && !q && !archive)
688
+ ul.innerHTML = `<li class="hint">${HINT[ci]}</li>`;
655
689
  s.appendChild(ul);
656
690
  b.appendChild(s);
657
691
  });
692
+ // The tab says how many runs wait on you, so a background tab still tells you.
693
+ const nw = store.S.cards.filter((c) => c.kind === "run" && c.status === "waiting" && !c.archived).length;
694
+ document.title = nw ? `(${nw}) kaizen` : "kaizen";
658
695
  stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
659
696
  if (fk && document.activeElement === document.body) cardEl(fk)?.focus({ preventScroll: true });
660
697
  }
698
+ // The header filter box lives outside #board, so a redraw keeps what is typed; draft() keeps it over a reload.
699
+ export function filterBox() {
700
+ const el = $("bq");
701
+ draft(el, "bq");
702
+ query = el.value;
703
+ el.addEventListener("input", () => {
704
+ query = el.value;
705
+ render();
706
+ });
707
+ el.addEventListener("keydown", (e) => {
708
+ if (e.key === "Escape") {
709
+ e.stopPropagation();
710
+ el.value = "";
711
+ el.dispatchEvent(new Event("input"));
712
+ el.blur();
713
+ }
714
+ });
715
+ }
716
+ // Phones: the footer's status strip opens as a sheet under the filter button; a press outside closes it.
717
+ export function statusSheet() {
718
+ const b = $("fbtn"),
719
+ f = document.querySelector(".ftr");
720
+ const set = (on) => {
721
+ f.classList.toggle("on", on);
722
+ b.setAttribute("aria-expanded", on);
723
+ if (on) f.style.top = b.getBoundingClientRect().bottom + 6 + "px";
724
+ };
725
+ b.onclick = () => set(!f.classList.contains("on"));
726
+ document.addEventListener("pointerdown", (e) => {
727
+ if (f.classList.contains("on") && !f.contains(e.target) && !b.contains(e.target)) set(false);
728
+ });
729
+ document.addEventListener("keydown", (e) => e.key === "Escape" && f.classList.contains("on") && set(false));
730
+ }
661
731
  export const focusedKey = () => document.activeElement?.closest?.("#board .card")?.dataset.k;
662
732
  export const cardEl = (k) => k && $("board").querySelector('.card[data-k="' + CSS.escape(k) + '"]');
663
733
  // Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
@@ -222,22 +222,22 @@ export async function openSources() {
222
222
  : `<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>`;
223
223
  const kind = st === "sheet" ? "sheet" : st === "doc" ? "doc" : st === "social" ? "at" : "link",
224
224
  bare = s.url.replace(/^https?:\/\/(www\.)?/, "");
225
- return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><span class="src-ico" title="${st === "social" ? esc(PLAT[s.social] || "social") : st}">${fav(s.url, kind, s.social)}</span><div class="src-info"><span class="src-title" title="${esc(title)}">${esc(title)}</span><a class="src-url" href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(bare)}</span>${ico("ext")}</a></div><div class="src-acts">${s.social ? `<button class="ib sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn ? "Gather with your ChatGPT plan" : "Gather: opens your agent on /kaizen gather"}">${ico("refresh")}</button>` : `<button class="ib sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check now">${ico("refresh")}</button>`}<button class="ib sm danger" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-status">${statusHtml}</div><div class="src-fields"><label class="src-f" title="Category"><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="${esc(platOf(s))}" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm pri" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></label><label class="src-f" title="Project"><span>Project</span>${plogo(s.dir, s.project || "")}<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 pri" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></label>${st === "sheet" || /github\.com/.test(s.url) ? `<label class="src-wb"><input type="checkbox" id="src-wb-${i}" data-wb="${i}"${s.writeback ? " checked" : ""}> ${st === "sheet" ? "Mark the row Done" : "Comment and close the issue"} when its run is done</label>` : ""}</div></div>`;
225
+ return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><span class="src-ico" title="${st === "social" ? esc(PLAT[s.social] || "social") : st}">${fav(s.url, kind, s.social)}</span><div class="src-info"><span class="src-title" title="${esc(title)}">${esc(title)}</span><a class="src-url" href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(bare)}</span>${ico("ext")}</a></div><div class="src-acts">${s.social ? `<button class="ib sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn ? "Gather with your ChatGPT plan" : "Gather: opens your agent on /kaizen gather"}">${ico("refresh")}</button>` : `<button class="ib sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check now">${ico("refresh")}</button>`}<button class="ib sm danger" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-status">${statusHtml}</div><div class="src-fields"><label class="src-f" title="Category"><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="${esc(platOf(s))}" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm pri" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></label><label class="src-f" title="Project"><span>Project</span>${plogo(s.dir, s.project || "")}<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 pri" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></label>${st === "sheet" || /github\.com/.test(s.url) ? `<label class="src-wb"><input type="checkbox" id="src-wb-${i}" data-wb="${i}"${s.writeback ? " checked" : ""}> Write back</label>` : ""}</div></div>`;
226
226
  })
227
227
  .join("");
228
228
  const fails = ((d && d.failed) || [])
229
229
  .map((f) => `<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`)
230
230
  .join("");
231
- const emptyState = `<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No request sources yet</h4><p>Connect shared links to gather requests into the icebox, where you choose which become ideas. Nothing runs automatically until you start it.</p><div class="src-empty-grid"><div class="src-empty-item"><strong>Google Sheets</strong>Public sheets with idea or task rows. Rows marked Done are skipped.</div><div class="src-empty-item"><strong>Google Docs</strong>Shared public docs with items or notes written line by line.</div><div class="src-empty-item"><strong>Public links</strong>TSV, CSV, or raw text links containing requests.</div><div class="src-empty-item"><strong>Social media</strong>A keyword, #hashtag or @account on X, TikTok, Instagram or Threads, read by your agent.</div></div></div>`;
231
+ const emptyState = `<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No sources yet</h4></div>`;
232
232
  const social = srcKind === "social";
233
233
  const addOn = !list.length || srcAdd || [typed[0], typed[1], typed[4], typed[6]].some(Boolean);
234
- const addBox = `<div class="src-add" id="src-addbox"${addOn ? "" : " hidden"}><div class="hd"><h4>Add a source</h4><div class="src-kind" role="group" aria-label="Kind of source"><button id="src-k-link" data-kind="link" aria-pressed="${!social}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${social}">Social</button></div></div><label for="src-url"${social ? " hidden" : ""}><span>Link</span><input class="sel" id="src-url" placeholder="https://docs.google.com/spreadsheets/d/… or any public link" spellcheck="false" autocomplete="off"></label><div class="two plat"${social ? "" : " hidden"}><label for="src-plat"><span>Platform</span><select class="sel" id="src-plat">${Object.entries(
234
+ const addBox = `<div class="src-add" id="src-addbox"${addOn ? "" : " hidden"}><div class="hd"><h4>Add a source</h4><div class="src-kind" role="group" aria-label="Kind of source"><button id="src-k-link" data-kind="link" aria-pressed="${!social}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${social}">Social</button></div></div><label for="src-url"${social ? " hidden" : ""}><span>Link</span><input class="sel" id="src-url" placeholder="Link" spellcheck="false" autocomplete="off"></label><div class="two plat"${social ? "" : " hidden"}><label for="src-plat"><span>Platform</span><select class="sel" id="src-plat">${Object.entries(
235
235
  PLAT,
236
236
  )
237
237
  .map(([k, v]) => `<option value="${k}">${v}</option>`)
238
238
  .join(
239
239
  "",
240
- )}</select></label><label for="src-q"><span>Follow</span><input class="sel" id="src-q" placeholder="keyword, #hashtag or @account" maxlength="100" spellcheck="false" autocomplete="off"></label></div><div class="two"><label for="src-label"><span>Label <i>optional</i></span><input class="sel" id="src-label" placeholder="${social ? "e.g. Mentions on X" : "e.g. Feedback Q4"}"></label><label for="src-cat"><span>Category <i>optional</i></span><input class="sel" id="src-cat" placeholder="${social ? "e.g. Social" : "e.g. Bugs"}" maxlength="40"></label></div><label for="src-dir"><span>Project</span><select class="sel" id="src-dir">${dir ? "" : `<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="hint">${social ? (chatOn ? "Your ChatGPT plan reads public posts when you press Gather on the source." : "Kaizen signs in nowhere. Your agent reads public posts when you press Gather on the source.") : "Public links only. For private docs, run /kaizen gather."}</span><button class="btn pri" id="src-add">Add source</button></div></div>`;
240
+ )}</select></label><label for="src-q"><span>Follow</span><input class="sel" id="src-q" placeholder="keyword, #hashtag or @account" maxlength="100" spellcheck="false" autocomplete="off"></label></div><div class="two"><label for="src-label"><span>Label <i>optional</i></span><input class="sel" id="src-label" placeholder="${social ? "e.g. Mentions on X" : "e.g. Feedback Q4"}"></label><label for="src-cat"><span>Category <i>optional</i></span><input class="sel" id="src-cat" placeholder="${social ? "e.g. Social" : "e.g. Bugs"}" maxlength="40"></label></div><label for="src-dir"><span>Project</span><select class="sel" id="src-dir">${dir ? "" : `<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="end"></span><button class="btn pri" id="src-add">Add source</button></div></div>`;
241
241
  // The inbox: one row per request, by project when showing all, then by category,
242
242
  // narrowed to one source by its chip. Checked, open and cursor items are kept by key.
243
243
  const srcName = (it) => {
@@ -340,21 +340,21 @@ export async function openSources() {
340
340
  .join(""),
341
341
  )
342
342
  : "");
343
- 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>`;
343
+ 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><span class="end"></span>${chips ? `<details class="ice-filt"><summary class="ib${iceCat || iceSrc ? " on" : ""}" aria-label="Filter requests" title="Filter requests">${ico("filter")}</summary><div class="ice-fpop">${chips}</div></details>` : ""}<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>`;
344
344
  const bulk = iceSel.size
345
- ? `<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>`
345
+ ? `<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="Promote (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>`
346
346
  : "";
347
347
  // The open item's form: a clean title for the idea's card and the person's own details;
348
348
  // what was gathered is added to the idea's notes by the server.
349
349
  const form = (it) =>
350
- `<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details</label><textarea class="sel" id="ice-details" rows="3" placeholder="What, for whom, why" aria-label="Details"></textarea><div class="ice-form-acts"><span class="hint">Source goes into the notes.</span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
350
+ `<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details</label><textarea class="sel" id="ice-details" rows="3" aria-label="Details"></textarea><div class="ice-form-acts"><span class="end"></span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
351
351
  // A gathered post can be answered: Reply drafts, the box is the approval, Send reply posts that text.
352
352
  const replyBox = (it) => {
353
353
  if (!it.post) return "";
354
354
  if (it.replied) return `<span class="hint" style="margin:0">You replied to this post ${ago(it.replied)}.</span>`;
355
355
  const r = iceReply && iceReply.k === iceKey(it) ? iceReply : null;
356
356
  const style = (d) =>
357
- `<select class="sel" id="ice-rst" aria-label="Reply style" title="Promo names kaizen; Helpful may mention it when it fits; Casual and Fun never do"${d ? " disabled" : ""}>${Object.entries(
357
+ `<select class="sel" id="ice-rst" aria-label="Reply style"${d ? " disabled" : ""}>${Object.entries(
358
358
  STYLES,
359
359
  )
360
360
  .map(([v, l]) => `<option value="${v}"${v === rStyle(it.dir) ? " selected" : ""}>${l}</option>`)
@@ -377,10 +377,10 @@ export async function openSources() {
377
377
  };
378
378
  const inbox =
379
379
  waiting && !order.length
380
- ? `<div class="ice-zero"><h4>Nothing matches</h4><p>${waiting} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
380
+ ? `<div class="ice-zero"><h4>No matches</h4><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
381
381
  : order.length
382
382
  ? `<div class="ice-list">${[...groups].map(([g, ix]) => `<div class="ice-cat">${esc(g)}<i>${ix.length}</i></div>${ix.map(row).join("")}`).join("")}</div>`
383
- : `<div class="ice-zero"><h4>Inbox zero</h4><p>Nothing waiting. Gather all checks your sources for new requests; /kaizen discover in your agent sorts what comes in.</p></div>`;
383
+ : `<div class="ice-zero"><h4>Nothing waiting</h4></div>`;
384
384
  // The sources, in the right-hand drawer: shut until asked for, open by itself while there
385
385
  // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
386
386
  const drawer = srcShow ?? !list.length,
@@ -389,7 +389,7 @@ export async function openSources() {
389
389
  // First visit: offer the ChatGPT plan for social sources, or keep the agent.
390
390
  const onb =
391
391
  G.use === "ask" && !G.connected
392
- ? `<div class="cg-onb"><b>Gather social posts with your ChatGPT plan</b><p>Connect ChatGPT Plus or Pro and Gather reads X, TikTok, Instagram and Threads sources right here, counted against your plan, with no agent to open. Skip it and Gather keeps using your agent. You can change this in Settings.</p><div class="acts"><button class="btn ghost" id="cg-skip">Skip for now</button><button class="btn pri" id="cg-go">Continue with ChatGPT</button></div></div>`
392
+ ? `<div class="cg-onb"><div class="acts"><b>Gather social posts with ChatGPT?</b><span class="end"></span><button class="btn ghost" id="cg-skip">Skip</button><button class="btn pri" id="cg-go">Connect</button></div></div>`
393
393
  : "";
394
394
  // A rebuild replaces the drawer: keep where it was scrolled to.
395
395
  const at = $("src-sec")?.querySelector(".body")?.scrollTop ?? 0;
@@ -460,7 +460,6 @@ export async function openSources() {
460
460
  if ($("cg-skip"))
461
461
  $("cg-skip").onclick = async () => {
462
462
  if ((await post("/chatgpt", { op: "skip" })).ok) {
463
- toast("Gather keeps using your agent");
464
463
  openSources();
465
464
  }
466
465
  };
@@ -550,7 +549,7 @@ export async function openSources() {
550
549
  })();
551
550
  b.onclick = () =>
552
551
  act({ op: "move", dir: s.dir, to: m.value, url: s.url }, () =>
553
- toast(`Source moved. Requests already gathered stay in ${s.project || "the old project"}.`),
552
+ toast("Source moved"),
554
553
  );
555
554
  });
556
555
  $("pbody")
@@ -568,7 +567,7 @@ export async function openSources() {
568
567
  return;
569
568
  }
570
569
  act({ op: "writeback", dir: s.dir, url: s.url, on: m.checked }, () =>
571
- toast(m.checked ? "Done requests will be written back" : "Write back off"),
570
+ toast(m.checked ? "Write back on" : "Write back off"),
572
571
  );
573
572
  };
574
573
  });
@@ -967,7 +966,7 @@ export async function openSources() {
967
966
  );
968
967
  if (j.ok && social) {
969
968
  $("src-q").value = $("src-label").value = $("src-cat").value = "";
970
- toast("Source added. Click Gather on it to have your agent read its posts.");
969
+ toast("Source added");
971
970
  load();
972
971
  } else if (j.ok) {
973
972
  $("src-url").value = $("src-label").value = $("src-cat").value = "";
package/web/board/keys.ts CHANGED
@@ -39,6 +39,11 @@ export function initKeys() {
39
39
  $("sq").focus();
40
40
  return;
41
41
  }
42
+ if (e.key === "/" && view === "board" && $("bq")) {
43
+ e.preventDefault();
44
+ $("bq").focus();
45
+ return;
46
+ }
42
47
  if (e.key === "Escape") {
43
48
  $("help").classList.remove("on");
44
49
  if (store.mode) {
package/web/board/main.ts CHANGED
@@ -9,6 +9,7 @@ import { initViews } from "./views.ts";
9
9
  import { initProjects } from "./projects.ts";
10
10
  import { initUpdate } from "./update.ts";
11
11
  import { initKeys } from "./keys.ts";
12
+ import { filterBox, statusSheet } from "./cards.ts";
12
13
  import { initLive } from "./live.ts";
13
14
  import { store } from "./store.ts";
14
15
 
@@ -18,6 +19,8 @@ initProjects();
18
19
  initUpdate();
19
20
  initKeys();
20
21
  initLive();
22
+ filterBox();
23
+ statusSheet();
21
24
 
22
25
  // One popup at a time: a press anywhere first shuts every open popup it is not inside,
23
26
  // before any handler runs (several stop propagation), so the one it opens is the only one
@@ -44,8 +44,8 @@ export async function openSettings() {
44
44
  .then((r) => r.json())
45
45
  .catch(() => ({}));
46
46
  if (gen !== store.pageGen) return;
47
- $("psub").textContent = "Theme applies to this browser. Everything else is saved to your kaizen settings.";
48
- $("psub").title = cfg ? cfg.file : "";
47
+ $("psub").textContent = "";
48
+ $("psub").title = "";
49
49
  // One control per row: a switch for on/off, one segment per value for a small number,
50
50
  // a select for a choice. The row's label names the control; its help sits under it.
51
51
  const ctl = (id, vals, on) => {
@@ -64,15 +64,12 @@ export async function openSettings() {
64
64
  return `<select class="sel" id="${id}">${vals.map((v) => `<option value="${esc(v)}"${v === on ? " selected" : ""}>${v === "" ? "—" : esc(v)}</option>`).join("")}</select>`;
65
65
  };
66
66
  const row = (label, control, hint, id, q) =>
67
- `<div class="srow" data-q="${esc(q.toLowerCase())}"><label for="${id}">${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
67
+ `<div class="srow" data-q="${esc(q.toLowerCase())}"><label for="${id}">${esc(label)}</label>${control}${hint ? `<p class="hint">${hint}</p>` : ""}</div>`;
68
68
  const hadFocus = document.activeElement?.id === "sq";
69
69
  const np = typeof Notification === "undefined" ? null : Notification.permission;
70
- const ntHelp =
71
- "A desktop notification when a run starts waiting on you." +
72
- (np ? (np === "denied" ? " Blocked in this browser's site settings." : "") : " Not supported in this browser.");
73
- const names = [...(cfg ? groups(cfg.rows).map(([g]) => g) : []), "Google", "Browser"];
70
+ const ntHelp = np ? (np === "denied" ? "Blocked by this browser" : "") : "Not supported in this browser";
74
71
  $("pbody").innerHTML =
75
- `<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>
72
+ `<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>
76
73
  ${
77
74
  cfg
78
75
  ? groups(cfg.rows)
@@ -98,47 +95,30 @@ export async function openSettings() {
98
95
  .join("")
99
96
  : `<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`
100
97
  }
101
- <h3 class="sg">Google</h3><div class="sgrp">${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")}
102
- ${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")}
103
- ${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")}</div>
104
- <h3 class="sg">Browser</h3><div class="sgrp">${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")}
98
+ <h3 class="sg">Google</h3><div class="sgrp">${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">`, "", "gg-id", "google oauth client id sheets write back")}
99
+ ${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>`, "", "gg-secret", "google oauth client secret")}
100
+ ${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-conn", "google account connect sheets write back")}</div>
101
+ <h3 class="sg">Browser</h3><div class="sgrp">${row("Theme", ctl("th", ["system", "light", "dark"], cur), "", "th", "theme light dark system browser")}
105
102
  ${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")}</div>
106
103
  <p class="snone" id="snone" hidden></p>`;
107
104
  // Hides rows that do not match, then any group (and its chip) left with none.
108
105
  updBar();
109
- const heads = [...$("pbody").querySelectorAll(".sg")],
110
- chips = [...$("pbody").querySelectorAll(".sjump button")];
106
+ const heads = [...$("pbody").querySelectorAll(".sg")];
111
107
  const filter = () => {
112
108
  const q = store.setQ.trim().toLowerCase();
113
109
  let any = false;
114
- heads.forEach((h, i) => {
110
+ heads.forEach((h) => {
115
111
  let vis = false;
116
112
  for (const n of h.nextElementSibling.children) {
117
113
  n.hidden = !!q && !n.dataset.q.includes(q);
118
114
  vis ||= !n.hidden;
119
115
  }
120
116
  h.hidden = !vis;
121
- if (chips[i]) chips[i].hidden = !vis;
122
117
  any ||= vis;
123
118
  });
124
119
  $("snone").hidden = any;
125
120
  $("snone").textContent = `No setting matches “${store.setQ.trim()}”.`;
126
121
  };
127
- // Scrolled by measure: the pinned bar's height changes as the chips wrap.
128
- chips.forEach(
129
- (b, i) =>
130
- (b.onclick = () => {
131
- const pb = $("pbody");
132
- pb.scrollTo({
133
- top:
134
- pb.scrollTop +
135
- heads[i].getBoundingClientRect().top -
136
- pb.querySelector(".sq").getBoundingClientRect().bottom -
137
- 8,
138
- behavior: "smooth",
139
- });
140
- }),
141
- );
142
122
  $("sq").oninput = (e) => {
143
123
  store.setQ = e.target.value;
144
124
  filter();
@@ -9,53 +9,52 @@ import { view } from "./views.ts";
9
9
  // is a quiet row whose Check for updates asks npm now instead of the server's 6 h cache.
10
10
  let upd = null,
11
11
  ver = null;
12
- function gotVer(v) {
12
+ export function gotVer(v) {
13
13
  ver = v;
14
- if (!v.update) return false;
15
- upd = v;
14
+ upd = v.update ? v : null;
16
15
  const a = document.querySelector('.rail a[data-view="settings"]');
17
- a.classList.add("upd");
18
- a.title = `Settings: kaizen ${v.latest} is available`;
19
- return true;
16
+ a.classList.toggle("upd", !!upd);
17
+ a.title = upd ? `Settings: kaizen ${v.latest} is available` : "Settings";
18
+ return !!upd;
20
19
  }
21
20
  export function updBar() {
21
+ const d = document.createElement("div");
22
+ d.className = `updbar${upd ? "" : " quiet"}`;
22
23
  if (!upd) {
23
- const d = document.createElement("div");
24
- d.className = "updbar quiet";
25
- d.innerHTML = `<span id="upd-msg" aria-live="polite">${ver ? `You are running kaizen ${esc(ver.version)}.` : "kaizen"}</span><button class="btn" id="upd-chk">Check for updates</button>`;
26
- $("pbody").prepend(d);
27
- const b = $("upd-chk");
28
- b.onclick = async () => {
29
- b.disabled = true;
30
- b.textContent = "Checking…";
31
- const v = await fetch("/version?fresh=1")
32
- .then((r) => r.json())
33
- .catch(() => null);
34
- if (!d.isConnected) return;
35
- b.disabled = false;
36
- b.textContent = "Check for updates";
37
- if (v && gotVer(v)) {
38
- d.remove();
39
- updBar();
40
- $("upd-go").focus();
41
- return;
42
- }
43
- $("upd-msg").textContent =
44
- !v || !v.latest ? "Could not reach npm. Try again later." : `kaizen ${v.version} is up to date.`;
45
- };
46
- return;
24
+ d.innerHTML = `<span id="upd-msg" aria-live="polite">${ver ? `kaizen ${esc(ver.version)}` : "kaizen"}</span><button class="btn" id="upd-chk">Check for updates</button>`;
25
+ } else {
26
+ // What's new: each newer release's one-line title, shown on hover, focus or click.
27
+ const notes = (upd.notes || []).map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`).join("");
28
+ const news = notes
29
+ ? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
30
+ : "";
31
+ d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
47
32
  }
48
- const d = document.createElement("div");
49
- d.className = "updbar";
50
- // What's new: each newer release's one-line title, shown on hover, focus or click.
51
- const notes = (upd.notes || [])
52
- .map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`)
53
- .join("");
54
- const news = notes
55
- ? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
56
- : "";
57
- d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn pri" id="upd-go">Update kaizen</button></span>`;
58
33
  $("pbody").prepend(d);
34
+ const check = $("upd-chk");
35
+ check.onclick = async () => {
36
+ check.disabled = true;
37
+ check.textContent = "Checking…";
38
+ const v = await fetch("/version?fresh=1")
39
+ .then((r) => r.json())
40
+ .catch(() => null);
41
+ if (!d.isConnected) return;
42
+ check.disabled = false;
43
+ if (!v || !v.latest) {
44
+ if (upd) check.textContent = "Could not check. Try again";
45
+ else $("upd-msg").textContent = "Could not reach npm. Try again later.";
46
+ return;
47
+ }
48
+ gotVer(v);
49
+ d.remove();
50
+ updBar();
51
+ if (upd) $("upd-go").focus();
52
+ else {
53
+ $("upd-msg").textContent = `kaizen ${v.version} is up to date.`;
54
+ $("upd-chk").focus();
55
+ }
56
+ };
57
+ if (!upd) return;
59
58
  const b = $("upd-go");
60
59
  b.onclick = async () => {
61
60
  if (!confirm(`Update kaizen to ${upd.latest}? The board restarts, and agents running inside cards stop.`)) return;