kaizen-agent 1.34.12 → 1.36.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.
@@ -4,7 +4,7 @@ import { load, post } from "./api.ts";
4
4
  import { via } from "./cards.ts";
5
5
  import { stopDictation } from "./dictation.ts";
6
6
  import { $, esc, ico } from "./dom.ts";
7
- import { setShown, short, showable } from "./projects.ts";
7
+ import { plogo, setShown, short, showable } from "./projects.ts";
8
8
  import { toast } from "./toast.ts";
9
9
  import { go, view } from "./views.ts";
10
10
 
@@ -54,6 +54,60 @@ const ago = (at) => {
54
54
  ? Math.round(m / 60) + " h ago"
55
55
  : Math.round(m / 1440) + " d ago";
56
56
  };
57
+ // A searchable filter picker: the button opens it, typing narrows the rows (a group header
58
+ // stays while any of its rows does), Enter or a click picks.
59
+ function wirePick(id, set) {
60
+ const c = $(id);
61
+ if (!c) return;
62
+ const btn = c.querySelector("button.sel"),
63
+ pp = c.querySelector(".menu-pop"),
64
+ q = pp.querySelector("input"),
65
+ box = pp.querySelector(".pick-list");
66
+ const show = (on) => {
67
+ pp.hidden = !on;
68
+ btn.setAttribute("aria-expanded", on);
69
+ if (on) {
70
+ q.value = "";
71
+ narrow();
72
+ q.focus();
73
+ }
74
+ };
75
+ const narrow = () => {
76
+ const t = q.value.trim().toLowerCase();
77
+ let g = null,
78
+ any = false;
79
+ for (const el of box.children) {
80
+ if (el.classList.contains("grp")) {
81
+ if (g) g.hidden = !any;
82
+ g = el;
83
+ any = false;
84
+ } else {
85
+ const hit = !t || el.dataset.q.includes(t) || (g && g.dataset.g.includes(t));
86
+ el.hidden = !hit;
87
+ any ||= hit;
88
+ }
89
+ }
90
+ if (g) g.hidden = !any;
91
+ };
92
+ btn.onclick = () => show(pp.hidden);
93
+ q.oninput = narrow;
94
+ q.onkeydown = (e) => {
95
+ if (e.key === "Escape") {
96
+ e.stopPropagation();
97
+ show(false);
98
+ btn.focus();
99
+ } else if (e.key === "Enter") {
100
+ e.preventDefault();
101
+ box.querySelector("button:not([hidden])")?.click();
102
+ }
103
+ };
104
+ box.onclick = (e) => {
105
+ const b = e.target.closest("button[data-v]");
106
+ if (!b) return;
107
+ show(false);
108
+ set(b.dataset.v);
109
+ };
110
+ }
57
111
  export async function openSources() {
58
112
  if (view !== "sources") return;
59
113
  const gen = ++store.pageGen;
@@ -128,6 +182,8 @@ export async function openSources() {
128
182
  : h + "/favicon.ico";
129
183
  return `<span class="fav">${src ? `<img src="${esc(src)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">` : ""}${ico(fb)}</span>`;
130
184
  };
185
+ // What a source is called when nothing names it: its platform, else its site.
186
+ const platOf = (s) => PLAT[s?.social] || (s ? host(s.url).replace(/^www\./, "") : "") || "Other";
131
187
  const srcType = (s) => {
132
188
  if (s.social) return "social";
133
189
  const u = s.url;
@@ -146,10 +202,10 @@ export async function openSources() {
146
202
  // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
147
203
  const opts = (on, name) =>
148
204
  (on && !store.S.projects.some((p) => p.dir === on)
149
- ? `<option value="${esc(on)}" selected>${esc(name || on)}</option>`
205
+ ? `<option value="${esc(on)}" title="${esc(name || on)}" selected>${esc(short(name || on))}</option>`
150
206
  : "") +
151
207
  store.S.projects
152
- .map((p) => `<option value="${esc(p.dir)}"${p.dir === on ? " selected" : ""}>${esc(p.label)}</option>`)
208
+ .map((p) => `<option value="${esc(p.dir)}" title="${esc(p.label)}"${p.dir === on ? " selected" : ""}>${esc(short(p.label))}</option>`)
153
209
  .join("");
154
210
  const cards = list
155
211
  .map((s, i) => {
@@ -164,7 +220,9 @@ export async function openSources() {
164
220
  : s.last.ok
165
221
  ? `<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>`
166
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>`;
167
- 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>`;
223
+ const kind = st === "sheet" ? "sheet" : st === "doc" ? "doc" : st === "social" ? "at" : "link",
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>`;
168
226
  })
169
227
  .join("");
170
228
  const fails = ((d && d.failed) || [])
@@ -190,7 +248,14 @@ export async function openSources() {
190
248
  srcKey = (it) => it.dir + " " + it.source;
191
249
  // Filters narrow in turn: project (the board's own picker, all projects only), then
192
250
  // category, then source; each chip counts what the filters before it leave.
193
- const catOf = (it) => (it.status === "dismissed" ? "Dismissed" : it.status === "done" ? "Done" : it.category || "Uncategorised"),
251
+ // An item with no category of its own takes its source's, else the source's platform or site.
252
+ const srcOf = (it) => list.find((x) => x.dir === it.dir && x.url === it.source),
253
+ catOf = (it) =>
254
+ it.status === "dismissed"
255
+ ? "Dismissed"
256
+ : it.status === "done"
257
+ ? "Done"
258
+ : it.category || srcOf(it)?.category || platOf(srcOf(it) || { url: it.source }),
194
259
  count = (xs, f) => {
195
260
  const m = new Map();
196
261
  xs.forEach((it) => {
@@ -234,23 +299,46 @@ export async function openSources() {
234
299
  const nSel = order.filter((i) => iceSel.has(iceKey(ice[i]))).length;
235
300
  // Filters are plain dropdowns, each shown only when it has a choice to make. Projects are
236
301
  // narrowed by the board's own Showing picker above, not here.
237
- const many = byProj.size > 1,
238
- opt = (v, on, t) => `<option value="${esc(v)}"${on ? " selected" : ""}>${esc(t)}</option>`;
239
- const pick = (id, label, xs) =>
240
- `<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
302
+ const many = byProj.size > 1;
303
+ // Filters are searchable pickers (the header's combo): a button, then a search over the list.
304
+ const pick = (id, label, cur, rows) =>
305
+ `<div class="combo ice-combo" id="${id}"><button class="sel" aria-label="${label}" aria-haspopup="listbox" aria-expanded="false">${cur}</button><div class="menu-pop" hidden><input class="sel" aria-label="Search ${label.toLowerCase()}" placeholder="Search…" spellcheck="false" autocomplete="off"><div class="pick-list" role="listbox">${rows}</div></div></div>`;
306
+ const opt = (v, on, html, q, cls = "") =>
307
+ `<button type="button" role="option" data-v="${esc(v)}" data-q="${esc(q.toLowerCase())}" class="${on ? "on" : ""}${cls}" aria-selected="${on}">${html}</button>`;
308
+ // Sources group by platform: two Threads searches sit under one Threads.
309
+ const srcGroups = new Map();
310
+ for (const [k, { it }] of bySrc) {
311
+ const g = platOf(srcOf(it) || { url: it.source });
312
+ srcGroups.has(g) || srcGroups.set(g, []);
313
+ srcGroups.get(g).push([k, it]);
314
+ }
315
+ const srcIco = (it) => fav(it.source, "link", srcOf(it)?.social);
316
+ const curSrc = iceSrc && [...bySrc].find(([k]) => k === iceSrc)?.[1].it;
241
317
  const chips =
242
318
  // Dismissed and Done are hidden until picked, so even a lone one is a choice.
243
319
  (byCat.size > 1 || byCat.has("Dismissed") || byCat.has("Done")
244
- ? pick("ice-fc", "Category", [
245
- opt("", !iceCat, "All categories"),
246
- ...[...byCat.keys()].map((k) => opt(k, iceCat === k, k)),
247
- ])
320
+ ? pick(
321
+ "ice-fc",
322
+ "Category",
323
+ `<span>${esc(iceCat || "All categories")}</span>`,
324
+ opt("", !iceCat, `<span>All categories</span>`, "all categories") +
325
+ [...byCat].map(([k, { n }]) => opt(k, iceCat === k, `<span>${esc(k)}</span><i>${n}</i>`, k)).join(""),
326
+ )
248
327
  : "") +
249
328
  (bySrc.size > 1
250
- ? pick("ice-fs", "Source", [
251
- opt("", !iceSrc, "All sources"),
252
- ...[...bySrc].map(([k, { it }]) => opt(k, iceSrc === k, srcName(it))),
253
- ])
329
+ ? pick(
330
+ "ice-fs",
331
+ "Source",
332
+ curSrc ? `${srcIco(curSrc)}<span>${esc(srcName(curSrc))}</span>` : `<span>All sources</span>`,
333
+ opt("", !iceSrc, `<span>All sources</span>`, "all sources") +
334
+ [...srcGroups]
335
+ .map(
336
+ ([g, xs]) =>
337
+ `<div class="grp" data-g="${esc(g.toLowerCase())}">${srcIco(xs[0][1])}${esc(g)}</div>` +
338
+ xs.map(([k, it]) => opt(k, iceSrc === k, `${srcIco(it)}<span>${esc(srcName(it).toLowerCase().startsWith(g.toLowerCase() + ":") ? srcName(it).slice(g.length + 1).trim() : srcName(it))}</span><i>${bySrc.get(k).n}</i>`, g + " " + srcName(it), " sub")).join(""),
339
+ )
340
+ .join(""),
341
+ )
254
342
  : "");
255
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>`;
256
344
  const bulk = iceSel.size
@@ -281,7 +369,7 @@ export async function openSources() {
281
369
  k = iceKey(it),
282
370
  open = iceForm === k;
283
371
  return (
284
- `<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" title="${esc(it.project || "")}">${esc(short(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 (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>` +
372
+ `<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" title="${esc(it.project || "")}">${plogo(it.dir, it.project || "")}${esc(short(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 (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>` +
285
373
  (open
286
374
  ? `<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)}${icePro === k ? form(it) : ""}</div>`
287
375
  : "")
@@ -297,12 +385,14 @@ export async function openSources() {
297
385
  // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
298
386
  const drawer = srcShow ?? !list.length,
299
387
  slide = drawer && !$("src-sec")?.classList.contains("open");
300
- 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>`;
388
+ 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="src-chips"><a id="src-to-settings" class="schip" role="button" tabindex="0" title="${d && d.every && d.every !== "off" ? `Checked every ${esc(d.every)}` : "Auto-check off"}: change in Settings">${ico("clock")}${d && d.every && d.every !== "off" ? esc(d.every) : "Off"}</a>${G.connected ? `<span class="schip on" title="ChatGPT${G.email ? ": " + esc(G.email) : ""}">${ico("ai")}ChatGPT<a id="cg-out" role="button" tabindex="0" aria-label="Disconnect ChatGPT" title="Disconnect">${ico("x")}</a></span>` : `<a id="cg-in" class="schip" role="button" tabindex="0" title="Connect ChatGPT">${ico("ai")}ChatGPT</a>`}${(d.google || {}).connected ? `<span class="schip on" title="Google connected">${ico("google")}Google<a id="gg-out" role="button" tabindex="0" aria-label="Disconnect Google" title="Disconnect">${ico("x")}</a></span>` : `<a id="gg-in" class="schip" role="button" tabindex="0" title="Connect Google">${ico("google")}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>`;
301
389
  // First visit: offer the ChatGPT plan for social sources, or keep the agent.
302
390
  const onb =
303
391
  G.use === "ask" && !G.connected
304
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>`
305
393
  : "";
394
+ // A rebuild replaces the drawer: keep where it was scrolled to.
395
+ const at = $("src-sec")?.querySelector(".body")?.scrollTop ?? 0;
306
396
  $("pbody").innerHTML =
307
397
  d && d.error
308
398
  ? `<p style="color:var(--dim)">${esc(d.error)}</p>`
@@ -310,6 +400,8 @@ export async function openSources() {
310
400
  ? `<p style="color:var(--dim)">The icebox could not be read.</p>`
311
401
  : onb + bar + bulk + fails + inbox + sec;
312
402
  if (!d || d.error) return;
403
+ const body = $("src-sec")?.querySelector(".body");
404
+ if (body) body.scrollTop = at;
313
405
  if (iceDraft && iceDraft[0] === iceForm && $("ice-title")) {
314
406
  $("ice-title").value = iceDraft[1];
315
407
  $("ice-details").value = iceDraft[2];
@@ -413,16 +505,14 @@ export async function openSources() {
413
505
  srcShow = true;
414
506
  openSources().then(() => srcShow && $("src-url")?.focus());
415
507
  };
416
- if ($("ice-fs"))
417
- $("ice-fs").onchange = (e) => {
418
- iceSrc = e.target.value;
419
- openSources();
420
- };
421
- if ($("ice-fc"))
422
- $("ice-fc").onchange = (e) => {
423
- iceCat = e.target.value;
424
- openSources();
425
- };
508
+ wirePick("ice-fs", (v) => {
509
+ iceSrc = v;
510
+ openSources();
511
+ });
512
+ wirePick("ice-fc", (v) => {
513
+ iceCat = v;
514
+ openSources();
515
+ });
426
516
  if ($("ice-unf"))
427
517
  $("ice-unf").onclick = () => {
428
518
  iceCat = iceSrc = "";
package/web/board/main.ts CHANGED
@@ -10,6 +10,7 @@ import { initProjects } from "./projects.ts";
10
10
  import { initUpdate } from "./update.ts";
11
11
  import { initKeys } from "./keys.ts";
12
12
  import { initLive } from "./live.ts";
13
+ import { store } from "./store.ts";
13
14
 
14
15
  initPanel();
15
16
  initViews();
@@ -17,3 +18,29 @@ initProjects();
17
18
  initUpdate();
18
19
  initKeys();
19
20
  initLive();
21
+
22
+ // One popup at a time: a press anywhere first shuts every open popup it is not inside,
23
+ // before any handler runs (several stop propagation), so the one it opens is the only one
24
+ // left. Combos hide theirs, a <details> menu closes, a card's or column's menu goes away.
25
+ document.addEventListener(
26
+ "pointerdown",
27
+ (e) => {
28
+ const t = e.target as Node;
29
+ for (const p of document.querySelectorAll<HTMLElement>(".menu-pop:not([hidden])")) {
30
+ const own = p.closest(".combo,details") || p.parentElement;
31
+ if (!own || own.contains(t)) continue;
32
+ if (own instanceof HTMLDetailsElement) own.open = false;
33
+ else if (own.classList.contains("combo")) {
34
+ p.hidden = true;
35
+ own.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "false");
36
+ } else {
37
+ // Drawn by render() from store: drop it now without a redraw, which would swallow this click.
38
+ p.remove();
39
+ own.classList.remove("menu");
40
+ store.menuKey = null;
41
+ store.clearMenu = false;
42
+ }
43
+ }
44
+ },
45
+ true,
46
+ );
@@ -8,7 +8,7 @@ import { stopDictation } from "./dictation.ts";
8
8
  import { $, esc } from "./dom.ts";
9
9
  import { fixForm, fixOff } from "./fixes.ts";
10
10
  import { commitPush, confirmCommit, gitBtn } from "./git.ts";
11
- import { WORD, need } from "./labels.ts";
11
+ import { ICON, WORD, need } from "./labels.ts";
12
12
  import { attach, brief, md, planQuestions, preview, progress } from "./markdown.ts";
13
13
  import { toast } from "./toast.ts";
14
14
  import { go, view } from "./views.ts";
@@ -25,6 +25,18 @@ const TABS = [
25
25
  ["Preview", "preview"],
26
26
  ];
27
27
  // keepTab: a link already chose the tab.
28
+ const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
29
+ // "2026-10-18-board-ui-revision" or "board-ui-revision" reads "Board UI revision".
30
+ const readable = (t) =>
31
+ /\s/.test(t)
32
+ ? t
33
+ : cap(
34
+ t
35
+ .replace(/^\d{4}-\d{2}-\d{2}-/, "")
36
+ .split(/[-_]/)
37
+ .map((w) => (/^(ui|ux|api|cli|ai|ci|id|url|pr|tui|ios|pwa)$/i.test(w) ? w.toUpperCase() : w))
38
+ .join(" "),
39
+ );
28
40
  export function open(c, keepTab) {
29
41
  if (view !== "board") go("board", true);
30
42
  stopDictation(true); // the panel body is about to be replaced
@@ -32,12 +44,14 @@ export function open(c, keepTab) {
32
44
  store.openKey = key(c);
33
45
  $("panel").classList.add("open");
34
46
  $("scrim").classList.add("on");
35
- $("ptitle").textContent = c.kind === "idea" ? brief(c.text).head : c.text;
47
+ // A run is titled as its card is, its slug made readable; the id stays in the tooltip.
48
+ $("ptitle").textContent = c.kind === "idea" ? brief(c.text).head : readable(c.title || c.text);
49
+ $("ptitle").title = c.kind === "idea" ? "" : c.id || "";
36
50
  render();
37
51
  $("pterm").hidden = true;
38
52
  if (c.kind === "idea") {
39
53
  store.runDoc = null;
40
- $("psub").textContent = "Idea · " + c.where;
54
+ $("psub").innerHTML = `<span class="pst"><span class="st d-idea"><svg><use href="#s-idea"/></svg></span>Idea</span><span class="pst dim">${esc(c.where.split(/[\\/]/).pop() || c.where)}</span>`;
41
55
  $("ptabs").innerHTML = "";
42
56
  ideaBody(c);
43
57
  const foot = $("pfoot");
@@ -113,7 +127,11 @@ export async function openRun(c, refresh, again) {
113
127
  // A refresh while notes are being typed would wipe the textarea.
114
128
  const ta = $("pbody").querySelector(".notes textarea");
115
129
  if (refresh && ta && document.activeElement === ta) return;
116
- $("psub").textContent = c.id + " · " + (WORD[c.status] || c.status) + (c.awaiting ? " · " + need(c.awaiting) : "");
130
+ // The board's own status mark, then what it waits on: chips, not a dotted line.
131
+ $("psub").innerHTML =
132
+ `<span class="pst"><span class="st d-${ICON[c.status] || "idea"}"><svg><use href="#s-${ICON[c.status] || "idea"}"/></svg></span>${esc(cap(WORD[c.status] || c.status))}</span>` +
133
+ (c.awaiting ? `<span class="pst dim">${esc(cap(need(c.awaiting)))}</span>` : "") +
134
+ (c.parent ? `<span class="pst link" title="Filed by this run">${esc(c.parent)}</span>` : "");
117
135
  // Only tabs with something in them show. A card whose agent runs in the board gets its
118
136
  // terminal behind the header's terminal button, chosen once right after a launch from here.
119
137
  const tabs = TABS.filter(
@@ -1,7 +1,7 @@
1
1
  import { store } from "./store.ts";
2
2
  import { load, post } from "./api.ts";
3
3
  import { render } from "./cards.ts";
4
- import { $, esc } from "./dom.ts";
4
+ import { $, esc, ico } from "./dom.ts";
5
5
  import { openSources, srcOpen } from "./icebox.ts";
6
6
  import { toast } from "./toast.ts";
7
7
  import { view } from "./views.ts";
@@ -17,6 +17,16 @@ export function keep(d) {
17
17
  }
18
18
  // A project shows by its folder name alone; the full path stays in the tooltip.
19
19
  export const short = (l) => l.split(/[\\/]/).pop() || l;
20
+ // A project's mark in every picker: its own logo when /state found one, else its initial.
21
+ // A logo the browser cannot draw falls back to the initial too.
22
+ export const plogo = (dir, label = "") => {
23
+ const ini = `<span class="plogo" aria-hidden="true">${esc((short(label) || "?").charAt(0).toUpperCase())}</span>`;
24
+ return store.S?.projects?.some((p) => p.dir === dir && p.logo)
25
+ ? `<img class="plogo" src="/project-logo?dir=${encodeURIComponent(dir)}" alt="" loading="lazy" onerror="this.outerHTML=this.dataset.f" data-f="${esc(ini)}">`
26
+ : ini;
27
+ };
28
+ // A picker row: the mark, then the folder name; the full path in the tooltip.
29
+ const prow = (dir, label) => `${plogo(dir, label)}<span>${esc(short(label))}</span>`;
20
30
  // The project picker: a button shaped like a select that opens a search field over the list.
21
31
  export function list() {
22
32
  const q = $("projq").value.trim().toLowerCase();
@@ -25,7 +35,7 @@ export function list() {
25
35
  store.PL.map((p, i) =>
26
36
  p[0].toLowerCase().includes(q)
27
37
  ? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
28
- `<button role="option" data-i="${i}" title="${esc(p[0])}">${esc(short(p[0]))}</button>`
38
+ `<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__find" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
29
39
  : "",
30
40
  ).join("") || `<div class="grp">No match</div>`;
31
41
  const f = $("projs").querySelector("button");
@@ -71,7 +81,8 @@ export function ideaCombo(f) {
71
81
  box = f.querySelector("#nds");
72
82
  if (!ps.some((p) => p.dir === newDir)) newDir = ps[0]?.dir ?? null;
73
83
  const name = () => {
74
- btn.textContent = short((ps.find((p) => p.dir === newDir) || { label: "Choose project" }).label);
84
+ const p = ps.find((p) => p.dir === newDir);
85
+ btn.innerHTML = p ? prow(p.dir, p.label) : "Choose project";
75
86
  };
76
87
  const draw = () => {
77
88
  const t = q.value.trim().toLowerCase();
@@ -80,7 +91,7 @@ export function ideaCombo(f) {
80
91
  .filter((p) => p.label.toLowerCase().includes(t))
81
92
  .map(
82
93
  (p) =>
83
- `<button type="button" role="option" data-d="${esc(p.dir)}" title="${esc(p.label)}" class="${p.dir === newDir ? "on" : ""}">${esc(short(p.label))}</button>`,
94
+ `<button type="button" role="option" data-d="${esc(p.dir)}" title="${esc(p.label)}" class="${p.dir === newDir ? "on" : ""}">${prow(p.dir, p.label)}</button>`,
84
95
  )
85
96
  .join("") || `<div class="grp">No match</div>`;
86
97
  box.querySelectorAll("button").forEach(
@@ -136,9 +147,11 @@ export function showList() {
136
147
  $("shows").innerHTML =
137
148
  SP.map((p, i) =>
138
149
  p.label.toLowerCase().includes(q)
139
- ? `<label title="${esc(p.label)}"><input type="checkbox" data-i="${i}" ${store.shown.has(p.dir) ? "checked" : ""}>${esc(short(p.label))}</label>`
150
+ ? `<button type="button" role="menuitemcheckbox" data-i="${i}" aria-checked="${store.shown.has(p.dir)}" title="${esc(p.label)}">${prow(p.dir, p.label)}<svg class="ck"><use href="#i-check"/></svg></button>`
140
151
  : "",
141
152
  ).join("") || `<div class="grp">No match</div>`;
153
+ const n = SP.filter((p) => store.shown.has(p.dir)).length;
154
+ $("showcount").textContent = n ? `${n} of ${SP.length} shown` : `All ${SP.length} shown`;
142
155
  }
143
156
  export function setShown() {
144
157
  try {
@@ -196,12 +209,13 @@ export function initProjects() {
196
209
  $("show").focus();
197
210
  }
198
211
  };
199
- // render() redraws the list; the ticked box keeps keyboard focus.
200
- $("shows").onchange = (e) => {
201
- const i = e.target.dataset.i,
212
+ // render() redraws the list; the toggled row keeps keyboard focus.
213
+ $("shows").onclick = (e) => {
214
+ const b = e.target.closest("button[data-i]"),
215
+ i = b?.dataset.i,
202
216
  d = SP[i]?.dir;
203
217
  if (!d) return;
204
- e.target.checked ? store.shown.add(d) : store.shown.delete(d);
218
+ store.shown.has(d) ? store.shown.delete(d) : store.shown.add(d);
205
219
  setShown();
206
220
  $("shows").querySelector(`[data-i="${i}"]`)?.focus();
207
221
  };
@@ -47,7 +47,14 @@ export function updBar() {
47
47
  }
48
48
  const d = document.createElement("div");
49
49
  d.className = "updbar";
50
- d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><button class="btn pri" id="upd-go">Update kaizen</button>`;
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>`;
51
58
  $("pbody").prepend(d);
52
59
  const b = $("upd-go");
53
60
  b.onclick = async () => {