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.
- 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 +6 -3
- package/web/board/icebox.ts +115 -29
- 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 +57 -28
- 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;
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
const pick = (id, label,
|
|
240
|
-
`<
|
|
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(
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
251
|
-
|
|
252
|
-
|
|
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="
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
+
);
|
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(
|
package/web/board/projects.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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" : ""}">${
|
|
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
|
-
? `<
|
|
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
|
|
200
|
-
$("shows").
|
|
201
|
-
const
|
|
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
|
-
|
|
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
|
};
|