kaizen-agent 1.33.1 → 1.34.1
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/browser.test.ts +1 -0
- package/cli/browser.ts +12 -8
- package/cli/sources.test.ts +82 -3
- package/cli/sources.ts +153 -20
- package/cli/state.test.ts +31 -0
- package/cli/state.ts +10 -0
- package/cli/web.ts +50 -22
- package/package.json +1 -1
- package/skills/kaizen/gather.md +4 -1
- package/skills/kaizen/spec.md +5 -1
- package/web/board/api.ts +5 -2
- package/web/board/icebox.ts +26 -26
- package/web/board/live.ts +3 -1
- package/web/board/projects.ts +4 -1
- package/web/board/store.ts +2 -0
- package/web/board.html +10 -2
- package/web/board.js +18 -18
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/web.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession,
|
|
11
11
|
} from "./state.ts";
|
|
12
12
|
import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
13
|
-
import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, markReplied, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
13
|
+
import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, restore, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
14
14
|
import * as chatgpt from "./chatgpt.ts";
|
|
15
15
|
import { postLink, sendReply } from "./browser.ts";
|
|
16
16
|
import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
|
|
@@ -91,10 +91,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
91
91
|
// Before anything looks for an agent on PATH: the board starts agents itself, and
|
|
92
92
|
// from a launcher it would otherwise have none of the user's shell setup.
|
|
93
93
|
adoptLoginEnv();
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
// Where the board started. Each page picks its own project and names it in the
|
|
95
|
+
// x-kz-dir header, so a phone choosing another project never moves this tab.
|
|
96
|
+
const boardState = locate();
|
|
97
|
+
if (boardState && dirname(boardState) !== home) remember(dirname(boardState));
|
|
98
98
|
// The "Projects folder" setting, resolved; empty when unset.
|
|
99
99
|
const projectsRoot = async () => {
|
|
100
100
|
const { listSettings } = await import("./settings.ts");
|
|
@@ -125,11 +125,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
125
125
|
.filter((d) => existsSync(join(d, ".kaizen")) ? (remember(d), false) : true).sort();
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
const states = (all: boolean) => (all || !
|
|
128
|
+
const states = (all: boolean, cur = boardState) => (all || !cur
|
|
129
129
|
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
130
|
-
: [
|
|
130
|
+
: [cur]
|
|
131
131
|
).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
|
|
132
132
|
|
|
133
|
+
// Hostnames the user put in KAIZEN_WEB_HOSTS (e.g. a `tailscale serve` name):
|
|
134
|
+
// their explicit opt-in, matched exactly, empty when unset.
|
|
135
|
+
const extraHosts = new Set((process.env.KAIZEN_WEB_HOSTS ?? "").split(",")
|
|
136
|
+
.map((s) => s.trim().toLowerCase().replace(/:\d+$/, "")).filter(Boolean));
|
|
137
|
+
|
|
133
138
|
// Any tab on this machine can reach loopback. GET only reads the user's own
|
|
134
139
|
// files, but a POST from a page on the wider web must not write them.
|
|
135
140
|
const local = (req: Request) => {
|
|
@@ -137,7 +142,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
137
142
|
if (!origin) return true; // curl, same-origin fetch on old browsers
|
|
138
143
|
try {
|
|
139
144
|
const h = new URL(origin).hostname;
|
|
140
|
-
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
|
|
145
|
+
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1" || extraHosts.has(h);
|
|
141
146
|
} catch { return false; }
|
|
142
147
|
};
|
|
143
148
|
const json = (body: unknown, status = 200) =>
|
|
@@ -281,10 +286,13 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
281
286
|
async fetch(req, srv) {
|
|
282
287
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
283
288
|
// arrives with that hostname in Host, and gets nothing.
|
|
284
|
-
const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
|
|
285
|
-
if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
|
|
289
|
+
const hostname = (req.headers.get("host") ?? "").toLowerCase().replace(/:\d+$/, "");
|
|
290
|
+
if (hostname !== "127.0.0.1" && hostname !== "localhost" && !extraHosts.has(hostname)) return new Response("forbidden", { status: 403 });
|
|
286
291
|
const url = new URL(req.url);
|
|
287
292
|
const path = url.pathname;
|
|
293
|
+
const asked = req.headers.get("x-kz-dir");
|
|
294
|
+
const state = asked && states(true).includes(asked) ? asked : boardState;
|
|
295
|
+
const project = state ? label(state) : null;
|
|
288
296
|
|
|
289
297
|
if (req.method === "GET" && path === "/") {
|
|
290
298
|
const page = served(PAGE);
|
|
@@ -355,11 +363,20 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
355
363
|
return new Response(Bun.file(LOGO), { headers: { "content-type": "image/png", "cache-control": "max-age=86400" } });
|
|
356
364
|
}
|
|
357
365
|
|
|
366
|
+
// Lets a phone add the board to its home screen as a standalone app.
|
|
367
|
+
if (req.method === "GET" && path === "/manifest.webmanifest") {
|
|
368
|
+
return new Response(JSON.stringify({
|
|
369
|
+
name: "kaizen", short_name: "kaizen", start_url: "/", scope: "/", display: "standalone",
|
|
370
|
+
background_color: "#0c162f", theme_color: "#0c162f",
|
|
371
|
+
icons: [{ src: "/logo.png", sizes: "1024x1024", type: "image/png", purpose: "any" }],
|
|
372
|
+
}), { headers: { "content-type": "application/manifest+json" } });
|
|
373
|
+
}
|
|
374
|
+
|
|
358
375
|
if (req.method === "GET" && path === "/state") {
|
|
359
376
|
const all = url.searchParams.get("all") === "1";
|
|
360
377
|
// Before the project list: it registers folders that turn out to be set up.
|
|
361
378
|
const unset = await fresh();
|
|
362
|
-
const dirs = states(all);
|
|
379
|
+
const dirs = states(all, state);
|
|
363
380
|
// With a projects folder set, the picker offers only real projects inside it,
|
|
364
381
|
// and always the one open now so it never hides where the board is.
|
|
365
382
|
const root = await projectsRoot();
|
|
@@ -417,16 +434,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
417
434
|
// says which project it belongs to. Nothing is assumed from where the board started.
|
|
418
435
|
const dir = url.searchParams.get("dir");
|
|
419
436
|
if (dir && !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
420
|
-
const sources = [], icebox = [], failed = [];
|
|
437
|
+
const sources = [], icebox = [], dismissed = [], failed = [];
|
|
421
438
|
for (const d of dir ? [dir] : states(true)) {
|
|
422
439
|
// The seen list is bookkeeping, not something the page shows.
|
|
423
440
|
try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, social: socialOf(s.url), dir: d, project: label(d) }))); }
|
|
424
441
|
catch { failed.push(label(d)); }
|
|
425
442
|
// Only what still waits for a decision; promoted and dismissed items are record.
|
|
426
|
-
|
|
443
|
+
// Dismissed ones come apart, for the Dismissed category, where they can be restored.
|
|
444
|
+
try {
|
|
445
|
+
const all = readIcebox(d).map((s) => ({ ...s, post: postLink(s.notes), dir: d, project: label(d) }));
|
|
446
|
+
icebox.push(...all.filter((s) => s.status === "new"));
|
|
447
|
+
dismissed.push(...all.filter((s) => s.status === "dismissed"));
|
|
448
|
+
}
|
|
427
449
|
catch { failed.push(label(d) + " (icebox)"); }
|
|
428
450
|
}
|
|
429
|
-
return json({ sources, icebox, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
|
|
451
|
+
return json({ sources, icebox, dismissed, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
|
|
430
452
|
}
|
|
431
453
|
|
|
432
454
|
// The Run form's choices for one project: installed tools and their models.
|
|
@@ -553,6 +575,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
553
575
|
return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
|
|
554
576
|
}
|
|
555
577
|
|
|
578
|
+
// An icebox image, sent on to a link read fresh from its source: the one kept
|
|
579
|
+
// since the gather may have expired. Only an https link the item holds is followed.
|
|
580
|
+
if (req.method === "GET" && path === "/ice-image") {
|
|
581
|
+
const dir = url.searchParams.get("dir") ?? "";
|
|
582
|
+
if (!states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
583
|
+
const u = (await freshImages(dir, url.searchParams.get("id") ?? ""))[Number(url.searchParams.get("i"))];
|
|
584
|
+
if (!u || !/^https:\/\//.test(u)) return bad("no such image", 404);
|
|
585
|
+
return new Response(null, { status: 302, headers: { location: u, "cache-control": "no-store", "referrer-policy": "no-referrer" } });
|
|
586
|
+
}
|
|
587
|
+
|
|
556
588
|
if (req.method === "GET" && path === "/events") {
|
|
557
589
|
let ctrl: ReadableStreamDefaultController;
|
|
558
590
|
const stream = new ReadableStream({
|
|
@@ -593,14 +625,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
593
625
|
}
|
|
594
626
|
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
595
627
|
|
|
596
|
-
//
|
|
597
|
-
// and the process follows so anything resolved from cwd agrees.
|
|
628
|
+
// Only checks the project: the page that picked it sends it back in x-kz-dir.
|
|
598
629
|
if (path === "/cd") {
|
|
599
|
-
state = dir;
|
|
600
|
-
project = label(dir);
|
|
601
|
-
try { process.chdir(dirname(dir)); } catch { /* the state dir is what matters */ }
|
|
602
630
|
changed();
|
|
603
|
-
return json({ ok: true, project });
|
|
631
|
+
return json({ ok: true, project: label(dir) });
|
|
604
632
|
}
|
|
605
633
|
|
|
606
634
|
if (path === "/inbox") {
|
|
@@ -690,7 +718,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
690
718
|
const ids = Array.isArray(body.ids) ? body.ids.filter((i: unknown) => typeof i === "string") : [];
|
|
691
719
|
try {
|
|
692
720
|
const why = body.op === "promote" ? promote(body.dir, ids, typeof body.text === "string" ? body.text : undefined, typeof body.details === "string" ? body.details : undefined)
|
|
693
|
-
: body.op === "dismiss" ? dismiss(body.dir, ids) : "unknown op";
|
|
721
|
+
: body.op === "dismiss" ? dismiss(body.dir, ids) : body.op === "restore" ? restore(body.dir, ids) : "unknown op";
|
|
694
722
|
if (why) return bad(why, why === "no such item" ? 404 : why === "unknown op" ? 400 : 409);
|
|
695
723
|
} catch (e) { return bad((e as Error).message, 500); }
|
|
696
724
|
changed();
|
|
@@ -922,7 +950,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
922
950
|
|
|
923
951
|
const url = `http://127.0.0.1:${server.port}/`;
|
|
924
952
|
console.log(`\n kaizen web ${url}`);
|
|
925
|
-
console.log(` ${
|
|
953
|
+
console.log(` ${boardState ? `project ${label(boardState)}` : "no .kaizen here; showing every known project"}`);
|
|
926
954
|
console.log(` ctrl-c to stop\n`);
|
|
927
955
|
if (opts.open !== false) await openApp(url);
|
|
928
956
|
|
package/package.json
CHANGED
package/skills/kaizen/gather.md
CHANGED
|
@@ -31,7 +31,10 @@ cannot judge on its own.
|
|
|
31
31
|
account or a key the user has not set up for this.
|
|
32
32
|
3. Hand what you read to kaizen. With your file-writing tool, not the shell, write the
|
|
33
33
|
requests to a new temporary file outside the project, under a name you choose: one
|
|
34
|
-
request per line, optionally a tab and then notes for that request.
|
|
34
|
+
request per line, optionally a tab and then notes for that request. A picture the source
|
|
35
|
+
shows beside a request goes in its notes as `img https://its-address`, one token per
|
|
36
|
+
image; kaizen keeps those as the item's images. Lines from `kaizen gather --posts`
|
|
37
|
+
already carry theirs: copy them unchanged. Leave out any
|
|
35
38
|
request the source marks Done or Finish. Then run:
|
|
36
39
|
|
|
37
40
|
```
|
package/skills/kaizen/spec.md
CHANGED
|
@@ -86,7 +86,9 @@ Record the track in `state.json` as `track`, with the three answers written into
|
|
|
86
86
|
|
|
87
87
|
Create the run id as `YYYY-MM-DD-<short-kebab-slug>`. Write the request verbatim —
|
|
88
88
|
do not summarize it, since later stages read this instead of the conversation and a
|
|
89
|
-
summary is where requirements quietly disappear.
|
|
89
|
+
summary is where requirements quietly disappear. The board also finds a run's card by
|
|
90
|
+
this text: a request that does not contain the user's words leaves their idea as a
|
|
91
|
+
second card. Context you add goes below the verbatim text, never in place of it.
|
|
90
92
|
|
|
91
93
|
Verbatim includes the mess. Requests arrive as thinking out loud: half-formed,
|
|
92
94
|
out of order, self-correcting, with the decisive constraint tucked into an aside near
|
|
@@ -110,6 +112,8 @@ Write into `00-request.md`: the verbatim request, the deliverable, what done mea
|
|
|
110
112
|
and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `agent`, `track`,
|
|
111
113
|
`stage: "plan"`, `awaiting: null`, `iteration: 0`, and `updated` — the time of this
|
|
112
114
|
write, refreshed on every later one.
|
|
115
|
+
Where the environment has `CLAUDE_CODE_SESSION_ID`, also record it as `session`: the
|
|
116
|
+
board keeps the run on the card it was launched from by that id.
|
|
113
117
|
|
|
114
118
|
`stage` takes exactly one of five words, and boards key their columns on them:
|
|
115
119
|
`plan`, `build`, `review`, `done`, `abandoned`. Set it when the stage starts —
|
package/web/board/api.ts
CHANGED
|
@@ -7,11 +7,14 @@ import { notifyUser, toast } from "./toast.ts";
|
|
|
7
7
|
// The server writes notifier() from state.ts over a marker in board.html: one rule for both UIs.
|
|
8
8
|
declare const due: (key: string, say: string | null, done: boolean, now: number) => boolean;
|
|
9
9
|
|
|
10
|
+
// This page's project, sent with each request: the server keeps none, so another
|
|
11
|
+
// tab or a phone picking a project never moves this one.
|
|
12
|
+
export const dirHeader = () => (store.dir ? { "x-kz-dir": store.dir } : {});
|
|
10
13
|
// quiet: the server said something changed, nobody asked for a redraw.
|
|
11
14
|
export async function load(quiet) {
|
|
12
15
|
let r;
|
|
13
16
|
try {
|
|
14
|
-
r = await fetch("/state" + (store.all ? "?all=1" : ""));
|
|
17
|
+
r = await fetch("/state" + (store.all ? "?all=1" : ""), { headers: dirHeader() });
|
|
15
18
|
} catch {
|
|
16
19
|
toast(GONE);
|
|
17
20
|
return;
|
|
@@ -85,7 +88,7 @@ export async function post(path, body) {
|
|
|
85
88
|
try {
|
|
86
89
|
r = await fetch(path, {
|
|
87
90
|
method: "POST",
|
|
88
|
-
headers: { "content-type": "application/json" },
|
|
91
|
+
headers: { "content-type": "application/json", ...dirHeader() },
|
|
89
92
|
body: JSON.stringify(body),
|
|
90
93
|
});
|
|
91
94
|
} catch {
|
package/web/board/icebox.ts
CHANGED
|
@@ -134,7 +134,9 @@ export async function openSources() {
|
|
|
134
134
|
return "link";
|
|
135
135
|
};
|
|
136
136
|
const list = (d && d.sources) || [],
|
|
137
|
-
|
|
137
|
+
// Dismissed items ride along, shown only under their own category.
|
|
138
|
+
ice = [...((d && d.icebox) || []), ...((d && d.dismissed) || [])],
|
|
139
|
+
waiting = ((d && d.icebox) || []).length,
|
|
138
140
|
G = (d && d.gatherer) || {},
|
|
139
141
|
chatOn = G.use === "chatgpt" && G.connected;
|
|
140
142
|
// A source belongs to one project, named on its row. Picking another only arms Move: a select
|
|
@@ -186,7 +188,7 @@ export async function openSources() {
|
|
|
186
188
|
srcKey = (it) => it.dir + " " + it.source;
|
|
187
189
|
// Filters narrow in turn: project (the board's own picker, all projects only), then
|
|
188
190
|
// category, then source; each chip counts what the filters before it leave.
|
|
189
|
-
const catOf = (it) => it.category || "Uncategorised",
|
|
191
|
+
const catOf = (it) => (it.status === "dismissed" ? "Dismissed" : it.category || "Uncategorised"),
|
|
190
192
|
count = (xs, f) => {
|
|
191
193
|
const m = new Map();
|
|
192
194
|
xs.forEach((it) => {
|
|
@@ -213,7 +215,7 @@ export async function openSources() {
|
|
|
213
215
|
if (iceForm && !keys.has(iceForm)) iceForm = null;
|
|
214
216
|
const groups = new Map();
|
|
215
217
|
ice.forEach((it, i) => {
|
|
216
|
-
if (!inProj(it) || (iceCat
|
|
218
|
+
if (!inProj(it) || (iceCat ? catOf(it) !== iceCat : it.status === "dismissed") || (iceSrc && srcKey(it) !== iceSrc)) return;
|
|
217
219
|
const k = catOf(it);
|
|
218
220
|
groups.has(k) || groups.set(k, []);
|
|
219
221
|
groups.get(k).push(i);
|
|
@@ -227,20 +229,13 @@ export async function openSources() {
|
|
|
227
229
|
}
|
|
228
230
|
if (!order.some((i) => iceKey(ice[i]) === iceCur)) iceCur = order.length ? iceKey(ice[order[0]]) : null;
|
|
229
231
|
const nSel = order.filter((i) => iceSel.has(iceKey(ice[i]))).length;
|
|
230
|
-
// Filters are plain dropdowns, each shown only when it has a choice to make.
|
|
231
|
-
//
|
|
232
|
+
// Filters are plain dropdowns, each shown only when it has a choice to make. Projects are
|
|
233
|
+
// narrowed by the board's own Showing picker above, not here.
|
|
232
234
|
const many = byProj.size > 1,
|
|
233
235
|
opt = (v, on, t) => `<option value="${esc(v)}"${on ? " selected" : ""}>${esc(t)}</option>`;
|
|
234
236
|
const pick = (id, label, xs) =>
|
|
235
237
|
`<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
|
|
236
238
|
const chips =
|
|
237
|
-
(many
|
|
238
|
-
? pick("ice-fp", "Project", [
|
|
239
|
-
opt("", !store.shown.size, "All projects"),
|
|
240
|
-
...(store.shown.size > 1 ? [opt("*", true, store.shown.size + " projects")] : []),
|
|
241
|
-
...[...byProj].map(([k, { it }]) => opt(k, store.shown.size === 1 && store.shown.has(k), it.project || k)),
|
|
242
|
-
])
|
|
243
|
-
: "") +
|
|
244
239
|
(byCat.size > 1
|
|
245
240
|
? pick("ice-fc", "Category", [
|
|
246
241
|
opt("", !iceCat, "All categories"),
|
|
@@ -253,7 +248,7 @@ export async function openSources() {
|
|
|
253
248
|
...[...bySrc].map(([k, { it }]) => opt(k, iceSrc === k, srcName(it))),
|
|
254
249
|
])
|
|
255
250
|
: "");
|
|
256
|
-
const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${order.length
|
|
251
|
+
const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" ? `${order.length} dismissed` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
|
|
257
252
|
const bulk = iceSel.size
|
|
258
253
|
? `<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Each becomes its own idea, titled as gathered (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`
|
|
259
254
|
: "";
|
|
@@ -282,16 +277,16 @@ export async function openSources() {
|
|
|
282
277
|
k = iceKey(it),
|
|
283
278
|
open = iceForm === k;
|
|
284
279
|
return (
|
|
285
|
-
`<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button
|
|
280
|
+
`<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button>${it.status === "dismissed" ? `<button class="btn ghost sm" id="ice-rs-${i}" data-rs="${i}" title="Restore to waiting" aria-label="Restore: ${esc(it.text)}">Restore</button>` : `<button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button>`}</span></span></div>` +
|
|
286
281
|
(open
|
|
287
|
-
? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post ? ` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${form(it)}</div>`
|
|
282
|
+
? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}${(it.images || []).filter((u) => /^https:\/\//.test(u)).length ? `<details class="ice-imgs"><summary>Show images (${it.images.filter((u) => /^https:\/\//.test(u)).length}) — loads them from the source's site</summary>${it.images.filter((u) => /^https:\/\//.test(u)).map((u, n) => { const src = `/ice-image?dir=${encodeURIComponent(it.dir)}&id=${encodeURIComponent(it.id)}&i=${n}`; return `<a href="${esc(src)}" target="_blank" rel="noopener noreferrer"><img data-src="${esc(src)}" alt="" referrerpolicy="no-referrer"></a>`; }).join("")}</details>` : ""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post ? ` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${form(it)}</div>`
|
|
288
283
|
: "")
|
|
289
284
|
);
|
|
290
285
|
};
|
|
291
286
|
const inbox =
|
|
292
|
-
|
|
293
|
-
? `<div class="ice-zero"><h4>Nothing matches</h4><p>${
|
|
294
|
-
:
|
|
287
|
+
waiting && !order.length
|
|
288
|
+
? `<div class="ice-zero"><h4>Nothing matches</h4><p>${waiting} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
|
|
289
|
+
: order.length
|
|
295
290
|
? `<div class="ice-list">${[...groups].map(([g, ix]) => `<div class="ice-cat">${esc(g)}<i>${ix.length}</i></div>${ix.map(row).join("")}`).join("")}</div>`
|
|
296
291
|
: `<div class="ice-zero"><h4>Inbox zero</h4><p>Nothing waiting. Gather all checks your sources for new requests; /kaizen discover in your agent sorts what comes in.</p></div>`;
|
|
297
292
|
// The sources, in the right-hand drawer: shut until asked for, open by itself while there
|
|
@@ -398,14 +393,6 @@ export async function openSources() {
|
|
|
398
393
|
iceCat = e.target.value;
|
|
399
394
|
openSources();
|
|
400
395
|
};
|
|
401
|
-
if ($("ice-fp"))
|
|
402
|
-
$("ice-fp").onchange = (e) => {
|
|
403
|
-
const v = e.target.value;
|
|
404
|
-
if (v === "*") return;
|
|
405
|
-
store.shown = new Set(v ? [v] : []);
|
|
406
|
-
setShown();
|
|
407
|
-
openSources();
|
|
408
|
-
};
|
|
409
396
|
if ($("ice-unf"))
|
|
410
397
|
$("ice-unf").onclick = () => {
|
|
411
398
|
iceCat = iceSrc = "";
|
|
@@ -573,6 +560,10 @@ export async function openSources() {
|
|
|
573
560
|
$("pbody")
|
|
574
561
|
.querySelectorAll("[data-o]")
|
|
575
562
|
.forEach((b) => (b.onclick = () => toggle(+b.dataset.o)));
|
|
563
|
+
// Images from a source load only when asked: fetching one tells its host the item was opened.
|
|
564
|
+
$("pbody")
|
|
565
|
+
.querySelectorAll("details.ice-imgs")
|
|
566
|
+
.forEach((d) => (d.ontoggle = () => d.open && d.querySelectorAll("img[data-src]").forEach((i) => (i.src = i.dataset.src))));
|
|
576
567
|
$("pbody")
|
|
577
568
|
.querySelectorAll("[data-pr]")
|
|
578
569
|
.forEach(
|
|
@@ -591,6 +582,15 @@ export async function openSources() {
|
|
|
591
582
|
iceAct("dismiss", ice[+b.dataset.ds], "Dismissed");
|
|
592
583
|
}),
|
|
593
584
|
);
|
|
585
|
+
$("pbody")
|
|
586
|
+
.querySelectorAll("[data-rs]")
|
|
587
|
+
.forEach(
|
|
588
|
+
(b) =>
|
|
589
|
+
(b.onclick = () => {
|
|
590
|
+
b.disabled = true;
|
|
591
|
+
iceAct("restore", ice[+b.dataset.rs], "Restored");
|
|
592
|
+
}),
|
|
593
|
+
);
|
|
594
594
|
const open1 = ice.find((it) => iceKey(it) === iceForm);
|
|
595
595
|
if ($("ice-rp"))
|
|
596
596
|
$("ice-rp").onclick = async () => {
|
package/web/board/live.ts
CHANGED
package/web/board/projects.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
|
|
|
2
2
|
import { load, post } from "./api.ts";
|
|
3
3
|
import { render } from "./cards.ts";
|
|
4
4
|
import { $, esc } from "./dom.ts";
|
|
5
|
-
import { openSources } from "./icebox.ts";
|
|
5
|
+
import { openSources, srcOpen } from "./icebox.ts";
|
|
6
6
|
import { toast } from "./toast.ts";
|
|
7
7
|
import { view } from "./views.ts";
|
|
8
8
|
|
|
@@ -52,6 +52,7 @@ async function pick(v) {
|
|
|
52
52
|
const j = await post("/cd", { dir: v });
|
|
53
53
|
if (j.ok) {
|
|
54
54
|
store.all = false;
|
|
55
|
+
store.dir = v;
|
|
55
56
|
keep(v);
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -144,6 +145,8 @@ export function setShown() {
|
|
|
144
145
|
localStorage.setItem("kz-show", JSON.stringify([...store.shown]));
|
|
145
146
|
} catch (e) {}
|
|
146
147
|
render();
|
|
148
|
+
// The Icebox narrows by the same picker, so it redraws too.
|
|
149
|
+
if (srcOpen()) openSources();
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
export function initProjects() {
|
package/web/board/store.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export const store = {
|
|
5
5
|
S: { project: null, projects: [], cards: [], now: Date.now(), all: false },
|
|
6
6
|
all: new URLSearchParams(location.search).get("all") !== "0",
|
|
7
|
+
// The project this page picked; null is wherever the board started.
|
|
8
|
+
dir: null,
|
|
7
9
|
openKey: null,
|
|
8
10
|
mode: null,
|
|
9
11
|
modeKey: null,
|
package/web/board.html
CHANGED
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
6
6
|
<title>kaizen</title>
|
|
7
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
8
|
+
<meta name="theme-color" content="#f6f8fc" media="(prefers-color-scheme: light)">
|
|
9
|
+
<meta name="theme-color" content="#0c162f" media="(prefers-color-scheme: dark)">
|
|
10
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
11
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
12
|
+
<meta name="apple-mobile-web-app-title" content="kaizen">
|
|
13
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
7
14
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
8
15
|
<link rel="icon" type="image/png" href="/logo.png">
|
|
9
16
|
<link rel="apple-touch-icon" href="/logo.png">
|
|
@@ -49,7 +56,7 @@ svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke
|
|
|
49
56
|
/* end side menu */
|
|
50
57
|
/* the work area: an inset rounded panel on the canvas the side menu sits on */
|
|
51
58
|
.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}
|
|
52
|
-
@media(max-width:640px){.app{margin:0;border:0;border-radius:0}}
|
|
59
|
+
@media(max-width:640px){.app{margin:0;border:0;border-radius:0}body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}aside:not(.docked){padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) 0}}
|
|
53
60
|
/* header */
|
|
54
61
|
.hdr{display:flex;align-items:center;gap:12px;padding:10px 20px 10px 28px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);flex-wrap:wrap}
|
|
55
62
|
pre.mark{margin:0;font:700 8px/1 var(--mono)}
|
|
@@ -378,6 +385,7 @@ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
|
|
|
378
385
|
.ice-form-acts .sel{width:auto;flex:none}
|
|
379
386
|
.ice-form-acts .end{flex:1}
|
|
380
387
|
.ice-form-acts .btn{white-space:nowrap}
|
|
388
|
+
.ice-imgs{font-size:12px;color:var(--dim)}.ice-imgs[open]{display:flex;gap:6px;flex-wrap:wrap}.ice-imgs summary{cursor:pointer;flex-basis:100%}.ice-imgs img{height:72px;max-width:120px;object-fit:cover;border-radius:var(--r-s);border:1px solid var(--line)}
|
|
381
389
|
.ice-notes{font-size:12px;color:var(--dim);line-height:1.45;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
382
390
|
.src-empty{padding:26px 16px;text-align:center;background:var(--bg);border:1px dashed var(--line);border-radius:var(--r);margin:12px 0 16px}
|
|
383
391
|
.src-empty-ico{width:36px;height:36px;margin:0 auto 10px;color:var(--dim);display:flex;align-items:center;justify-content:center}
|