kaizen-agent 1.34.11 → 1.35.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.
@@ -0,0 +1,18 @@
1
+ import { test, expect } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { projectLogo } from "./logo.ts";
6
+
7
+ test("projectLogo finds a fixed-name logo and nothing else", () => {
8
+ const root = mkdtempSync(join(tmpdir(), "kz-logo-"));
9
+ mkdirSync(join(root, ".kaizen"));
10
+ expect(projectLogo(join(root, ".kaizen"))).toBeNull();
11
+ writeFileSync(join(root, "brand.png"), "x");
12
+ expect(projectLogo(join(root, ".kaizen"))).toBeNull();
13
+ mkdirSync(join(root, "public"));
14
+ writeFileSync(join(root, "public", "favicon.svg"), "<svg/>");
15
+ expect(projectLogo(join(root, ".kaizen"))).toBe(join(root, "public", "favicon.svg"));
16
+ writeFileSync(join(root, "logo.png"), "x");
17
+ expect(projectLogo(join(root, ".kaizen"))).toBe(join(root, "logo.png"));
18
+ });
package/cli/logo.ts ADDED
@@ -0,0 +1,20 @@
1
+ // A project's logo, when it has one: the first of a fixed list of files in the project
2
+ // folder (the parent of its .kaizen). Fixed names only, so the board never serves a path
3
+ // a request chose.
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ export const LOGO_TYPES: Record<string, string> = { png: "image/png", svg: "image/svg+xml", jpg: "image/jpeg", webp: "image/webp", ico: "image/x-icon" };
8
+ const NAMES = ["logo", "icon", "favicon"];
9
+ const DIRS = ["", "assets", "public", "static", ".github", "docs", "web"];
10
+
11
+ export function projectLogo(stateDir: string): string | null {
12
+ const root = dirname(stateDir);
13
+ for (const d of DIRS)
14
+ for (const n of NAMES)
15
+ for (const e of Object.keys(LOGO_TYPES)) {
16
+ const f = join(root, d, `${n}.${e}`);
17
+ try { if (existsSync(f) && statSync(f).isFile() && statSync(f).size < 2_000_000) return f; } catch {}
18
+ }
19
+ return null;
20
+ }
package/cli/web.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "./state.ts";
12
12
  import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
13
  import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
14
+ import { projectLogo, LOGO_TYPES } from "./logo.ts";
14
15
  import * as chatgpt from "./chatgpt.ts";
15
16
  import * as google from "./google.ts";
16
17
  import { writeBacks } from "./writeback.ts";
@@ -370,6 +371,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
370
371
  return new Response(Bun.file(LOGO), { headers: { "content-type": "image/png", "cache-control": "max-age=86400" } });
371
372
  }
372
373
 
374
+ // A project's own logo, for the project pickers: only a known project, only the fixed
375
+ // names in LOGOS, sandboxed so an SVG opened directly runs nothing on this origin.
376
+ if (req.method === "GET" && path === "/project-logo") {
377
+ const dir = url.searchParams.get("dir") ?? "";
378
+ const f = states(true).includes(dir) ? projectLogo(dir) : null;
379
+ if (!f) return bad("no logo", 404);
380
+ const ext = f.slice(f.lastIndexOf(".") + 1).toLowerCase();
381
+ return new Response(Bun.file(f), { headers: { "content-type": LOGO_TYPES[ext], "content-security-policy": "sandbox", "x-content-type-options": "nosniff", "cache-control": "max-age=300" } });
382
+ }
383
+
373
384
  // Lets a phone add the board to its home screen as a standalone app.
