kaizen-agent 1.34.12 → 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.12",
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;
@@ -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,7 +385,7 @@ 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
@@ -413,16 +501,14 @@ export async function openSources() {
413
501
  srcShow = true;
414
502
  openSources().then(() => srcShow && $("src-url")?.focus());
415
503
  };
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
- };
504
+ wirePick("ice-fs", (v) => {
505
+ iceSrc = v;
506
+ openSources();
507
+ });
508
+ wirePick("ice-fc", (v) => {
509
+ iceCat = v;
510
+ openSources();
511
+ });
426
512
  if ($("ice-unf"))
427
513
  $("ice-unf").onclick = () => {
428
514
  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,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(
@@ -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
  };