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.
- package/cli/logo.test.ts +18 -0
- package/cli/logo.ts +20 -0
- package/cli/web.ts +12 -1
- package/package.json +1 -1
- package/web/board/cards.ts +7 -4
- package/web/board/icebox.ts +131 -42
- package/web/board/main.ts +27 -0
- package/web/board/panel.ts +21 -4
- package/web/board/projects.ts +23 -9
- package/web/board.html +60 -29
- package/web/board.js +23 -23
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/logo.test.ts
ADDED
|
@@ -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
package/web/board/cards.ts
CHANGED
|
@@ -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").
|
|
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="
|
|
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 = [];
|
package/web/board/icebox.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
const pick = (id, label,
|
|
237
|
-
`<
|
|
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(
|
|
242
|
-
|
|
243
|
-
|
|
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(
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
);
|
package/web/board/panel.ts
CHANGED
|
@@ -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
|
-
|
|
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").
|
|
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
|
-
|
|
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(
|