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.
- package/cli/backlog.test.ts +133 -0
- package/cli/board.test.ts +7 -2
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/state.test.ts +61 -2
- package/cli/state.ts +219 -45
- package/cli/web.ts +58 -15
- package/package.json +1 -1
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +240 -107
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +51 -69
- package/web/board/keys.ts +7 -6
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +9 -14
- package/web/board/settings.ts +18 -22
- package/web/board/store.ts +2 -0
- package/web/board/update.ts +7 -7
- package/web/board.html +12 -0
- package/web/board.js +23 -23
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/web/board/cards.ts
CHANGED
|
@@ -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,
|
|
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 === "
|
|
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="${
|
|
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="${
|
|
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">${
|
|
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 ${
|
|
100
|
-
if (c.
|
|
101
|
-
|
|
102
|
-
|
|
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="${
|
|
128
|
-
<div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${
|
|
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
|
|
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">${
|
|
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>"))
|
|
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
|
-
|
|
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
|
-
|
|
289
|
+
show(c);
|
|
240
290
|
};
|
|
241
291
|
el.onkeydown = (e) => {
|
|
242
292
|
if (e.key === "Enter" || e.key === " ") {
|
|
243
293
|
e.preventDefault();
|
|
244
|
-
|
|
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="${
|
|
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
|
|
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 =
|
|
276
|
-
|
|
277
|
-
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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="${
|
|
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
|
|
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
|
-
|
|
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))
|
|
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>${
|
|
478
|
-
: `${plogo(store.S.dir, 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
|
|
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
|
-
|
|
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 (
|
|
538
|
-
s.querySelector(".
|
|
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
|
-
|
|
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 { $,
|
|
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>${
|
|
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 = [];
|