kaizen-agent 1.42.2 → 1.43.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.
@@ -2,11 +2,11 @@ import { store } from "./store.ts";
2
2
  import { where } from "./agent-term.ts";
3
3
  import { load, post } from "./api.ts";
4
4
  import { mic, stopDictation } from "./dictation.ts";
5
- import { $, dataUrls, esc, ico } from "./dom.ts";
5
+ import { $, dataUrls, html, ico, modal, raw } from "./dom.ts";
6
6
  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
- import { open, toggleArchive } from "./panel.ts";
9
+ import { open, openAt, toggleArchive } from "./panel.ts";
10
10
  import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
12
  import { answer, decided, resumable, resume } from "./approvals.ts";
@@ -25,6 +25,41 @@ let order = (() => {
25
25
  }
26
26
  })();
27
27
  let archive = false; // showing the archive instead of the board
28
+ // The Ideas column shows ideas or the runs' open backlog, never both; kept per browser.
29
+ let ideasTab = (() => {
30
+ try {
31
+ return localStorage.getItem("kz-ideas-tab") === "backlog" ? "backlog" : "ideas";
32
+ } catch (e) {
33
+ return "ideas";
34
+ }
35
+ })();
36
+ // On the Backlog tab: one severity, or null for every one.
37
+ let sev = null;
38
+ const SEVS = [
39
+ ["high", "High+", (s) => s === "critical" || s === "high"],
40
+ ["medium", "Medium", (s) => s === "medium"],
41
+ ["low", "Low", (s) => s === "low"],
42
+ ["none", "No severity", (s) => !s],
43
+ ];
44
+ // Open backlog items as cards for the Ideas column. A card's key names its line by
45
+ // source and number, which is what Start and Reject send back.
46
+ const backlogList = () =>
47
+ (store.S.backlog || []).map((b) => ({
48
+ ...b,
49
+ kind: "backlog",
50
+ status: b.starting ? "starting" : "idea",
51
+ title: b.text,
52
+ column: 0,
53
+ archived: false,
54
+ _k: "backlog:" + b.state + ":" + b.source + "#" + b.n,
55
+ }));
56
+ // A backlog card opens its source run on the Backlog tab, the item marked.
57
+ const show = (c) => {
58
+ if (c.kind !== "backlog") return open(c);
59
+ const r = store.S.cards.find((x) => x.kind === "run" && x.id === c.source && x.state === c.state);
60
+ if (!r) return toast(c.source === "orphan" ? "Kept from a run that was pruned" : "That run is not on this board");
61
+ openAt(r, "backlog", c.n);
62
+ };
28
63
  // Clear on Done: menu, then cherry-pick mode.
29
64
  let picking = false;
30
65
  const picked = new Set();
