kaizen-agent 1.38.0 → 1.39.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/board.test.ts +53 -1
- package/cli/state.ts +5 -0
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +3 -0
- package/skills/kaizen/spec-main.md +13 -0
- package/web/board/cards.ts +82 -14
- package/web/board/keys.ts +5 -0
- package/web/board/main.ts +3 -0
- package/web/board/update.ts +38 -39
- package/web/board.html +22 -2
- package/web/board.js +23 -22
- package/web/notebook.js +1 -1
- package/web/notes.html +8 -0
- package/web/term.js +1 -1
package/cli/board.test.ts
CHANGED
|
@@ -8,6 +8,56 @@ import { draft, undraft } from "../web/board/drafts.ts";
|
|
|
8
8
|
const html = readFileSync(join(dirname(import.meta.dir), "web", "board.html"), "utf8");
|
|
9
9
|
const cards = readFileSync(join(dirname(import.meta.dir), "web", "board", "cards.ts"), "utf8");
|
|
10
10
|
|
|
11
|
+
test("Settings rechecks an existing update alert and clears or refreshes it", async () => {
|
|
12
|
+
const classes = new Set<string>();
|
|
13
|
+
const link = { classList: { toggle(name: string, on: boolean) { on ? classes.add(name) : classes.delete(name); } }, title: "Settings" };
|
|
14
|
+
const previous = globalThis.document;
|
|
15
|
+
const previousLocation = globalThis.location;
|
|
16
|
+
const previousFetch = globalThis.fetch;
|
|
17
|
+
const elements = new Map<string, any>();
|
|
18
|
+
const body = { row: null as any, prepend(row: any) { this.row = row; row.isConnected = true; } };
|
|
19
|
+
(globalThis as any).location = { search: "" };
|
|
20
|
+
(globalThis as any).document = {
|
|
21
|
+
querySelector: () => link,
|
|
22
|
+
getElementById: (id: string) => id === "pbody" ? body : elements.get(id),
|
|
23
|
+
createElement: () => ({
|
|
24
|
+
isConnected: false,
|
|
25
|
+
set innerHTML(html: string) {
|
|
26
|
+
this.html = html;
|
|
27
|
+
elements.clear();
|
|
28
|
+
for (const [, id] of html.matchAll(/id="([^"]+)"/g)) elements.set(id, { textContent: "", disabled: false, focus() {} });
|
|
29
|
+
},
|
|
30
|
+
remove() { this.isConnected = false; },
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const { gotVer, updBar } = await import("../web/board/update.ts");
|
|
35
|
+
expect(gotVer({ version: "1.38.1", latest: "1.39.0", update: true })).toBe(true);
|
|
36
|
+
updBar();
|
|
37
|
+
expect(body.row.html).toContain('id="upd-chk"');
|
|
38
|
+
expect(classes.has("upd")).toBe(true);
|
|
39
|
+
expect(link.title).toContain("1.39.0");
|
|
40
|
+
globalThis.fetch = (async () => ({ json: async () => ({ version: "1.39.0", latest: "1.39.0", update: false }) })) as any;
|
|
41
|
+
await elements.get("upd-chk").onclick();
|
|
42
|
+
expect(classes.has("upd")).toBe(false);
|
|
43
|
+
expect(link.title).toBe("Settings");
|
|
44
|
+
expect(body.row.className).toContain("quiet");
|
|
45
|
+
expect(elements.get("upd-msg").textContent).toBe("kaizen 1.39.0 is up to date.");
|
|
46
|
+
globalThis.fetch = (async () => ({ json: async () => ({ version: "1.39.0", latest: "1.40.0", update: true }) })) as any;
|
|
47
|
+
await elements.get("upd-chk").onclick();
|
|
48
|
+
expect(classes.has("upd")).toBe(true);
|
|
49
|
+
expect(body.row.html).toContain("1.40.0");
|
|
50
|
+
globalThis.fetch = (async () => { throw Error("offline"); }) as any;
|
|
51
|
+
await elements.get("upd-chk").onclick();
|
|
52
|
+
expect(classes.has("upd")).toBe(true);
|
|
53
|
+
expect(body.row.html).toContain("1.40.0");
|
|
54
|
+
} finally {
|
|
55
|
+
globalThis.document = previous;
|
|
56
|
+
globalThis.location = previousLocation;
|
|
57
|
+
globalThis.fetch = previousFetch;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
11
61
|
test("backlog items: a checkbox each, ticked only when done", () => {
|
|
12
62
|
const out = md('- open: one | why\n- done: two <b>x</b> | fixed\n- rejected: three | no\n- plain\n', true);
|
|
13
63
|
const boxes = out.match(/<li[^>]*>.*?<\/li>/g)!;
|
|
@@ -105,7 +155,9 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
|
|
|
105
155
|
const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
|
|
106
156
|
const from = cards.indexOf("if (k === store.menuKey) {");
|
|
107
157
|
const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
|
|
108
|
-
const
|
|
158
|
+
const a0 = cards.indexOf("const actionOf =");
|
|
159
|
+
const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
|
|
160
|
+
const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
|
|
109
161
|
|
|
110
162
|
// Phase 0: Ideas (idea card)
|
|
111
163
|
const idea = { kind: "idea", status: "idea", column: 0 };
|
package/cli/state.ts
CHANGED
|
@@ -1394,6 +1394,8 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
|
|
|
1394
1394
|
// pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
|
|
1395
1395
|
// tool and model, and never yolo. card: run it inside the board (agent.launch: card),
|
|
1396
1396
|
// where a pseudo-terminal exists; onChange is told when that agent ends.
|
|
1397
|
+
const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
|
|
1398
|
+
|
|
1397
1399
|
export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void } = {}): Launch {
|
|
1398
1400
|
const projectDir = projectOf(from);
|
|
1399
1401
|
// The run will be born here; the board must know the dir or the idea never retires.
|
|
@@ -1401,6 +1403,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1401
1403
|
const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
|
|
1402
1404
|
const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
|
|
1403
1405
|
if (agent.cmd === "claude") trustFolder(projectDir);
|
|
1406
|
+
// A board started inside a Claude Code session carries that session's identity. An
|
|
1407
|
+
// agent inheriting it runs as its child and keeps no transcript, so nothing to resume.
|
|
1408
|
+
for (const k of Object.keys(process.env)) if (CHILD_ENV.test(k)) delete process.env[k];
|
|
1404
1409
|
// onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
|
|
1405
1410
|
const agentBin = onPath(agent.cmd) ?? agent.cmd;
|
|
1406
1411
|
// No text: a run's conversation reopened as it was, with nothing sent to it.
|
package/package.json
CHANGED
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -273,6 +273,9 @@ first dispatch of the run. `inherit` means leave the file alone.
|
|
|
273
273
|
|
|
274
274
|
After a subagent returns, update `state.json` yourself in the main thread. Subagents
|
|
275
275
|
report; the main thread owns the state machine.
|
|
276
|
+
Before moving any run from `build` to `review`, under either runner, apply the
|
|
277
|
+
*Preview gate* in [`spec-main.md`](spec-main.md): no `## Preview` with a screenshot
|
|
278
|
+
(or a one-line reason there is none) means the build is not done.
|
|
276
279
|
|
|
277
280
|
**Name files, do not narrate them.** The prompt says which paths to read, which
|
|
278
281
|
decisions bind the stage, and what to report back. It does not summarize those files:
|
|
@@ -83,6 +83,19 @@ subagents at all.
|
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
|
+
## Preview gate
|
|
87
|
+
|
|
88
|
+
Before setting `stage: "review"` after a build, in either runner, open `03-impl.md` and
|
|
89
|
+
check its `## Preview`: it has `### How to test` steps, and — when the result shows on a
|
|
90
|
+
screen — at least one `` embed whose file exists under
|
|
91
|
+
`runs/<id>/attachments/`. A result with no screen needs one line under `## Preview`
|
|
92
|
+
saying why there is no screenshot. Anything missing means the build is not finished:
|
|
93
|
+
under `lite`, take the screenshot and write the section now; under `full`, send the
|
|
94
|
+
builder back to add it. Then advance. This is the step that keeps the board's Preview
|
|
95
|
+
tab from staying empty; the reviewer flagging it later is a second net, not the first.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
86
99
|
## Final approval
|
|
87
100
|
|
|
88
101
|
Controlled by `approvals.review`, skipped in `auto`.
|
package/web/board/cards.ts
CHANGED
|
@@ -9,8 +9,10 @@ import { brief } from "./markdown.ts";
|
|
|
9
9
|
import { open, 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
|
+
import { resumable, resume } from "./approvals.ts";
|
|
12
13
|
|
|
13
14
|
let filter = null;
|
|
15
|
+
let query = ""; // the header filter box, matched against a card's title and request
|
|
14
16
|
// A column shows this many cards, then a "more" line; Done is where it matters.
|
|
15
17
|
const FOLD = 6;
|
|
16
18
|
const unfolded = new Set();
|
|
@@ -39,6 +41,12 @@ const nested = (c) =>
|
|
|
39
41
|
store.S.cards.some((p) => p.kind === "run" && p.id === c.parent && p.state === c.state && !p.archived && p.column !== 4);
|
|
40
42
|
const kidsOpen = new Set();
|
|
41
43
|
|
|
44
|
+
// What opening a waiting run does, in words: the card's button and its menu say the same.
|
|
45
|
+
const actionOf = (c) =>
|
|
46
|
+
c.status !== "waiting"
|
|
47
|
+
? "Open"
|
|
48
|
+
: { "approvals.plan": "Review plan", findings: "See findings", "approvals.review": "Review work" }[c.awaiting] || "Open";
|
|
49
|
+
|
|
42
50
|
function card(c) {
|
|
43
51
|
const k = key(c),
|
|
44
52
|
el = document.createElement("div");
|
|
@@ -51,7 +59,7 @@ function card(c) {
|
|
|
51
59
|
el.dataset.k = k;
|
|
52
60
|
el.setAttribute("role", "button");
|
|
53
61
|
const st = ICON[c.status] || "idea",
|
|
54
|
-
idt = c.kind === "idea" ? "
|
|
62
|
+
idt = c.kind === "idea" ? "" : c.text.replace(/-/g, " ");
|
|
55
63
|
// The status icon carries the state; what a waiting run needs is its tooltip.
|
|
56
64
|
const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
|
|
57
65
|
// Footer: words on the left (project, what it needs), icon-only marks on the right.
|
|
@@ -98,10 +106,22 @@ function card(c) {
|
|
|
98
106
|
);
|
|
99
107
|
if (c.open) marks.push(mk("i-bug", `${c.open} open finding${c.open === 1 ? "" : "s"} to fix`, " warn"));
|
|
100
108
|
const pick = picking && !archive && c.column === 4;
|
|
109
|
+
// Done cards are one line on the board; the panel still shows everything.
|
|
110
|
+
if (c.column === 4 && !archive) el.classList.add("compact");
|
|
111
|
+
// The one thing a card asks of you, on its face: what a waiting run needs, or resuming a stalled one.
|
|
112
|
+
const act =
|
|
113
|
+
c.kind === "run" && !archive && !pick
|
|
114
|
+
? c.status === "waiting"
|
|
115
|
+
? `<button class="btn sm pri act" data-a="open">${actionOf(c)}</button>`
|
|
116
|
+
: c.status === "stalled" && resumable(c)
|
|
117
|
+
? `<button class="btn sm act" data-a="resume">Resume</button>`
|
|
118
|
+
: ""
|
|
119
|
+
: "";
|
|
101
120
|
el.innerHTML =
|
|
102
121
|
(pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
|
|
103
122
|
`<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>
|
|
104
123
|
<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>
|
|
124
|
+
${act}
|
|
105
125
|
${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
|
|
106
126
|
const kids = kidsOf(c);
|
|
107
127
|
if (kids.length) {
|
|
@@ -160,17 +180,7 @@ function card(c) {
|
|
|
160
180
|
["delete", "Remove", ico("trash"), "danger"],
|
|
161
181
|
);
|
|
162
182
|
} else {
|
|
163
|
-
|
|
164
|
-
c.status === "waiting"
|
|
165
|
-
? c.awaiting === "approvals.plan"
|
|
166
|
-
? "Review plan"
|
|
167
|
-
: c.awaiting === "findings"
|
|
168
|
-
? "See findings"
|
|
169
|
-
: c.awaiting === "approvals.review"
|
|
170
|
-
? "Review work"
|
|
171
|
-
: "Open"
|
|
172
|
-
: "Open";
|
|
173
|
-
items.push(["open", openLabel, ico("open")]);
|
|
183
|
+
items.push(["open", actionOf(c), ico("open")]);
|
|
174
184
|
items.push(["archive", "Archive", ico("archive")]);
|
|
175
185
|
if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
|
|
176
186
|
items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
|
|
@@ -194,6 +204,10 @@ function card(c) {
|
|
|
194
204
|
open(c);
|
|
195
205
|
return;
|
|
196
206
|
}
|
|
207
|
+
if (a === "resume") {
|
|
208
|
+
resume(c, bt);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
197
211
|
if (a === "archive" || a === "restore") {
|
|
198
212
|
toggleArchive(c, a === "archive");
|
|
199
213
|
return;
|
|
@@ -383,6 +397,8 @@ async function startRun(c, kind) {
|
|
|
383
397
|
load();
|
|
384
398
|
}
|
|
385
399
|
|
|
400
|
+
const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built here", 3: "Finished builds are checked here" };
|
|
401
|
+
|
|
386
402
|
export function render() {
|
|
387
403
|
// A closed form leaves no draft behind; an open one is reopened after a reload.
|
|
388
404
|
// A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
|
|
@@ -422,6 +438,10 @@ export function render() {
|
|
|
422
438
|
`<button class="${archive ? "on" : ""}" data-f="__archive" title="${cs.filter((c) => c.kind === "idea").length} ideas · ${cs.filter((c) => c.kind === "run").length} runs on the board">${archive ? "Back to board" : "Archive " + nArch}</button>`,
|
|
423
439
|
);
|
|
424
440
|
$("strip").innerHTML = strip.join("");
|
|
441
|
+
// The phone's status button: amber dot while a run waits, accent while a filter or the archive is on.
|
|
442
|
+
$("fbtn").hidden = false;
|
|
443
|
+
$("fbtn").classList.toggle("on", !!filter || archive);
|
|
444
|
+
$("fbtn").classList.toggle("wait", !filter && !archive && n("waiting") > 0);
|
|
425
445
|
$("strip")
|
|
426
446
|
.querySelectorAll("button")
|
|
427
447
|
.forEach(
|
|
@@ -468,12 +488,21 @@ export function render() {
|
|
|
468
488
|
}
|
|
469
489
|
COLS.forEach((name, ci) => {
|
|
470
490
|
const s = document.createElement("section");
|
|
471
|
-
const
|
|
491
|
+
const q = query.trim().toLowerCase();
|
|
492
|
+
const list = cs.filter(
|
|
493
|
+
(c) =>
|
|
494
|
+
c.column === ci &&
|
|
495
|
+
(!filter || c.status === filter) &&
|
|
496
|
+
(archive || !nested(c)) &&
|
|
497
|
+
(!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
|
|
498
|
+
);
|
|
472
499
|
// Runs age by their last move, ideas by inbox position (new ones are appended).
|
|
473
500
|
if (ci < 4) {
|
|
474
501
|
const at = (c) => (c.kind === "run" ? c.moved || 0 : store.S.cards.indexOf(c)),
|
|
475
502
|
old = order[ci] === "old";
|
|
476
|
-
|
|
503
|
+
// Runs waiting on you go first, then the column's own order.
|
|
504
|
+
const w = (c) => (c.status === "waiting" ? 1 : 0);
|
|
505
|
+
list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) : at(b) - at(a)));
|
|
477
506
|
}
|
|
478
507
|
const clearable = ci === 4 && !archive && list.length;
|
|
479
508
|
const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
|
|
@@ -652,12 +681,51 @@ export function render() {
|
|
|
652
681
|
};
|
|
653
682
|
ul.appendChild(li);
|
|
654
683
|
}
|
|
684
|
+
// An empty middle column says what lands in it, so it does not look broken.
|
|
685
|
+
if (!list.length && ci >= 1 && ci <= 3 && cs.length && !filter && !q && !archive)
|
|
686
|
+
ul.innerHTML = `<li class="hint">${HINT[ci]}</li>`;
|
|
655
687
|
s.appendChild(ul);
|
|
656
688
|
b.appendChild(s);
|
|
657
689
|
});
|
|
690
|
+
// The tab says how many runs wait on you, so a background tab still tells you.
|
|
691
|
+
const nw = store.S.cards.filter((c) => c.kind === "run" && c.status === "waiting" && !c.archived).length;
|
|
692
|
+
document.title = nw ? `(${nw}) kaizen` : "kaizen";
|
|
658
693
|
stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
|
|
659
694
|
if (fk && document.activeElement === document.body) cardEl(fk)?.focus({ preventScroll: true });
|
|
660
695
|
}
|
|
696
|
+
// The header filter box lives outside #board, so a redraw keeps what is typed; draft() keeps it over a reload.
|
|
697
|
+
export function filterBox() {
|
|
698
|
+
const el = $("bq");
|
|
699
|
+
draft(el, "bq");
|
|
700
|
+
query = el.value;
|
|
701
|
+
el.addEventListener("input", () => {
|
|
702
|
+
query = el.value;
|
|
703
|
+
render();
|
|
704
|
+
});
|
|
705
|
+
el.addEventListener("keydown", (e) => {
|
|
706
|
+
if (e.key === "Escape") {
|
|
707
|
+
e.stopPropagation();
|
|
708
|
+
el.value = "";
|
|
709
|
+
el.dispatchEvent(new Event("input"));
|
|
710
|
+
el.blur();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
// Phones: the footer's status strip opens as a sheet under the filter button; a press outside closes it.
|
|
715
|
+
export function statusSheet() {
|
|
716
|
+
const b = $("fbtn"),
|
|
717
|
+
f = document.querySelector(".ftr");
|
|
718
|
+
const set = (on) => {
|
|
719
|
+
f.classList.toggle("on", on);
|
|
720
|
+
b.setAttribute("aria-expanded", on);
|
|
721
|
+
if (on) f.style.top = b.getBoundingClientRect().bottom + 6 + "px";
|
|
722
|
+
};
|
|
723
|
+
b.onclick = () => set(!f.classList.contains("on"));
|
|
724
|
+
document.addEventListener("pointerdown", (e) => {
|
|
725
|
+
if (f.classList.contains("on") && !f.contains(e.target) && !b.contains(e.target)) set(false);
|
|
726
|
+
});
|
|
727
|
+
document.addEventListener("keydown", (e) => e.key === "Escape" && f.classList.contains("on") && set(false));
|
|
728
|
+
}
|
|
661
729
|
export const focusedKey = () => document.activeElement?.closest?.("#board .card")?.dataset.k;
|
|
662
730
|
export const cardEl = (k) => k && $("board").querySelector('.card[data-k="' + CSS.escape(k) + '"]');
|
|
663
731
|
// Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
|
package/web/board/keys.ts
CHANGED
|
@@ -39,6 +39,11 @@ export function initKeys() {
|
|
|
39
39
|
$("sq").focus();
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
|
+
if (e.key === "/" && view === "board" && $("bq")) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
$("bq").focus();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
42
47
|
if (e.key === "Escape") {
|
|
43
48
|
$("help").classList.remove("on");
|
|
44
49
|
if (store.mode) {
|
package/web/board/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { initViews } from "./views.ts";
|
|
|
9
9
|
import { initProjects } from "./projects.ts";
|
|
10
10
|
import { initUpdate } from "./update.ts";
|
|
11
11
|
import { initKeys } from "./keys.ts";
|
|
12
|
+
import { filterBox, statusSheet } from "./cards.ts";
|
|
12
13
|
import { initLive } from "./live.ts";
|
|
13
14
|
import { store } from "./store.ts";
|
|
14
15
|
|
|
@@ -18,6 +19,8 @@ initProjects();
|
|
|
18
19
|
initUpdate();
|
|
19
20
|
initKeys();
|
|
20
21
|
initLive();
|
|
22
|
+
filterBox();
|
|
23
|
+
statusSheet();
|
|
21
24
|
|
|
22
25
|
// One popup at a time: a press anywhere first shuts every open popup it is not inside,
|
|
23
26
|
// before any handler runs (several stop propagation), so the one it opens is the only one
|
package/web/board/update.ts
CHANGED
|
@@ -9,53 +9,52 @@ import { view } from "./views.ts";
|
|
|
9
9
|
// is a quiet row whose Check for updates asks npm now instead of the server's 6 h cache.
|
|
10
10
|
let upd = null,
|
|
11
11
|
ver = null;
|
|
12
|
-
function gotVer(v) {
|
|
12
|
+
export function gotVer(v) {
|
|
13
13
|
ver = v;
|
|
14
|
-
|
|
15
|
-
upd = v;
|
|
14
|
+
upd = v.update ? v : null;
|
|
16
15
|
const a = document.querySelector('.rail a[data-view="settings"]');
|
|
17
|
-
a.classList.
|
|
18
|
-
a.title = `Settings: kaizen ${v.latest} is available
|
|
19
|
-
return
|
|
16
|
+
a.classList.toggle("upd", !!upd);
|
|
17
|
+
a.title = upd ? `Settings: kaizen ${v.latest} is available` : "Settings";
|
|
18
|
+
return !!upd;
|
|
20
19
|
}
|
|
21
20
|
export function updBar() {
|
|
21
|
+
const d = document.createElement("div");
|
|
22
|
+
d.className = `updbar${upd ? "" : " quiet"}`;
|
|
22
23
|
if (!upd) {
|
|
23
|
-
const d = document.createElement("div");
|
|
24
|
-
d.className = "updbar quiet";
|
|
25
24
|
d.innerHTML = `<span id="upd-msg" aria-live="polite">${ver ? `You are running kaizen ${esc(ver.version)}.` : "kaizen"}</span><button class="btn" id="upd-chk">Check for updates</button>`;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.catch(() => null);
|
|
34
|
-
if (!d.isConnected) return;
|
|
35
|
-
b.disabled = false;
|
|
36
|
-
b.textContent = "Check for updates";
|
|
37
|
-
if (v && gotVer(v)) {
|
|
38
|
-
d.remove();
|
|
39
|
-
updBar();
|
|
40
|
-
$("upd-go").focus();
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
$("upd-msg").textContent =
|
|
44
|
-
!v || !v.latest ? "Could not reach npm. Try again later." : `kaizen ${v.version} is up to date.`;
|
|
45
|
-
};
|
|
46
|
-
return;
|
|
25
|
+
} else {
|
|
26
|
+
// What's new: each newer release's one-line title, shown on hover, focus or click.
|
|
27
|
+
const notes = (upd.notes || []).map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`).join("");
|
|
28
|
+
const news = notes
|
|
29
|
+
? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
|
|
30
|
+
: "";
|
|
31
|
+
d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
|
|
47
32
|
}
|
|
48
|
-
const d = document.createElement("div");
|
|
49
|
-
d.className = "updbar";
|
|
50
|
-
// What's new: each newer release's one-line title, shown on hover, focus or click.
|
|
51
|
-
const notes = (upd.notes || [])
|
|
52
|
-
.map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`)
|
|
53
|
-
.join("");
|
|
54
|
-
const news = notes
|
|
55
|
-
? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
|
|
56
|
-
: "";
|
|
57
|
-
d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn pri" id="upd-go">Update kaizen</button></span>`;
|
|
58
33
|
$("pbody").prepend(d);
|
|
34
|
+
const check = $("upd-chk");
|
|
35
|
+
check.onclick = async () => {
|
|
36
|
+
check.disabled = true;
|
|
37
|
+
check.textContent = "Checking…";
|
|
38
|
+
const v = await fetch("/version?fresh=1")
|
|
39
|
+
.then((r) => r.json())
|
|
40
|
+
.catch(() => null);
|
|
41
|
+
if (!d.isConnected) return;
|
|
42
|
+
check.disabled = false;
|
|
43
|
+
if (!v || !v.latest) {
|
|
44
|
+
if (upd) check.textContent = "Could not check. Try again";
|
|
45
|
+
else $("upd-msg").textContent = "Could not reach npm. Try again later.";
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
gotVer(v);
|
|
49
|
+
d.remove();
|
|
50
|
+
updBar();
|
|
51
|
+
if (upd) $("upd-go").focus();
|
|
52
|
+
else {
|
|
53
|
+
$("upd-msg").textContent = `kaizen ${v.version} is up to date.`;
|
|
54
|
+
$("upd-chk").focus();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (!upd) return;
|
|
59
58
|
const b = $("upd-go");
|
|
60
59
|
b.onclick = async () => {
|
|
61
60
|
if (!confirm(`Update kaizen to ${upd.latest}? The board restarts, and agents running inside cards stop.`)) return;
|
package/web/board.html
CHANGED
|
@@ -59,6 +59,14 @@ svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke
|
|
|
59
59
|
[data-rail=min] .theme{flex-direction:column}
|
|
60
60
|
[data-rail=min] .theme button{width:34px;flex:none}
|
|
61
61
|
@media(max-width:640px){#fold{display:none}}
|
|
62
|
+
/* phones: the side menu is a bottom tab bar, in the page flow so nothing sits under it; it owns the bottom safe area */
|
|
63
|
+
@media(max-width:640px){body{flex-direction:column;padding-bottom:0}.app{flex:1;min-height:0}
|
|
64
|
+
.rail,[data-rail=min] .rail{order:2;width:auto;flex-direction:row;align-items:stretch;gap:0;padding:4px 6px calc(4px + env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--bg-2);overflow:visible}
|
|
65
|
+
.rail .top,.rail .end,.rail .theme{display:none}
|
|
66
|
+
.rail a.it,[data-rail=min] .rail a.it{flex:1;width:auto;flex-direction:column;justify-content:center;gap:2px;height:auto;min-height:48px;padding:4px 0;font-size:11px}
|
|
67
|
+
.rail a.it[aria-current=page]{background:none;color:var(--accent)}
|
|
68
|
+
[data-rail=min] .rail .lbl{position:static;width:auto;height:auto;overflow:visible;clip-path:none}
|
|
69
|
+
body .rail a.upd::after{left:calc(50% + 6px);top:4px}body .toast{left:16px;bottom:calc(68px + env(safe-area-inset-bottom))}}
|
|
62
70
|
/* end side menu */
|
|
63
71
|
/* the work area: an inset rounded panel on the canvas the side menu sits on */
|
|
64
72
|
.app{flex:1;min-width:0;display:flex;flex-direction:column;margin:8px 8px 8px 0;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
|
@@ -81,6 +89,8 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
|
|
|
81
89
|
/* project picker: a select-shaped button over a searchable list */
|
|
82
90
|
.combo{position:relative;max-width:100%;display:flex}
|
|
83
91
|
.combo .menu-pop{left:0;right:auto;top:calc(100% + 4px);width:320px;max-width:calc(100vw - 32px);gap:6px;z-index:9}
|
|
92
|
+
/* phones: the right-hand combo opens leftward, so its list stays on screen */
|
|
93
|
+
@media(max-width:640px){#showc .menu-pop{left:auto;right:0}}
|
|
84
94
|
.combo[hidden],.combo .menu-pop[hidden]{display:none}
|
|
85
95
|
.combo input.sel{width:100%;max-width:none}
|
|
86
96
|
#ndc>.sel{width:100%;max-width:none;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -166,6 +176,10 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
|
|
|
166
176
|
.card:hover .age,.card.menu .age{display:none}
|
|
167
177
|
.c-title{font-size:13px;font-weight:500;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--ink)}
|
|
168
178
|
.c-foot{display:flex;align-items:center;gap:8px;margin-top:2px}
|
|
179
|
+
.card .act{align-self:flex-start;min-height:24px;padding:0 10px;font-size:12px}
|
|
180
|
+
.card.compact{flex-direction:row;align-items:center;gap:8px;padding:7px 12px}.card.compact .c-id{display:contents}.card.compact .idt{display:none}.card.compact .c-title{order:1;flex:1;min-width:0;-webkit-line-clamp:1}.card.compact .age{order:2}.card.compact .more{order:3}.card.compact .c-foot,.card.compact .kids{display:none}
|
|
181
|
+
section li.hint{display:block;padding:14px;border:1px dashed var(--line);border-radius:var(--r-s);color:var(--dim);font-size:12px;text-align:center}
|
|
182
|
+
#bq{width:220px;min-height:30px}
|
|
169
183
|
.c-chips{display:flex;gap:4px;flex-wrap:wrap;flex:1;min-width:0}
|
|
170
184
|
.c-marks{display:flex;align-items:center;gap:6px;color:var(--dim);margin-left:auto}
|
|
171
185
|
.mk{display:grid;place-items:center;font-size:11px;font-weight:500}
|
|
@@ -226,6 +240,9 @@ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-directio
|
|
|
226
240
|
.ftr{display:flex;justify-content:space-between;gap:16px;padding:6px 28px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;flex-wrap:wrap;align-items:center}
|
|
227
241
|
.ftr span+span,.ftr a+a{margin-left:14px}.ftr .strip{font-size:12px}.ftr .strip .st{display:inline-grid}
|
|
228
242
|
/* The footer is out of the eye's path, so the waiting alert is filled, not just outlined. */
|
|
243
|
+
/* phones: the status strip moves into a sheet under the header, opened by #fbtn; the dot says a run waits or a filter is on */
|
|
244
|
+
#fbtn{position:relative;display:none}body.paged #fbtn{display:none!important}#fbtn.wait::after,#fbtn.on::after{content:"";position:absolute;right:6px;top:6px;width:7px;height:7px;border-radius:50%;background:var(--warn);box-shadow:0 0 0 2px var(--bg)}#fbtn.on::after{background:var(--accent)}
|
|
245
|
+
@media(max-width:640px){#fbtn:not([hidden]){display:grid;order:2;flex:none}body .hdr .combo{flex-basis:calc(50% - 30px)}.ftr{display:none}.ftr.on{display:flex;position:fixed;z-index:10;left:12px;right:12px;padding:12px;background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow-2)}}
|
|
229
246
|
.ftr .strip .attn{color:var(--ink);background:color-mix(in srgb,var(--warn) 18%,transparent);box-shadow:inset 0 0 0 1px var(--warn)}.ftr .strip .attn:hover,.ftr .strip .attn.on{background:color-mix(in srgb,var(--warn) 30%,transparent)}.ftr .strip .attn svg{color:var(--warn)}.ftr .st svg{width:12px;height:12px}
|
|
230
247
|
.ftr a{color:var(--dim);cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-flex;align-items:center;gap:5px}.ftr a>svg{width:13px;height:13px}.ftr a:hover{color:var(--ink)}
|
|
231
248
|
|
|
@@ -235,7 +252,7 @@ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-directio
|
|
|
235
252
|
aside{position:fixed;top:0;right:0;bottom:0;z-index:11;width:520px;max-width:100%;background:var(--bg-2);border-left:1px solid var(--line);box-shadow:var(--shadow-2);display:flex;flex-direction:column;transform:translateX(100%);visibility:hidden;transition:transform .18s,visibility .18s}
|
|
236
253
|
aside.open{transform:none;visibility:visible}
|
|
237
254
|
/* Settings and the Icebox are pages: the same panel, docked into the app column, full width like the board */
|
|
238
|
-
body.paged #board,body.paged .ftr,body.paged .scrim{display:none}
|
|
255
|
+
body.paged #board,body.paged .ftr,body.paged .scrim,body.paged #bq{display:none}
|
|
239
256
|
/* The Icebox keeps the project bar with its own title, without New idea or the panel's header; Settings has none */
|
|
240
257
|
body.paged:not(.ice) .hdr,body.ice aside.docked>header{display:none}body.ice #newtop{visibility:hidden}
|
|
241
258
|
aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
|
|
@@ -448,7 +465,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
|
|
|
448
465
|
.src-add .acts .btn{flex-shrink:0}
|
|
449
466
|
@media(max-width:480px){.src-add .two,.src-add .two.plat{grid-template-columns:1fr}}
|
|
450
467
|
@media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.ftr,aside.docked>header,aside.docked>.body{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
|
|
451
|
-
@media(max-width:640px){.hdr{gap:8px 12px}.hdr h1{order:0;flex:1 0 50%}#newtop{order:1}.hdr .combo{order:2;flex:1 1 calc(50% - 6px);min-width:0}.hdr .combo>.sel{width:100%;max-width:none}.hdr .sp{display:none}.ftr{gap:6px 12px}}
|
|
468
|
+
@media(max-width:640px){.hdr{gap:8px 12px}.hdr h1{order:0;flex:1 0 50%}#newtop{order:1}.hdr .combo{order:2;flex:1 1 calc(50% - 6px);min-width:0}.hdr .combo>.sel{width:100%;max-width:none}.hdr .sp{display:none}#bq{order:3;flex:1 0 100%;width:auto}.ftr{gap:6px 12px}}
|
|
452
469
|
@media(hover:none){#kbd{display:none}}
|
|
453
470
|
.skel{display:block;border-radius:6px;background:linear-gradient(90deg,var(--line) 25%,color-mix(in srgb,var(--line) 45%,transparent) 50%,var(--line) 75%);background-size:200% 100%;animation:skel 1.2s ease-in-out infinite}@keyframes skel{from{background-position:200% 0}to{background-position:-200% 0}}
|
|
454
471
|
@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
|
|
@@ -473,6 +490,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
|
|
|
473
490
|
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6"/></symbol>
|
|
474
491
|
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
|
|
475
492
|
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
|
|
493
|
+
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M4 5h16l-6 8v6l-4-2v-4z"/></symbol>
|
|
476
494
|
<symbol id="i-note" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h6M9 17h6"/></symbol>
|
|
477
495
|
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
|
|
478
496
|
<symbol id="i-term" viewBox="0 0 24 24"><path d="M4 17l6-5-6-5M12 19h8"/></symbol>
|
|
@@ -543,6 +561,8 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
|
|
|
543
561
|
<h1>Board</h1>
|
|
544
562
|
<div class="combo" id="combo"><button class="sel" id="proj" aria-label="project" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" id="projpop" hidden><input class="sel" id="projq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="projs" role="listbox"></div></div></div>
|
|
545
563
|
<div class="combo" id="showc" hidden><button class="sel" id="show" aria-label="Projects shown" aria-haspopup="true" aria-expanded="false"></button><div class="menu-pop" id="showpop" hidden><input class="sel" id="showq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="shows"></div><div class="pop-ft"><span id="showcount"></span><button id="showall">Show all</button></div></div></div>
|
|
564
|
+
<button class="ib" id="fbtn" aria-label="Filter by status" title="Filter by status" aria-expanded="false" hidden><svg><use href="#i-filter"/></svg></button>
|
|
565
|
+
<input type="search" class="sel" id="bq" placeholder="Filter cards /" aria-label="Filter cards" spellcheck="false" autocomplete="off">
|
|
546
566
|
<span class="sp"></span>
|
|
547
567
|
<button class="btn pri" id="newtop">New idea</button>
|
|
548
568
|
</div>
|