374
385
  if (req.method === "GET" && path === "/manifest.webmanifest") {
375
386
  return new Response(JSON.stringify({
@@ -408,7 +419,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
408
419
  marks.set(d, cardsGit(d, cards.filter((c) => markable(c) && c.state === d).map((c) => c.id!)));
409
420
  return json({
410
421
  project, all, dir: state,
411
- projects: picker.map((d) => ({ dir: d, label: label(d) })),
422
+ projects: picker.map((d) => ({ dir: d, label: label(d), logo: !!projectLogo(d) })),
412
423
  fresh: unset.map((d) => ({ dir: join(d, ".kaizen"), label: tilde(d) })),
413
424
  cards: cards.map((c) => ({ ...c, notice: notice(c), git: markable(c) ? marks.get(c.state)?.[c.id!] : undefined, pty: ptyOf(c) })),
414
425
  now: Date.now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.34.11",
3
+ "version": "1.35.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,7 +7,7 @@ import { draft, undraft } from "./drafts.ts";
7
7
  import { COLS, ICON, MARKS, NAMES, WORD, age, d, need } from "./labels.ts";
8
8
  import { brief } from "./markdown.ts";
9
9
  import { open, toggleArchive } from "./panel.ts";
10
- import { ideaCombo, list, newDir, short, showList, showable } from "./projects.ts";
10
+ import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
12
 
13
13
  let filter = null;
@@ -227,7 +227,7 @@ function inlineForm(c) {
227
227
  }
228
228
  if (store.mode === "move") {
229
229
  const ps = store.S.projects.filter((p) => p.dir !== dir);
230
- li.innerHTML = `<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map((p) => `<option value="${esc(p.dir)}">${esc(p.label)}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
230
+ li.innerHTML = `<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map((p) => `<option value="${esc(p.dir)}" title="${esc(p.label)}">${esc(short(p.label))}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
231
231
  li.querySelector("[data-a=s]").onclick = async () => {
232
232
  const sel = li.querySelector("select"),
233
233
  to = sel.value,
@@ -400,7 +400,10 @@ export function render() {
400
400
  ["Find projects on this PC…", "__find"],
401
401
  ];
402
402
  const cur = store.all || !store.S.dir ? "All projects" : (store.PL.find((p) => p[1] === store.S.dir) || ["All projects"])[0];
403
- $("proj").textContent = short(cur);
403
+ $("proj").innerHTML =
404
+ store.all || !store.S.dir
405
+ ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(cur)}</span>`
406
+ : `${plogo(store.S.dir, cur)}<span>${esc(short(cur))}</span>`;
404
407
  $("proj").title = cur;
405
408
  if (!$("projpop").hidden) list();
406
409
  $("showc").hidden = !store.all;
@@ -507,7 +510,7 @@ export function render() {
507
510
  if (ci === 0 && store.mode === "new") {
508
511
  const f = document.createElement("li");
509
512
  f.className = "inl";
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>`;
513
+ 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="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add</button></div>`;
511
514
  ul.appendChild(f);
512
515
  const inp = f.querySelector("#newidea"),
513
516
  files = [];
@@ -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
 
@@ -31,6 +31,8 @@ const rStyle = (dir) => {
31
31
  };
32
32
  const STYLES = { casual: "Casual", fun: "Fun", helpful: "Helpful", promo: "Promo" };
33
33
  export let iceForm = null,
34
+ // The item whose promote form is showing: opened by its "+", never by opening the row.
35
+ icePro = null,
34
36
  iceCur = null,
35
37
  iceSrc = "",
36
38
  iceCat = "",
@@ -52,6 +54,60 @@ const ago = (at) => {
52
54
  ? Math.round(m / 60) + " h ago"
53
55
  : Math.round(m / 1440) + " d ago";
54
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
+ }
55
111
  export async function openSources() {
56
112
  if (view !== "sources") return;
57
113
  const gen = ++store.pageGen;
@@ -126,6 +182,8 @@ export async function openSources() {
126
182
  : h + "/favicon.ico";
127
183
  return `<span class="fav">${src ? `<img src="${esc(src)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">` : ""}${ico(fb)}</span>`;
128
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";
129
187
  const srcType = (s) => {
130
188
  if (s.social) return "social";
131
189
  const u = s.url;
@@ -144,10 +202,10 @@ export async function openSources() {
144
202
  // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
145
203
  const opts = (on, name) =>
146
204
  (on && !store.S.projects.some((p) => p.dir === on)
147
- ? `<option value="${esc(on)}" selected>${esc(name || on)}</option>`
205
+ ? `<option value="${esc(on)}" title="${esc(name || on)}" selected>${esc(short(name || on))}</option>`
148
206
  : "") +
149
207
  store.S.projects
150
- .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>`)
151
209
  .join("");
152
210
  const cards = list
153
211
  .map((s, i) => {
@@ -162,7 +220,9 @@ export async function openSources() {
162
220
  : s.last.ok
163
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>`
164
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>`;
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>`;
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>`;
166
226
  })
167
227
  .join("");
168
228
  const fails = ((d && d.failed) || [])
@@ -188,7 +248,14 @@ export async function openSources() {
188
248
  srcKey = (it) => it.dir + " " + it.source;
189
249
  // Filters narrow in turn: project (the board's own picker, all projects only), then
190
250
  // category, then source; each chip counts what the filters before it leave.
191
- 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 }),
192
259
  count = (xs, f) => {
193
260
  const m = new Map();
194
261
  xs.forEach((it) => {
@@ -213,6 +280,7 @@ export async function openSources() {
213
280
  if (iceSrc && !bySrc.has(iceSrc)) iceSrc = "";
214
281
  const keys = new Set(ice.map(iceKey));
215
282
  if (iceForm && !keys.has(iceForm)) iceForm = null;
283
+ if (icePro !== iceForm) icePro = null;
216
284
  const groups = new Map();
217
285
  ice.forEach((it, i) => {
218
286
  if (!inProj(it) || (iceCat ? catOf(it) !== iceCat : it.status === "dismissed" || it.status === "done") || (iceSrc && srcKey(it) !== iceSrc)) return;
@@ -231,23 +299,46 @@ export async function openSources() {
231
299
  const nSel = order.filter((i) => iceSel.has(iceKey(ice[i]))).length;
232
300
  // Filters are plain dropdowns, each shown only when it has a choice to make. Projects are
233
301
  // narrowed by the board's own Showing picker above, not here.
234
- const many = byProj.size > 1,
235
- opt = (v, on, t) => `<option value="${esc(v)}"${on ? " selected" : ""}>${esc(t)}</option>`;
236
- const pick = (id, label, xs) =>
237
- `<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;
238
317
  const chips =
239
318
  // Dismissed and Done are hidden until picked, so even a lone one is a choice.
240
319
  (byCat.size > 1 || byCat.has("Dismissed") || byCat.has("Done")
241
- ? pick("ice-fc", "Category", [
242
- opt("", !iceCat, "All categories"),
243
- ...[...byCat.keys()].map((k) => opt(k, iceCat === k, k)),
244
- ])
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
+ )
245
327
  : "") +
246
328
  (bySrc.size > 1
247
- ? pick("ice-fs", "Source", [
248
- opt("", !iceSrc, "All sources"),
249
- ...[...bySrc].map(([k, { it }]) => opt(k, iceSrc === k, srcName(it))),
250
- ])
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
+ )
251
342
  : "");
252
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>`;
253
344
  const bulk = iceSel.size
@@ -278,9 +369,9 @@ export async function openSources() {
278
369
  k = iceKey(it),
279
370
  open = iceForm === k;
280
371
  return (
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" 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 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>` +
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>` +
282
373
  (open
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>`
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>`
284
375
  : "")
285
376
  );
286
377
  };
@@ -294,7 +385,7 @@ export async function openSources() {
294
385
  // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
295
386
  const drawer = srcShow ?? !list.length,
296
387
  slide = drawer && !$("src-sec")?.classList.contains("open");
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>`;
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>`;
298
389
  // First visit: offer the ChatGPT plan for social sources, or keep the agent.
299
390
  const onb =
300
391
  G.use === "ask" && !G.connected
@@ -410,16 +501,14 @@ export async function openSources() {
410
501
  srcShow = true;
411
502
  openSources().then(() => srcShow && $("src-url")?.focus());
412
503
  };
413
- if ($("ice-fs"))
414
- $("ice-fs").onchange = (e) => {
415
- iceSrc = e.target.value;
416
- openSources();
417
- };
418
- if ($("ice-fc"))
419
- $("ice-fc").onchange = (e) => {
420
- iceCat = e.target.value;
421
- openSources();
422
- };
504
+ wirePick("ice-fs", (v) => {
505
+ iceSrc = v;
506
+ openSources();
507
+ });
508
+ wirePick("ice-fc", (v) => {
509
+ iceCat = v;
510
+ openSources();
511
+ });
423
512
  if ($("ice-unf"))
424
513
  $("ice-unf").onclick = () => {
425
514
  iceCat = iceSrc = "";
@@ -522,7 +611,7 @@ export async function openSources() {
522
611
  stepPast(new Set([iceKey(it)]));
523
612
  const j = await post("/icebox", { op, dir: it.dir, ids: [it.id], ...extra });
524
613
  if (j.ok) {
525
- if (op === "promote" && iceForm === iceKey(it)) iceForm = null;
614
+ if (op === "promote" && iceForm === iceKey(it)) iceForm = icePro = null;
526
615
  toast(msg);
527
616
  load();
528
617
  }
@@ -587,13 +676,8 @@ export async function openSources() {
587
676
  const k = iceKey(ice[i]);
588
677
  iceCur = k;
589
678
  iceForm = iceForm === k ? null : k;
590
- openSources().then(() => {
591
- const t = $("ice-title");
592
- if (t && iceForm === k) {
593
- t.focus();
594
- t.select();
595
- } else $("ice-o-" + i)?.focus();
596
- });
679
+ icePro = null;
680
+ openSources().then(() => $("ice-o-" + i)?.focus());
597
681
  };
598
682
  $("pbody")
599
683
  .querySelectorAll(".ice-row")
@@ -615,8 +699,12 @@ export async function openSources() {
615
699
  .forEach(
616
700
  (b) =>
617
701
  (b.onclick = () => {
618
- b.disabled = true;
619
- iceAct("promote", ice[+b.dataset.pr], "Promoted to Ideas");
702
+ const k = iceKey(ice[+b.dataset.pr]);
703
+ iceCur = iceForm = icePro = k;
704
+ openSources().then(() => {
705
+ $("ice-title")?.focus();
706
+ $("ice-title")?.select();
707
+ });
620
708
  }),
621
709
  );
622
710
  $("pbody")
@@ -716,7 +804,7 @@ export async function openSources() {
716
804
  };
717
805
  $("ice-ok").onclick = ok;
718
806
  $("ice-cancel").onclick = () => {
719
- iceForm = null;
807
+ icePro = null;
720
808
  openSources();
721
809
  };
722
810
  $("ice-title").onkeydown = (e) => {
@@ -740,7 +828,8 @@ export async function openSources() {
740
828
  return true;
741
829
  }
742
830
  if (iceForm) {
743
- iceForm = null;
831
+ if (icePro) icePro = null;
832
+ else iceForm = null;
744
833
  openSources();
745
834
  return true;
746
835
  }
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,10 @@ 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>` : "");
117
134
  // Only tabs with something in them show. A card whose agent runs in the board gets its
118
135
  // terminal behind the header's terminal button, chosen once right after a launch from here.
119
136
  const tabs = TABS.filter(