@@ -62,17 +97,17 @@ function card(c) {
62
97
  el.dataset.k = k;
63
98
  el.setAttribute("role", "button");
64
99
  const st = ICON[c.status] || "idea",
65
- idt = c.kind === "idea" ? "" : c.text.replace(/-/g, " ");
100
+ idt = c.kind === "run" ? c.text.replace(/-/g, " ") : "";
66
101
  // The status icon carries the state; what a waiting run needs is its tooltip.
67
102
  const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
68
103
  // Footer: words on the left (project, what it needs), icon-only marks on the right.
69
104
  const chips = [],
70
105
  marks = [],
71
106
  mk = (id, t, cls = "") =>
72
- `<span class="mk${cls}" role="img" title="${esc(t)}" aria-label="${esc(t)}"><svg><use href="#${id}"/></svg></span>`;
107
+ html`<span class="mk${cls}" role="img" title="${t}" aria-label="${t}"><svg><use href="#${id}"/></svg></span>`;
73
108
  if (store.all) {
74
109
  const n = c.where.split("/").filter(Boolean).pop() || c.where;
75
- chips.push(`<span class="chip proj" title="${esc(c.where)}">${esc(n)}</span>`);
110
+ chips.push(html`<span class="chip proj" title="${c.where}">${n}</span>`);
76
111
  }
77
112
  for (const t of c.tags || []) {
78
113
  if (MARKS[t])
@@ -84,8 +119,8 @@ function card(c) {
84
119
  ),
85
120
  );
86
121
  else if (t === "yolo")
87
- marks.push(`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
88
- else chips.push(`<span class="chip">${esc(NAMES[t] || t)}</span>`);
122
+ marks.push(html`<span class="mk warn" title="Started in Yolo: the agent runs commands without asking">yolo</span>`);
123
+ else chips.push(html`<span class="chip">${NAMES[t] || t}</span>`);
89
124
  }
90
125
  if (c.pty && c.pty.alive && c.pty.asking)
91
126
  marks.push(mk("i-ask", "The agent is asking you something: open it, terminal button at the top", " warn ask"));
@@ -96,10 +131,19 @@ function card(c) {
96
131
  const g = c.notes && /^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. Outside text/.exec(c.notes);
97
132
  // A card filed by a run names that run; the parent itself shows nothing.
98
133
  if (c.parent)
99
- chips.push(`<span class="chip link" title="Filed by ${esc(c.parent)}">${esc(c.parent.replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " "))}</span>`);
100
- if (c.branch) chips.push(`<span class="chip" title="Built on its own branch, ${esc(c.branch)}">branch</span>`);
101
- if (g) chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);
102
- else if (c.notes) chips.push(`<span class="chip">notes</span>`);
134
+ chips.push(html`<span class="chip link" title="Filed by ${c.parent}">${c.parent.replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")}</span>`);
135
+ if (c.kind === "backlog") {
136
+ if (c.severity)
137
+ chips.push(html`<span class="chip${c.severity === "critical" || c.severity === "high" ? " warn" : ""}"><i></i>${c.severity}</span>`);
138
+ chips.push(
139
+ c.source === "orphan"
140
+ ? html`<span class="chip" title="Kept from a pruned run">orphaned</span>`
141
+ : html`<span class="chip link" title="Left by ${c.source}${c.at ? " · " + c.at : ""}">${c.source.replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")}</span>`,
142
+ );
143
+ }
144
+ if (c.branch) chips.push(html`<span class="chip" title="Built on its own branch, ${c.branch}">branch</span>`);
145
+ if (g) chips.push(html`<span class="chip" title="From ${g[1]}">gathered</span>`);
146
+ else if (c.notes) chips.push(html`<span class="chip">notes</span>`);
103
147
  // Information only: the panel's button is where it is committed and pushed.
104
148
  if (c.git)
105
149
  marks.push(
@@ -117,17 +161,17 @@ function card(c) {
117
161
  const act =
118
162
  c.kind === "run" && !archive && !pick
119
163
  ? c.status === "waiting"
120
- ? `<button class="btn sm${decided.has(answer(c)) ? "" : " pri"} act" data-a="open">${actionOf(c)}</button>`
164
+ ? html`<button class="btn sm${decided.has(answer(c)) ? "" : " pri"} act" data-a="open">${actionOf(c)}</button>`
121
165
  : c.status === "stalled" && resumable(c)
122
- ? `<button class="btn sm act" data-a="resume">Resume</button>`
166
+ ? html`<button class="btn sm act" data-a="resume">Resume</button>`
123
167
  : ""
124
168
  : "";
125
169
  el.innerHTML =
126
- (pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
127
- `<div class="c-id"><span class="st d-${st}" role="img" title="${esc(sttl)}" aria-label="${esc(sttl)}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind === "run" ? age(c.moved || store.S.now, store.S.now) : ""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k === store.menuKey}">${ico("more")}</button></div>
128
- <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${esc(c.text)}"` : ""}>${esc(c.kind === "idea" ? brief(c.text).head : c.title || c.id)}</div>
170
+ (pick ? html`<input type="checkbox" class="pick" ${raw(picked.has(k) ? "checked" : "")} aria-label="Select">` : "") +
171
+ html`<div class="c-id"><span class="st d-${st}" role="img" title="${sttl}" aria-label="${sttl}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${idt}</span><span class="age">${c.kind === "run" ? age(c.moved || store.S.now, store.S.now) : ""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${String(k === store.menuKey)}">${ico("more")}</button></div>
172
+ <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? html` title="${c.text}"` : ""}>${c.kind === "idea" ? brief(c.text).head : c.title || c.id}</div>
129
173
  ${act}
130
- ${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
174
+ ${chips.length || marks.length ? html`<div class="c-foot"><span class="c-chips">${chips}</span><span class="c-marks">${marks}</span></div>` : ""}`;
131
175
  const kids = kidsOf(c);
132
176
  if (kids.length) {
133
177
  const on = kidsOpen.has(k),
@@ -136,14 +180,13 @@ function card(c) {
136
180
  const box = document.createElement("div");
137
181
  box.className = "kids";
138
182
  box.innerHTML =
139
- `<button class="kids-t" aria-expanded="${on}">${on ? "▾" : "▸"} ${kids.length} ${kids.length === 1 ? "child" : "children"}${nDone ? ` · ${nDone} done` : ""}${wait ? `<span class="kids-w" title="A child is waiting on you"></span>` : ""}</button>` +
183
+ html`<button class="kids-t" aria-expanded="${String(on)}">${on ? "▾" : "▸"} ${kids.length} ${kids.length === 1 ? "child" : "children"}${nDone ? ` · ${nDone} done` : ""}${wait ? html`<span class="kids-w" title="A child is waiting on you"></span>` : ""}</button>` +
140
184
  (on
141
- ? `<ul>${kids
185
+ ? html`<ul>${kids
142
186
  .map((x, i) => {
143
187
  const s = ICON[x.status] || "idea";
144
- return `<li data-i="${i}" tabindex="0" role="button" title="Open"><span class="st d-${s}"><svg><use href="#s-${s}"/></svg></span><span class="kids-s">${esc(WORD[x.status] || x.status)}</span><span class="kids-n" title="${esc(x.title || x.text || x.id)}">${esc(x.kind === "idea" ? brief(x.text).head : (x.text || x.id || "").replace(/-/g, " "))}</span></li>`;
145
- })
146
- .join("")}</ul>`
188
+ return html`<li data-i="${i}" tabindex="0" role="button" title="Open"><span class="st d-${s}"><svg><use href="#s-${s}"/></svg></span><span class="kids-s">${WORD[x.status] || x.status}</span><span class="kids-n" title="${x.title || x.text || x.id}">${x.kind === "idea" ? brief(x.text).head : (x.text || x.id || "").replace(/-/g, " ")}</span></li>`;
189
+ })}</ul>`
147
190
  : "");
148
191
  box.querySelector(".kids-t").onclick = (e) => {
149
192
  e.stopPropagation();
@@ -166,6 +209,13 @@ function card(c) {
166
209
  items.push(["restore", "Restore", ico("restore")]);
167
210
  items.push(["open", "Open", ico("open")]);
168
211
  if (c.kind === "idea") items.push(null, ["delete", "Delete", ico("trash"), "danger"]);
212
+ } else if (c.kind === "backlog") {
213
+ items.push(
214
+ ["run", c.status === "starting" ? "Run again" : "Run", ico("play")],
215
+ ["open", "Open its run", ico("open")],
216
+ null,
217
+ ["reject", "Reject", ico("x"), "danger"],
218
+ );
169
219
  } else if (c.kind === "idea" && c.status === "idea") {
170
220
  items.push(
171
221
  ["run", "Run", ico("play")],
@@ -191,7 +241,7 @@ function card(c) {
191
241
  items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
192
242
  }
193
243
  }
194
- el.innerHTML += `<div class="menu-pop" role="menu">${items.map((i) => (i ? `<button role="menuitem" data-a="${i[0]}" class="${i[3] || ""}">${i[2]}${i[1]}</button>` : "<hr>")).join("")}</div>`;
244
+ el.innerHTML += html`<div class="menu-pop" role="menu">${items.map((i) => (i ? html`<button role="menuitem" data-a="${i[0]}" class="${i[3] || ""}">${i[2]}${i[1]}</button>` : raw("<hr>")))}</div>`;
195
245
  }
196
246
  el.querySelectorAll("[data-a]").forEach(
197
247
  (bt) =>
@@ -206,7 +256,7 @@ function card(c) {
206
256
  return;
207
257
  }
208
258
  if (a === "open") {
209
- open(c);
259
+ show(c);
210
260
  return;
211
261
  }
212
262
  if (a === "resume") {
@@ -236,12 +286,12 @@ function card(c) {
236
286
  render();
237
287
  return;
238
288
  }
239
- open(c);
289
+ show(c);
240
290
  };
241
291
  el.onkeydown = (e) => {
242
292
  if (e.key === "Enter" || e.key === " ") {
243
293
  e.preventDefault();
244
- open(c);
294
+ show(c);
245
295
  }
246
296
  };
247
297
  return el;
@@ -252,7 +302,7 @@ function inlineForm(c) {
252
302
  li.className = "inl";
253
303
  const dir = c.state;
254
304
  if (store.mode === "edit") {
255
- li.innerHTML = `<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes || "")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
305
+ li.innerHTML = html`<input value="${c.text}"><textarea placeholder="Notes: links, paths, background">${c.notes || ""}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
256
306
  li.querySelector("[data-a=s]").onclick = async () => {
257
307
  const next = li.querySelector("input").value.trim(),
258
308
  notes = li.querySelector("textarea").value.trim();
@@ -263,8 +313,8 @@ function inlineForm(c) {
263
313
  };
264
314
  }
265
315
  if (store.mode === "reject") {
266
- const idea = c.kind === "idea";
267
- li.innerHTML = `<div class="q">${idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
316
+ const idea = c.kind !== "run";
317
+ li.innerHTML = html`<div class="q">${c.kind === "backlog" ? "Not doing this backlog item?" : idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
268
318
  li.querySelector("[data-a=s]").onclick = async () => {
269
319
  const why = li.querySelector("input").value.trim();
270
320
  if (!why) {
@@ -272,15 +322,18 @@ function inlineForm(c) {
272
322
  return;
273
323
  }
274
324
  store.mode = null;
275
- const r = idea
276
- ? await post("/inbox", { op: "reject", dir, text: c.text, reason: why })
277
- : await post("/abort", { dir, id: c.id, why });
325
+ const r =
326
+ c.kind === "backlog"
327
+ ? await post("/backlog", { op: "reject", dir, source: c.source, n: c.n, reason: why })
328
+ : idea
329
+ ? await post("/inbox", { op: "reject", dir, text: c.text, reason: why })
330
+ : await post("/abort", { dir, id: c.id, why });
278
331
  if (r.ok) toast(idea ? "Rejected" : "Abandoned, reason recorded");
279
332
  load();
280
333
  };
281
334
  }
282
335
  if (store.mode === "delete") {
283
- li.innerHTML = `<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;
336
+ li.innerHTML = html`<div class="q">Delete "${c.text}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;
284
337
  li.querySelector("[data-a=s]").onclick = async () => {
285
338
  store.mode = null;
286
339
  const r = await post("/inbox", { op: "delete", dir, text: c.text });
@@ -290,7 +343,7 @@ function inlineForm(c) {
290
343
  }
291
344
  if (store.mode === "move") {
292
345
  const ps = store.S.projects.filter((p) => p.dir !== dir);
293
- 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>`;
346
+ li.innerHTML = html`<div class="q">Move "${c.text}" to</div><select class="sel" aria-label="Project">${ps.map((p) => html`<option value="${p.dir}" title="${p.label}">${short(p.label)}</option>`)}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;
294
347
  li.querySelector("[data-a=s]").onclick = async () => {
295
348
  const sel = li.querySelector("select"),
296
349
  to = sel.value,
@@ -302,7 +355,7 @@ function inlineForm(c) {
302
355
  };
303
356
  }
304
357
  if (store.mode === "run") {
305
- li.innerHTML = `<div class="q">Run "${esc(c.text)}"</div><div class="q dim">Opens your agent with this request in a terminal.</div><div class="q dim">Full plans, builds, and checks with a separate reviewer. Lite does it in one session.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="run-actions"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div></div>`;
358
+ li.innerHTML = html`<div class="q">Run "${c.text}"</div><div class="q dim">Opens your agent with this request in a terminal.</div><div class="q dim">Full plans, builds, and checks with a separate reviewer. Lite does it in one session.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="run-actions"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div></div>`;
306
359
  li.querySelector("[data-a=s]").onclick = () => startRun(c, "full");
307
360
  li.querySelector("[data-a=l]").onclick = () => startRun(c, "lite");
308
361
  runForm(li, c);
@@ -354,7 +407,7 @@ async function runForm(li, c) {
354
407
  box.innerHTML = d.agents
355
408
  .map(
356
409
  (a) =>
357
- `<button type="button" class="agent" role="radio" aria-checked="${a.cmd === runPick.agent}" data-cmd="${esc(a.cmd)}"><svg><use href="#c-${esc(a.cmd)}"/></svg>${esc(a.name)}</button>`,
410
+ html`<button type="button" class="agent" role="radio" aria-checked="${String(a.cmd === runPick.agent)}" data-cmd="${a.cmd}"><svg><use href="#c-${a.cmd}"/></svg>${a.name}</button>`,
358
411
  )
359
412
  .join("");
360
413
  const yb = li.querySelector("#run-yolo");
@@ -394,20 +447,99 @@ async function startRun(c, kind) {
394
447
  const p = runPick && runPick.k === key(c) ? runPick : null;
395
448
  runPick = null;
396
449
  const yolo = !!(p && p.yolo && !$("run-yolo")?.hidden);
397
- const r = await post("/run", { dir: c.state, text: c.text, kind, agent: p ? p.agent : undefined, yolo });
450
+ const backlog = c.kind === "backlog" ? { source: c.source, n: c.n } : undefined;
451
+ const r = await post("/run", { dir: c.state, text: c.text, backlog, kind, agent: p ? p.agent : undefined, yolo });
398
452
  if (r.ok) {
399
453
  toast((kind === "full" ? "Full" : "Lite") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
400
- if (r.pty) store.wantTerm = key(c);
454
+ // A backlog card has no terminal of its own; the run's card gets it once the run exists.
455
+ if (r.pty && !backlog) store.wantTerm = key(c);
401
456
  }
402
457
  load();
403
458
  }
404
459
 
405
460
  const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built here", 3: "Finished builds are checked here" };
406
461
 
462
+ // The new-idea form, wired into f: inline at the top of Ideas, or in a modal.
463
+ function ideaForm(f, close) {
464
+ f.innerHTML = html`${store.all ? html`<div class="combo" id="ndc"><button type="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 type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" id="nc">Cancel</button><button type="button" class="btn pri" id="ns">Add</button></div>`;
465
+ const inp = f.querySelector("#newidea"),
466
+ files = [];
467
+ draft(inp, "f:new:0");
468
+ draft(f.querySelector("textarea"), "f:new:1");
469
+ const save = async () => {
470
+ const text = inp.value.trim();
471
+ if (!text) return;
472
+ const notes = f.querySelector("textarea").value.trim();
473
+ close();
474
+ const dir = store.all ? newDir : null;
475
+ const r = await post("/inbox", dir ? { op: "add", dir, text, notes } : { op: "add", text, notes });
476
+ if (r.ok) {
477
+ toast("Idea added");
478
+ // The idea exists first: a failed upload leaves it as plain text.
479
+ if (files.length) {
480
+ const enc = await dataUrls(files);
481
+ const n = await post("/note", { dir, for: text, text: "", files: enc });
482
+ if (!n.ok) toast("Idea added, files not attached");
483
+ }
484
+ }
485
+ load();
486
+ };
487
+ const add = (x) => {
488
+ files.push(x);
489
+ f.querySelector("#newatt").textContent = files.map((y) => y.name).join(" · ");
490
+ };
491
+ f.querySelector("#newpick").onclick = () => f.querySelector("#newfile").click();
492
+ f.querySelector("#newfile").onchange = (e) => {
493
+ [...e.target.files].forEach(add);
494
+ e.target.value = "";
495
+ };
496
+ for (const el of [inp, f.querySelector("textarea")])
497
+ el.onpaste = (e) => {
498
+ for (const it of e.clipboardData.items)
499
+ if (it.kind === "file") {
500
+ const x = it.getAsFile();
501
+ add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
502
+ e.preventDefault();
503
+ }
504
+ };
505
+ if (store.all) ideaCombo(f);
506
+ f.querySelector("#ns").onclick = save;
507
+ f.querySelector("#nc").onclick = close;
508
+ {
509
+ const m = mic([inp, f.querySelector("textarea")]);
510
+ if (m) f.querySelector(".row").prepend(m);
511
+ }
512
+ inp.onkeydown = (e) => {
513
+ if (e.key === "Enter") {
514
+ e.preventDefault();
515
+ save();
516
+ }
517
+ if (e.key === "Escape") {
518
+ e.preventDefault();
519
+ close();
520
+ }
521
+ };
522
+ setTimeout(() => inp.focus());
523
+ }
524
+
525
+ // New idea from the top bar, the empty board or n: the same form, in a modal.
526
+ export function newIdea() {
527
+ if (store.mode === "new") {
528
+ store.mode = null;
529
+ render();
530
+ }
531
+ const dl = modal("");
532
+ const f = dl.querySelector("form");
533
+ f.onsubmit = (e) => e.preventDefault();
534
+ dl.addEventListener("close", () => stopDictation(false));
535
+ ideaForm(f, () => dl.close());
536
+ }
537
+
407
538
  export function render() {
408
539
  // A closed form leaves no draft behind; an open one is reopened after a reload.
409
540
  // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
410
- if (store.mode && store.mode !== "new" && !store.S.cards.some((c) => key(c) === store.modeKey)) store.mode = null;
541
+ if (store.mode && store.mode !== "new" && ![...store.S.cards, ...backlogList()].some((c) => key(c) === store.modeKey))
542
+ store.mode = null;
411
543
  if (store.mode !== "run" && runPick) runPick.yolo = false;
412
544
  if (!store.mode) undraft("f:");
413
545
  try {
@@ -474,8 +606,8 @@ export function render() {
474
606
  const cur = store.all || !store.S.dir ? "All projects" : (store.PL.find((p) => p[1] === store.S.dir) || ["All projects"])[0];
475
607
  $("proj").innerHTML =
476
608
  store.all || !store.S.dir
477
- ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(cur)}</span>`
478
- : `${plogo(store.S.dir, cur)}<span>${esc(short(cur))}</span>`;
609
+ ? html`<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${cur}</span>`
610
+ : html`${plogo(store.S.dir, cur)}<span>${short(cur)}</span>`;
479
611
  $("proj").title = cur;
480
612
  if (!$("projpop").hidden) list();
481
613
  $("showc").hidden = !store.all;
@@ -488,24 +620,24 @@ export function render() {
488
620
  ? '<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>'
489
621
  : '<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';
490
622
  if (!archive)
491
- $("newempty").onclick = () => {
492
- store.mode = "new";
493
- render();
494
- };
623
+ $("newempty").onclick = newIdea;
495
624
  }
496
625
  COLS.forEach((name, ci) => {
497
626
  const s = document.createElement("section");
498
627
  const q = query.trim().toLowerCase();
499
- const list = cs.filter(
628
+ const bl = ci === 0 && !archive && ideasTab === "backlog";
629
+ const all = bl ? backlogList().filter((c) => !only || store.shown.has(c.state)) : cs;
630
+ const list = all.filter(
500
631
  (c) =>
501
632
  c.column === ci &&
633
+ (!bl || !sev || SEVS.find((x) => x[0] === sev)[2](c.severity)) &&
502
634
  (!filter || c.status === filter) &&
503
635
  (archive || !nested(c)) &&
504
636
  (!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
505
637
  );
506
638
  // Runs age by their last move, ideas by when they were added, across every project;
507
639
  // an idea from before the stamp falls back to its inbox position.
508
- if (ci < 4) {
640
+ if (ci < 4 && !bl) {
509
641
  const at = (c) => (c.kind === "run" ? c.moved || 0 : c.added || 0),
510
642
  old = order[ci] === "old",
511
643
  pos = (c) => store.S.cards.indexOf(c);
@@ -515,13 +647,66 @@ export function render() {
515
647
  }
516
648
  const clearable = ci === 4 && !archive && list.length;
517
649
  const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
518
- let head = `<h2>${name}<i>${list.length || ""}</i>${ci < 4 && list.length > 1 ? `<button class="btn sm ghost ord" title="Sort order">${order[ci] === "old" ? "Oldest" : "Newest"}</button>` : ""}${ci === 0 && !archive ? `<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>` : ""}`;
650
+ // The Ideas column is titled by its two tabs, icons with their counts: ideas, or the
651
+ // runs' leftovers. On Backlog a filter icon narrows it to one severity.
652
+ const tabbed = ci === 0 && !archive,
653
+ nb = tabbed ? backlogList().filter((c) => !only || store.shown.has(c.state)) : [];
654
+ const title = tabbed
655
+ ? `<span class="itabs" role="tablist" aria-label="Ideas column">${[
656
+ ["ideas", "Ideas", `<svg><use href="#s-idea"/></svg>`, cs.filter((c) => c.column === 0).length],
657
+ ["backlog", "Backlog: what finished runs left undone", ico("clock"), nb.length],
658
+ ]
659
+ .map(
660
+ ([v, l, svg, k]) =>
661
+ `<button role="tab" aria-selected="${String(ideasTab === v)}" data-t="${v}" title="${l}" aria-label="${l}, ${k}">${svg}<i>${k}</i></button>`,
662
+ )
663
+ .join("")}</span>`
664
+ : `${name}<i>${list.length || ""}</i>`;
665
+ const sevBtn = bl
666
+ ? `<button class="ib sm sevf${sev ? " on" : ""}" data-sv="1" title="${sev ? "Severity: " + SEVS.find((x) => x[0] === sev)[1] : "Filter by severity"}" aria-label="Filter by severity" aria-haspopup="menu" aria-expanded="${String(store.sevMenu)}">${ico("filter")}</button>` +
667
+ (store.sevMenu
668
+ ? `<div class="menu-pop sev-pop" role="menu"><button role="menuitemradio" aria-checked="${String(!sev)}" data-s="">All severities <i>${nb.length}</i></button><hr>${SEVS.map(
669
+ ([v, l, f]) =>
670
+ `<button role="menuitemradio" aria-checked="${String(sev === v)}" data-s="${v}">${l} <i>${nb.filter((c) => f(c.severity)).length}</i></button>`,
671
+ ).join("")}</div>`
672
+ : "")
673
+ : "";
674
+ let head = `<h2>${title}${ci < 4 && !bl && list.length > 1 ? `<button class="btn sm ghost ord" title="Sort order">${order[ci] === "old" ? "Oldest" : "Newest"}</button>` : ""}${sevBtn}${tabbed ? `<button class="ib sm newi" title="New idea" aria-label="New idea">${ico("plus")}</button>` : ""}`;
519
675
  if (clearable && picking)
520
676
  head += `<span class="pickbar"><button class="btn sm ghost" data-p="cancel">Cancel</button><button class="btn sm pri" data-p="go" ${picked.size ? "" : "disabled"}>Archive ${picked.size || ""}</button></span>`;
521
677
  else if (clearable) head += `<button class="btn sm ghost clear" title="Archive finished runs">Clear ▾</button>`;
522
678
  if (clearable && store.clearMenu)
523
679
  head += `<div class="menu-pop clear-pop"><button data-c="7">Older than a week <i>${olderThan(7).length}</i></button><button data-c="3">Older than 3 days <i>${olderThan(3).length}</i></button><button data-c="0">Everything <i>${list.length}</i></button><hr><button data-c="pick">Pick which…</button></div>`;
524
680
  s.innerHTML = head + `</h2>`;
681
+ if (tabbed) {
682
+ s.querySelectorAll("[data-t]").forEach(
683
+ (bt) =>
684
+ (bt.onclick = () => {
685
+ ideasTab = bt.dataset.t;
686
+ store.sevMenu = false;
687
+ try {
688
+ localStorage.setItem("kz-ideas-tab", ideasTab);
689
+ } catch (e) {}
690
+ render();
691
+ }),
692
+ );
693
+ const sv = s.querySelector("[data-sv]");
694
+ if (sv)
695
+ sv.onclick = (e) => {
696
+ e.stopPropagation();
697
+ store.sevMenu = !store.sevMenu;
698
+ render();
699
+ };
700
+ s.querySelectorAll("[data-s]").forEach(
701
+ (bt) =>
702
+ (bt.onclick = (e) => {
703
+ e.stopPropagation();
704
+ sev = bt.dataset.s || null;
705
+ store.sevMenu = false;
706
+ render();
707
+ }),
708
+ );
709
+ }
525
710
  {
526
711
  const o = s.querySelector(".ord");
527
712
  if (o)
@@ -534,9 +719,10 @@ export function render() {
534
719
  render();
535
720
  };
536
721
  }
537
- if (ci === 0 && !archive)
538
- s.querySelector(".ib").onclick = () => {
722
+ if (tabbed)
723
+ s.querySelector(".newi").onclick = () => {
539
724
  store.mode = "new";
725
+ ideasTab = "ideas";
540
726
  render();
541
727
  };
542
728
  if (clearable) {
@@ -593,66 +779,11 @@ export function render() {
593
779
  if (ci === 0 && store.mode === "new") {
594
780
  const f = document.createElement("li");
595
781
  f.className = "inl";
596
- 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>`;
597
782
  ul.appendChild(f);
598
- const inp = f.querySelector("#newidea"),
599
- files = [];
600
- draft(inp, "f:new:0");
601
- draft(f.querySelector("textarea"), "f:new:1");
602
- const save = async () => {
603
- const text = inp.value.trim();
604
- if (!text) return;
605
- const notes = f.querySelector("textarea").value.trim();
606
- store.mode = null;
607
- const dir = store.all ? newDir : null;
608
- const r = await post("/inbox", dir ? { op: "add", dir, text, notes } : { op: "add", text, notes });
609
- if (r.ok) {
610
- toast("Idea added");
611
- // The idea exists first: a failed upload leaves it as plain text.
612
- if (files.length) {
613
- const enc = await dataUrls(files);
614
- const n = await post("/note", { dir, for: text, text: "", files: enc });
615
- if (!n.ok) toast("Idea added, files not attached");
616
- }
617
- }
618
- load();
619
- };
620
- const add = (x) => {
621
- files.push(x);
622
- f.querySelector("#newatt").textContent = files.map((y) => y.name).join(" · ");
623
- };
624
- f.querySelector("#newpick").onclick = () => f.querySelector("#newfile").click();
625
- f.querySelector("#newfile").onchange = (e) => {
626
- [...e.target.files].forEach(add);
627
- e.target.value = "";
628
- };
629
- for (const el of [inp, f.querySelector("textarea")])
630
- el.onpaste = (e) => {
631
- for (const it of e.clipboardData.items)
632
- if (it.kind === "file") {
633
- const x = it.getAsFile();
634
- add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
635
- e.preventDefault();
636
- }
637
- };
638
- if (store.all) ideaCombo(f);
639
- f.querySelector("#ns").onclick = save;
640
- f.querySelector("#nc").onclick = () => {
783
+ ideaForm(f, () => {
641
784
  store.mode = null;
642
785
  render();
643
- };
644
- {
645
- const m = mic([inp, f.querySelector("textarea")]);
646
- if (m) f.querySelector(".row").prepend(m);
647
- }
648
- inp.onkeydown = (e) => {
649
- if (e.key === "Enter") save();
650
- if (e.key === "Escape") {
651
- store.mode = null;
652
- render();
653
- }
654
- };
655
- setTimeout(() => inp.focus());
786
+ });
656
787
  }
657
788
  const shown = unfolded.has(ci) || list.length <= FOLD + 1 ? list : list.slice(0, FOLD);
658
789
  shown.forEach((c) => {
@@ -684,6 +815,8 @@ export function render() {
684
815
  // An empty middle column says what lands in it, so it does not look broken.
685
816
  if (!list.length && ci >= 1 && ci <= 3 && cs.length && !filter && !q && !archive)
686
817
  ul.innerHTML = `<li class="hint">${HINT[ci]}</li>`;
818
+ if (bl && !list.length && !filter && !q)
819
+ ul.innerHTML = `<li class="hint">${sev ? "Nothing at this severity" : "What finished runs left undone shows here"}</li>`;
687
820
  s.appendChild(ul);
688
821
  b.appendChild(s);
689
822
  });
package/web/board/chat.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { load, post } from "./api.ts";
2
2
  import { key } from "./cards.ts";
3
3
  import { mic, stopDictation } from "./dictation.ts";
4
- import { $, esc } from "./dom.ts";
4
+ import { $, html, raw as rawHtml } from "./dom.ts";
5
5
  import { DK, draft } from "./drafts.ts";
6
6
  import { attach, brief, md } from "./markdown.ts";
7
7
  import { toast } from "./toast.ts";
@@ -28,12 +28,12 @@ export function chat(c, src, ro) {
28
28
  const b = c.kind === "idea" ? brief(c.text) : { parts: [] };
29
29
  let h =
30
30
  (b.parts.length
31
- ? `<section class="desc">${md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n"))}</section>`
31
+ ? html`<section class="desc">${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}</section>`
32
32
  : "") +
33
- `<div class="chat">${entries.map((n) => `<div class="msg">${n.at ? `<time>${esc(n.at)}</time>` : ""}${md(fix(n.text))}</div>`).join("") || `<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
33
+ html`<div class="chat">${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(n.text)))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
34
34
  if (!ro)
35
35
  h += `<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
36
- $("pbody").innerHTML = h;
36
+ $("pbody").innerHTML = rawHtml(h);
37
37
  if (ro) return;
38
38
  const ta = $("pbody").querySelector("textarea"),
39
39
  files = [];