kaizen-agent 1.32.0 → 1.33.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.
@@ -0,0 +1,869 @@
1
+ import { store } from "./store.ts";
2
+ import { termStop } from "./agent-term.ts";
3
+ import { load, post } from "./api.ts";
4
+ import { via } from "./cards.ts";
5
+ import { stopDictation } from "./dictation.ts";
6
+ import { $, esc, ico } from "./dom.ts";
7
+ import { setShown, showable } from "./projects.ts";
8
+ import { toast } from "./toast.ts";
9
+ import { go, view } from "./views.ts";
10
+
11
+ // ---- the icebox: requests gathered from request sources, read like an inbox, until
12
+ // someone promotes them to ideas or dismisses them. The sources themselves sit in a
13
+ // section below, folded. Same panel as settings.
14
+ const SRC = "Icebox";
15
+ // What the page keeps across the rebuild every server change brings: the item whose
16
+ // promote form is open and the one the cursor is on (both "<dir> <id>"), the checked
17
+ // items, the source filter ("<dir> <url>"), whether the sources section is open, and
18
+ // whether a bulk action is still posting.
19
+ // The category filter, and whether the add form takes a link or a social account/tag. The
20
+ // project filter is the board's own `shown`, so the board and the icebox narrow alike.
21
+ // The reply being written to a gathered post: its item, the text, and whether it is out.
22
+ // The reply style is per project: a promo voice in one need not leak into another's drafts.
23
+ let iceReply = null;
24
+ const iceStyle = {};
25
+ const rStyle = (dir) => {
26
+ if (!iceStyle[dir])
27
+ try {
28
+ iceStyle[dir] = localStorage.getItem("kz-reply-style:" + dir);
29
+ } catch (e) {}
30
+ return iceStyle[dir] || "casual";
31
+ };
32
+ const STYLES = { casual: "Casual", fun: "Fun", helpful: "Helpful", promo: "Promo" };
33
+ export let iceForm = null,
34
+ iceCur = null,
35
+ iceSrc = "",
36
+ iceCat = "",
37
+ srcKind = "link",
38
+ srcShow = null,
39
+ srcAdd = false,
40
+ iceBusy = false,
41
+ iceNav = null;
42
+ const iceSel = new Set();
43
+ const PLAT = { x: "X", tiktok: "TikTok", instagram: "Instagram", threads: "Threads" };
44
+ export const srcOpen = () => $("panel").classList.contains("open") && $("ptitle").textContent === SRC;
45
+ const ago = (at) => {
46
+ const m = Math.round((Date.now() - Date.parse(at)) / 60000);
47
+ return m < 1
48
+ ? "just now"
49
+ : m < 60
50
+ ? m + " min ago"
51
+ : m < 1440
52
+ ? Math.round(m / 60) + " h ago"
53
+ : Math.round(m / 1440) + " d ago";
54
+ };
55
+ export async function openSources() {
56
+ if (view !== "sources") return;
57
+ const gen = ++store.pageGen;
58
+ stopDictation(true);
59
+ // What is half typed survives a refresh from the server.
60
+ const typed = srcOpen()
61
+ ? [
62
+ $("src-url")?.value || "",
63
+ $("src-label")?.value || "",
64
+ document.activeElement?.id,
65
+ $("src-dir")?.value,
66
+ $("src-cat")?.value || "",
67
+ $("src-plat")?.value,
68
+ $("src-q")?.value || "",
69
+ ]
70
+ : ["", ""];
71
+ // So does a project picked on a row but not yet moved.
72
+ const picked = new Map(
73
+ srcOpen() ? [...$("pbody").querySelectorAll("[data-mv]")].map((m) => [m.dataset.k, m.value]) : [],
74
+ );
75
+ // And a promote form half filled, for the item it was typed on.
76
+ // And which control of which inbox row had focus, by item key: row indices shift when a row goes.
77
+ const fRow = srcOpen() && document.activeElement?.closest?.(".ice-row"),
78
+ fPick = fRow ? [fRow.dataset.k, document.activeElement.id.replace(/-\d+$/, "")] : null;
79
+ const iceDraft =
80
+ srcOpen() && $("ice-title") ? [$("ice-title").dataset.k, $("ice-title").value, $("ice-details").value] : null;
81
+ // And a category typed on a row but not yet saved.
82
+ const cats = new Map(
83
+ srcOpen() ? [...$("pbody").querySelectorAll("[data-ct]")].map((m) => [m.dataset.k, m.value]) : [],
84
+ );
85
+ if (!srcOpen()) $("pbody").innerHTML = `<p class="snone">Loading…</p>`;
86
+ termStop();
87
+ store.openKey = null;
88
+ store.runDoc = null;
89
+ $("pterm").hidden = true;
90
+ $("panel").classList.add("open");
91
+ $("scrim").classList.add("on");
92
+ $("ptitle").textContent = SRC;
93
+ $("ptabs").innerHTML = "";
94
+ $("pfoot").innerHTML = "";
95
+ $("psub").textContent = "";
96
+ $("psub").title = "";
97
+ const dir = store.all ? null : store.S.dir,
98
+ q = dir ? "?dir=" + encodeURIComponent(dir) : "";
99
+ const d = await fetch("/sources" + q)
100
+ .then((r) => r.json())
101
+ .catch(() => null);
102
+ if (gen !== store.pageGen || !srcOpen()) return;
103
+ const host = (u) => {
104
+ try {
105
+ return new URL(u).hostname;
106
+ } catch {
107
+ return u;
108
+ }
109
+ };
110
+ // The favicon of the site a link is on, asked of that site itself (Google's own static host for
111
+ // Sheets and Docs, whose site icon is generic): nothing but the host reaches anyone. A social
112
+ // platform's mark is drawn here instead: Meta's favicons are same-origin only.
113
+ const fav = (u, fb, soc) => {
114
+ if (PLAT[soc]) return `<span class="fav">${ico("b-" + soc)}</span>`;
115
+ let h;
116
+ try {
117
+ const x = new URL(u);
118
+ if (/^https?:$/.test(x.protocol)) h = x.origin;
119
+ } catch {}
120
+ const src = !h
121
+ ? ""
122
+ : u.includes("docs.google.com/spreadsheets")
123
+ ? "https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico"
124
+ : u.includes("docs.google.com/document")
125
+ ? "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico"
126
+ : h + "/favicon.ico";
127
+ return `<span class="fav">${src ? `<img src="${esc(src)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">` : ""}${ico(fb)}</span>`;
128
+ };
129
+ const srcType = (s) => {
130
+ if (s.social) return "social";
131
+ const u = s.url;
132
+ if (u.includes("docs.google.com/spreadsheets") || u.endsWith(".csv") || u.endsWith(".tsv")) return "sheet";
133
+ if (u.includes("docs.google.com/document")) return "doc";
134
+ return "link";
135
+ };
136
+ const list = (d && d.sources) || [],
137
+ ice = (d && d.icebox) || [],
138
+ G = (d && d.gatherer) || {},
139
+ chatOn = G.use === "chatgpt" && G.connected;
140
+ // A source belongs to one project, named on its row. Picking another only arms Move: a select
141
+ // changes on every arrow key, and each change would have moved the source. The names are the picker's;
142
+ // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
143
+ const opts = (on, name) =>
144
+ (on && !store.S.projects.some((p) => p.dir === on)
145
+ ? `<option value="${esc(on)}" selected>${esc(name || on)}</option>`
146
+ : "") +
147
+ store.S.projects
148
+ .map((p) => `<option value="${esc(p.dir)}"${p.dir === on ? " selected" : ""}>${esc(p.label)}</option>`)
149
+ .join("");
150
+ const cards = list
151
+ .map((s, i) => {
152
+ const st = srcType(s),
153
+ title = s.label || host(s.url);
154
+ // A social source is never read by kaizen, so its not-ok is the agent's turn, not a failure.
155
+ const statusHtml =
156
+ s.social && (!s.last || (!s.last.ok && /gathered by your agent/i.test(s.last.note)))
157
+ ? `<span class="src-badge dim">${ico("at")}${chatOn ? "Gathered with ChatGPT" : "Gathered by your agent"}</span>`
158
+ : !s.last
159
+ ? `<span class="src-badge dim">Not gathered yet</span>`
160
+ : s.last.ok
161
+ ? `<span class="src-badge ok"><svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`
162
+ : `<span class="src-badge warn"><svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke="currentColor" fill="none" stroke-width="1.8"/><path d="M8 5v3.5M8 11v.1" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>Failed: ${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`;
163
+ return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st === "social" ? esc(PLAT[s.social] || "social") : st}">${fav(s.url, st === "sheet" ? "sheet" : st === "doc" ? "doc" : st === "social" ? "at" : "link", s.social)}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts">${s.social ? `<button class="btn ghost sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn ? "Read this source with your ChatGPT plan" : "Open your agent to run /kaizen gather for this project"}">${ico("refresh")} Gather</button>` : `<button class="btn ghost sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check this source now">${ico("refresh")} Check</button>`}<button class="btn ghost danger sm" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-url"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(s.url)}</span>${ico("ext")}</a></div><div class="src-status">${statusHtml}</div><div class="src-dest"><span>Category</span><input class="sel" id="src-ct-${i}" data-ct="${i}" data-k="${esc(s.dir + " " + s.url)}" value="${esc(s.category || "")}" placeholder="e.g. Feedback" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></div><div class="src-dest"><span>Project</span><select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir + " " + s.url)}" aria-label="Project of ${esc(title)}">${opts(s.dir, s.project)}</select><button class="btn sm" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></div></div>`;
164
+ })
165
+ .join("");
166
+ const fails = ((d && d.failed) || [])
167
+ .map((f) => `<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`)
168
+ .join("");
169
+ const emptyState = `<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No request sources yet</h4><p>Connect shared links to gather requests into the icebox, where you choose which become ideas. Nothing runs automatically until you start it.</p><div class="src-empty-grid"><div class="src-empty-item"><strong>Google Sheets</strong>Public sheets with idea or task rows. Rows marked Done are skipped.</div><div class="src-empty-item"><strong>Google Docs</strong>Shared public docs with items or notes written line by line.</div><div class="src-empty-item"><strong>Public links</strong>TSV, CSV, or raw text links containing requests.</div><div class="src-empty-item"><strong>Social media</strong>A keyword, #hashtag or @account on X, TikTok, Instagram or Threads, read by your agent.</div></div></div>`;
170
+ const social = srcKind === "social";
171
+ const addOn = !list.length || srcAdd || [typed[0], typed[1], typed[4], typed[6]].some(Boolean);
172
+ const addBox = `<div class="src-add" id="src-addbox"${addOn ? "" : " hidden"}><div class="hd"><h4>Add a source</h4><div class="src-kind" role="group" aria-label="Kind of source"><button id="src-k-link" data-kind="link" aria-pressed="${!social}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${social}">Social</button></div></div><label for="src-url"${social ? " hidden" : ""}><span>Link</span><input class="sel" id="src-url" placeholder="https://docs.google.com/spreadsheets/d/… or any public link" spellcheck="false" autocomplete="off"></label><div class="two plat"${social ? "" : " hidden"}><label for="src-plat"><span>Platform</span><select class="sel" id="src-plat">${Object.entries(
173
+ PLAT,
174
+ )
175
+ .map(([k, v]) => `<option value="${k}">${v}</option>`)
176
+ .join(
177
+ "",
178
+ )}</select></label><label for="src-q"><span>Follow</span><input class="sel" id="src-q" placeholder="keyword, #hashtag or @account" maxlength="100" spellcheck="false" autocomplete="off"></label></div><div class="two"><label for="src-label"><span>Label <i>optional</i></span><input class="sel" id="src-label" placeholder="${social ? "e.g. Mentions on X" : "e.g. Feedback Q4"}"></label><label for="src-cat"><span>Category <i>optional</i></span><input class="sel" id="src-cat" placeholder="${social ? "e.g. Social" : "e.g. Bugs"}" maxlength="40"></label></div><label for="src-dir"><span>Project</span><select class="sel" id="src-dir">${dir ? "" : `<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="hint">${social ? (chatOn ? "Your ChatGPT plan reads public posts when you press Gather on the source." : "Kaizen signs in nowhere. Your agent reads public posts when you press Gather on the source.") : "Public links only. For private docs, run /kaizen gather."}</span><button class="btn pri" id="src-add">Add source</button></div></div>`;
179
+ // The inbox: one row per request, by project when showing all, then by category,
180
+ // narrowed to one source by its chip. Checked, open and cursor items are kept by key.
181
+ const srcName = (it) => {
182
+ const s = list.find((x) => x.dir === it.dir && x.url === it.source);
183
+ return s ? s.label || host(s.url) : host(it.source);
184
+ };
185
+ const iceKey = (it) => it.dir + " " + it.id,
186
+ srcKey = (it) => it.dir + " " + it.source;
187
+ // Filters narrow in turn: project (the board's own picker, all projects only), then
188
+ // category, then source; each chip counts what the filters before it leave.
189
+ const catOf = (it) => it.category || "Uncategorised",
190
+ count = (xs, f) => {
191
+ const m = new Map();
192
+ xs.forEach((it) => {
193
+ const k = f(it);
194
+ m.set(k, { it, n: (m.get(k)?.n || 0) + 1 });
195
+ });
196
+ return m;
197
+ };
198
+ const byProj = dir ? new Map() : count(ice, (it) => it.dir);
199
+ if (!dir)
200
+ for (const pd of store.shown)
201
+ if (!byProj.has(pd)) {
202
+ const p = showable().find((x) => x.dir === pd);
203
+ if (p) byProj.set(pd, { it: { dir: pd, project: p.label }, n: 0 });
204
+ }
205
+ const inProj = (it) => dir || !store.shown.size || store.shown.has(it.dir),
206
+ pv = ice.filter(inProj);
207
+ const byCat = count(pv, catOf);
208
+ if (iceCat && !byCat.has(iceCat)) iceCat = "";
209
+ const cv = pv.filter((it) => !iceCat || catOf(it) === iceCat);
210
+ const bySrc = count(cv, srcKey);
211
+ if (iceSrc && !bySrc.has(iceSrc)) iceSrc = "";
212
+ const keys = new Set(ice.map(iceKey));
213
+ if (iceForm && !keys.has(iceForm)) iceForm = null;
214
+ const groups = new Map();
215
+ ice.forEach((it, i) => {
216
+ if (!inProj(it) || (iceCat && catOf(it) !== iceCat) || (iceSrc && srcKey(it) !== iceSrc)) return;
217
+ const k = catOf(it);
218
+ groups.has(k) || groups.set(k, []);
219
+ groups.get(k).push(i);
220
+ });
221
+ // Rows in the order they are drawn, which is the order j and k walk.
222
+ const order = [...groups.values()].flat();
223
+ // A checked request a filter hides is unchecked: a bulk action only ever acts on what shows.
224
+ {
225
+ const vis = new Set(order.map((i) => iceKey(ice[i])));
226
+ [...iceSel].forEach((k) => vis.has(k) || iceSel.delete(k));
227
+ }
228
+ if (!order.some((i) => iceKey(ice[i]) === iceCur)) iceCur = order.length ? iceKey(ice[order[0]]) : null;
229
+ 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. The project one
231
+ // is the board's own picker: one project, or all; several picked on the board read as such.
232
+ const many = byProj.size > 1,
233
+ opt = (v, on, t) => `<option value="${esc(v)}"${on ? " selected" : ""}>${esc(t)}</option>`;
234
+ const pick = (id, label, xs) =>
235
+ `<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
236
+ 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
+ (byCat.size > 1
245
+ ? pick("ice-fc", "Category", [
246
+ opt("", !iceCat, "All categories"),
247
+ ...[...byCat.keys()].map((k) => opt(k, iceCat === k, k)),
248
+ ])
249
+ : "") +
250
+ (bySrc.size > 1
251
+ ? pick("ice-fs", "Source", [
252
+ opt("", !iceSrc, "All sources"),
253
+ ...[...bySrc].map(([k, { it }]) => opt(k, iceSrc === k, srcName(it))),
254
+ ])
255
+ : "");
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 < ice.length ? `${order.length} of ${ice.length}` : ice.length} 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
+ const bulk = iceSel.size
258
+ ? `<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
+ : "";
260
+ // The open item's form: a clean title for the idea's card and the person's own details;
261
+ // what was gathered is added to the idea's notes by the server.
262
+ const form = (it) =>
263
+ `<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details</label><textarea class="sel" id="ice-details" rows="3" placeholder="What, for whom, why" aria-label="Details"></textarea><div class="ice-form-acts"><span class="hint">Source goes into the notes.</span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
264
+ // A gathered post can be answered: Reply drafts, the box is the approval, Send reply posts that text.
265
+ const replyBox = (it) => {
266
+ if (!it.post) return "";
267
+ if (it.replied) return `<span class="hint" style="margin:0">You replied to this post ${ago(it.replied)}.</span>`;
268
+ const r = iceReply && iceReply.k === iceKey(it) ? iceReply : null;
269
+ const style = (d) =>
270
+ `<select class="sel" id="ice-rst" aria-label="Reply style" title="Promo names kaizen; Helpful may mention it when it fits; Casual and Fun never do"${d ? " disabled" : ""}>${Object.entries(
271
+ STYLES,
272
+ )
273
+ .map(([v, l]) => `<option value="${v}"${v === rStyle(it.dir) ? " selected" : ""}>${l}</option>`)
274
+ .join("")}</select>`;
275
+ if (!r)
276
+ return `<div class="ice-form-acts"><span class="hint">You review it before it posts.</span>${G.connected ? style(false) : ""}<button class="btn sm" id="ice-rp">Reply</button></div>`;
277
+ if (r.wait) return `<div class="ice-form-acts"><span class="hint">Drafting…</span></div>`;
278
+ return `<div class="ice-form"><label for="ice-reply" class="hint" style="margin:0">Your reply${r.note ? ` · ${esc(r.note)}` : ""}</label><textarea class="sel" id="ice-reply" rows="3" maxlength="2000" aria-label="Your reply"${r.busy || r.again ? " disabled" : ""}>${esc(r.text)}</textarea><span class="hint" id="ice-rn" style="margin:0">${r.again ? "Writing another…" : `${r.text.length} chars`}</span><div class="ice-form-acts"><span class="end"></span>${G.connected ? `${style(r.busy || r.again)}<button class="btn ghost sm" id="ice-rg"${r.busy || r.again ? " disabled" : ""}>Regenerate</button>` : ""}<button class="btn ghost sm" id="ice-rc"${r.busy ? " disabled" : ""}>Cancel</button><button class="btn pri sm" id="ice-rs"${r.busy || r.again || !r.text.trim() ? " disabled" : ""}>${r.busy ? "Sending…" : "Send reply"}</button></div></div>`;
279
+ };
280
+ const row = (i) => {
281
+ const it = ice[i],
282
+ k = iceKey(it),
283
+ open = iceForm === k;
284
+ 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><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
+ (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>`
288
+ : "")
289
+ );
290
+ };
291
+ const inbox =
292
+ ice.length && !order.length
293
+ ? `<div class="ice-zero"><h4>Nothing matches</h4><p>${ice.length} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
294
+ : ice.length
295
+ ? `<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
+ : `<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
+ // The sources, in the right-hand drawer: shut until asked for, open by itself while there
298
+ // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
299
+ const drawer = srcShow ?? !list.length,
300
+ slide = drawer && !$("src-sec")?.classList.contains("open");
301
+ const sec = `<div class="src-scrim${drawer && !slide ? " on" : ""}" id="src-scrim"></div><aside class="src-sec${drawer && !slide ? " open" : ""}" id="src-sec" aria-label="Sources"${drawer ? "" : " inert"}><header><div class="t"><b>Sources</b><div class="sub">${d && d.every && d.every !== "off" ? `Auto-check every ${esc(d.every)}` : "Auto-check off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a> · ${G.connected ? `ChatGPT${G.email ? " " + esc(G.email) : ""} <a id="cg-out" role="button" tabindex="0">Disconnect</a>` : `<a id="cg-in" role="button" tabindex="0">Connect ChatGPT</a>`}</div></div><div class="src-hda">${list.length ? `<button class="btn sm" id="src-new" aria-expanded="${addOn}" aria-controls="src-addbox">${ico("plus")} Add</button>` : ""}<button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></div></header><div class="body">${addBox}${list.length ? `<div class="src-list">${cards}</div>` : emptyState}</div></aside>`;
302
+ // First visit: offer the ChatGPT plan for social sources, or keep the agent.
303
+ const onb =
304
+ G.use === "ask" && !G.connected
305
+ ? `<div class="cg-onb"><b>Gather social posts with your ChatGPT plan</b><p>Connect ChatGPT Plus or Pro and Gather reads X, TikTok, Instagram and Threads sources right here, counted against your plan, with no agent to open. Skip it and Gather keeps using your agent. You can change this in Settings.</p><div class="acts"><button class="btn ghost" id="cg-skip">Skip for now</button><button class="btn pri" id="cg-go">Continue with ChatGPT</button></div></div>`
306
+ : "";
307
+ $("pbody").innerHTML =
308
+ d && d.error
309
+ ? `<p style="color:var(--dim)">${esc(d.error)}</p>`
310
+ : !d
311
+ ? `<p style="color:var(--dim)">The icebox could not be read.</p>`
312
+ : onb + bar + bulk + fails + inbox + sec;
313
+ if (!d || d.error) return;
314
+ if (iceDraft && iceDraft[0] === iceForm && $("ice-title")) {
315
+ $("ice-title").value = iceDraft[1];
316
+ $("ice-details").value = iceDraft[2];
317
+ }
318
+ if ($("src-add")) {
319
+ if (typed[5]) $("src-plat").value = typed[5];
320
+ $("src-q").value = typed[6] || "";
321
+ $("src-url").value = typed[0];
322
+ $("src-label").value = typed[1];
323
+ $("src-cat").value = typed[4] || "";
324
+ if (typed[3] !== undefined && $("src-dir")) $("src-dir").value = typed[3];
325
+ }
326
+ if ($("src-to-settings"))
327
+ $("src-to-settings").onclick = () => {
328
+ store.setQ = "sources";
329
+ go("settings", true);
330
+ };
331
+ // The tab is opened inside the click, before the sign-in URL comes back: a popup blocker
332
+ // lets only that through. The board hears of the result through /events.
333
+ const connect = async () => {
334
+ const w = window.open("", "_blank");
335
+ const j = await post("/chatgpt", { op: "connect" });
336
+ if (j.ok && w) w.location = j.url;
337
+ else {
338
+ w?.close();
339
+ if (j.ok) location.href = j.url;
340
+ }
341
+ };
342
+ for (const id of ["cg-go", "cg-in"]) if ($(id)) $(id).onclick = connect;
343
+ if ($("cg-skip"))
344
+ $("cg-skip").onclick = async () => {
345
+ if ((await post("/chatgpt", { op: "skip" })).ok) {
346
+ toast("Gather keeps using your agent");
347
+ openSources();
348
+ }
349
+ };
350
+ if ($("cg-out"))
351
+ $("cg-out").onclick = async () => {
352
+ if (!confirm("Disconnect ChatGPT? Gather goes back to your agent.")) return;
353
+ if ((await post("/chatgpt", { op: "disconnect" })).ok) {
354
+ toast("ChatGPT disconnected");
355
+ openSources();
356
+ }
357
+ };
358
+ if (slide) {
359
+ $("src-sec").offsetWidth;
360
+ $("src-sec").classList.add("open");
361
+ $("src-scrim").classList.add("on");
362
+ }
363
+ const srcShut = () => {
364
+ srcShow = false;
365
+ $("src-sec").classList.remove("open");
366
+ $("src-scrim").classList.remove("on");
367
+ $("src-sec").inert = true;
368
+ $("src-tog").setAttribute("aria-expanded", "false");
369
+ $("src-tog").focus();
370
+ };
371
+ $("src-x").onclick = $("src-scrim").onclick = srcShut;
372
+ if ($("src-new"))
373
+ $("src-new").onclick = () => {
374
+ const b = $("src-addbox");
375
+ srcAdd = b.hidden;
376
+ b.hidden = !srcAdd;
377
+ $("src-new").setAttribute("aria-expanded", srcAdd);
378
+ if (srcAdd) $(srcKind === "social" ? "src-q" : "src-url").focus();
379
+ };
380
+ $("src-sec").onkeydown = (e) => {
381
+ if (e.key === "Escape") {
382
+ e.stopPropagation();
383
+ srcShut();
384
+ }
385
+ };
386
+ $("src-tog").onclick = () => {
387
+ if ($("src-sec").classList.contains("open")) return srcShut();
388
+ srcShow = true;
389
+ openSources().then(() => srcShow && $("src-url")?.focus());
390
+ };
391
+ if ($("ice-fs"))
392
+ $("ice-fs").onchange = (e) => {
393
+ iceSrc = e.target.value;
394
+ openSources();
395
+ };
396
+ if ($("ice-fc"))
397
+ $("ice-fc").onchange = (e) => {
398
+ iceCat = e.target.value;
399
+ openSources();
400
+ };
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
+ if ($("ice-unf"))
410
+ $("ice-unf").onclick = () => {
411
+ iceCat = iceSrc = "";
412
+ if (!dir) store.shown.clear();
413
+ setShown();
414
+ openSources();
415
+ };
416
+ $("pbody")
417
+ .querySelectorAll("[data-kind]")
418
+ .forEach(
419
+ (b) =>
420
+ (b.onclick = () => {
421
+ if (srcKind === b.dataset.kind) return;
422
+ srcKind = b.dataset.kind;
423
+ openSources().then(() => $(srcKind === "social" ? "src-q" : "src-url")?.focus());
424
+ }),
425
+ );
426
+ const act = async (body, done) => {
427
+ const j = await post("/sources", body);
428
+ if (j.ok) {
429
+ done(j);
430
+ load();
431
+ }
432
+ if (srcOpen()) openSources();
433
+ };
434
+ $("pbody")
435
+ .querySelectorAll("[data-mv]")
436
+ .forEach((m) => {
437
+ const s = list[+m.dataset.mv],
438
+ b = $("src-mb-" + m.dataset.mv),
439
+ to = picked.get(m.dataset.k);
440
+ if (to && [...m.options].some((o) => o.value === to)) m.value = to;
441
+ (m.onchange = () => {
442
+ b.disabled = m.value === s.dir;
443
+ })();
444
+ b.onclick = () =>
445
+ act({ op: "move", dir: s.dir, to: m.value, url: s.url }, () =>
446
+ toast(`Source moved. Requests already gathered stay in ${s.project || "the old project"}.`),
447
+ );
448
+ });
449
+ // The category saves on its button or Enter, never per keystroke.
450
+ $("pbody")
451
+ .querySelectorAll("[data-ct]")
452
+ .forEach((m) => {
453
+ const s = list[+m.dataset.ct],
454
+ b = $("src-cs-" + m.dataset.ct),
455
+ was = cats.get(m.dataset.k);
456
+ if (was !== undefined) m.value = was;
457
+ (m.oninput = () => {
458
+ b.disabled = m.value.trim() === (s.category || "");
459
+ })();
460
+ m.onkeydown = (e) => {
461
+ if (e.key === "Enter" && !b.disabled) b.click();
462
+ };
463
+ b.onclick = () => {
464
+ b.disabled = true;
465
+ act({ op: "category", dir: s.dir, url: s.url, category: m.value.trim() }, () =>
466
+ toast(
467
+ m.value.trim()
468
+ ? `New requests from ${s.label || host(s.url)} go under ${m.value.trim()}`
469
+ : "Category cleared",
470
+ ),
471
+ );
472
+ };
473
+ });
474
+ // The acting button is disabled as it is pressed, which drops focus to the page; it goes back to the cursor row.
475
+ const focusCur = () => {
476
+ if (!document.activeElement || document.activeElement === document.body)
477
+ $("pbody").querySelector(".ice-row.cur .ice-t")?.focus();
478
+ };
479
+ // Before rows go, the cursor steps to the next row that stays, else the one before.
480
+ const stepPast = (gone) => {
481
+ const at = order.findIndex((i) => iceKey(ice[i]) === iceCur);
482
+ if (at < 0 || !gone.has(iceCur)) return;
483
+ const stay = (i) => !gone.has(iceKey(ice[i])),
484
+ n = order.slice(at + 1).find(stay) ?? order.slice(0, at).reverse().find(stay);
485
+ iceCur = n === undefined ? null : iceKey(ice[n]);
486
+ };
487
+ const iceAct = (op, it, msg, extra = {}) =>
488
+ (async () => {
489
+ stepPast(new Set([iceKey(it)]));
490
+ const j = await post("/icebox", { op, dir: it.dir, ids: [it.id], ...extra });
491
+ if (j.ok) {
492
+ if (op === "promote" && iceForm === iceKey(it)) iceForm = null;
493
+ toast(msg);
494
+ load();
495
+ }
496
+ if (srcOpen()) openSources().then(focusCur);
497
+ })();
498
+ // Bulk: one dismiss per project; promote one request at a time, each its own idea
499
+ // titled as gathered (one promote call with several ids would merge them into one).
500
+ const bulkDo = async (op) => {
501
+ const its = ice.filter((it) => iceSel.has(iceKey(it)));
502
+ if (!its.length) return;
503
+ stepPast(new Set(its.map(iceKey)));
504
+ iceBusy = true;
505
+ $("ice-bp").disabled = $("ice-bd").disabled = true;
506
+ let ok = 0;
507
+ try {
508
+ if (op === "dismiss")
509
+ for (const dd of new Set(its.map((it) => it.dir))) {
510
+ const ids = its.filter((it) => it.dir === dd).map((it) => it.id);
511
+ if ((await post("/icebox", { op, dir: dd, ids })).ok) ok += ids.length;
512
+ }
513
+ else for (const it of its) if ((await post("/icebox", { op, dir: it.dir, ids: [it.id] })).ok) ok++;
514
+ } finally {
515
+ iceBusy = false;
516
+ }
517
+ iceSel.clear();
518
+ if (ok)
519
+ toast(
520
+ `${ok} ${op === "promote" ? "promoted to Ideas" : "dismissed"}${ok < its.length ? `, ${its.length - ok} not` : ""}`,
521
+ );
522
+ load();
523
+ if (srcOpen()) openSources().then(focusCur);
524
+ };
525
+ if (bulk) {
526
+ $("ice-bp").onclick = () => bulkDo("promote");
527
+ $("ice-bd").onclick = () => bulkDo("dismiss");
528
+ $("ice-bc").onclick = () => {
529
+ iceSel.clear();
530
+ openSources();
531
+ };
532
+ }
533
+ if ($("ice-all"))
534
+ $("ice-all").onchange = () => {
535
+ const k = order.map((i) => iceKey(ice[i]));
536
+ if (nSel === order.length) k.forEach((x) => iceSel.delete(x));
537
+ else k.forEach((x) => iceSel.add(x));
538
+ openSources();
539
+ };
540
+ if ($("ice-all")) $("ice-all").indeterminate = nSel > 0 && nSel < order.length;
541
+ $("pbody")
542
+ .querySelectorAll("[data-ck]")
543
+ .forEach(
544
+ (c) =>
545
+ (c.onchange = () => {
546
+ const k = iceKey(ice[+c.dataset.ck]);
547
+ iceCur = k;
548
+ c.checked ? iceSel.add(k) : iceSel.delete(k);
549
+ openSources();
550
+ }),
551
+ );
552
+ // A row opens on its title or anywhere not a control; open shows what was gathered and the promote form.
553
+ const toggle = (i) => {
554
+ const k = iceKey(ice[i]);
555
+ iceCur = k;
556
+ iceForm = iceForm === k ? null : k;
557
+ openSources().then(() => {
558
+ const t = $("ice-title");
559
+ if (t && iceForm === k) {
560
+ t.focus();
561
+ t.select();
562
+ } else $("ice-o-" + i)?.focus();
563
+ });
564
+ };
565
+ $("pbody")
566
+ .querySelectorAll(".ice-row")
567
+ .forEach(
568
+ (r) =>
569
+ (r.onclick = (e) => {
570
+ if (!e.target.closest("input,button")) toggle(+r.dataset.i);
571
+ }),
572
+ );
573
+ $("pbody")
574
+ .querySelectorAll("[data-o]")
575
+ .forEach((b) => (b.onclick = () => toggle(+b.dataset.o)));
576
+ $("pbody")
577
+ .querySelectorAll("[data-pr]")
578
+ .forEach(
579
+ (b) =>
580
+ (b.onclick = () => {
581
+ b.disabled = true;
582
+ iceAct("promote", ice[+b.dataset.pr], "Promoted to Ideas");
583
+ }),
584
+ );
585
+ $("pbody")
586
+ .querySelectorAll("[data-ds]")
587
+ .forEach(
588
+ (b) =>
589
+ (b.onclick = () => {
590
+ b.disabled = true;
591
+ iceAct("dismiss", ice[+b.dataset.ds], "Dismissed");
592
+ }),
593
+ );
594
+ const open1 = ice.find((it) => iceKey(it) === iceForm);
595
+ if ($("ice-rp"))
596
+ $("ice-rp").onclick = async () => {
597
+ const it = open1,
598
+ k = iceKey(it);
599
+ iceReply = { k, text: "", wait: true };
600
+ openSources();
601
+ const j = await post("/reply", { op: "draft", dir: it.dir, id: it.id, style: rStyle(it.dir) });
602
+ if (!iceReply || iceReply.k !== k) return;
603
+ iceReply = { k, text: j.text || "", note: j.ok ? j.note || "" : "", wait: false };
604
+ openSources().then(() => $("ice-reply")?.focus());
605
+ };
606
+ if ($("ice-rst"))
607
+ $("ice-rst").onchange = (e) => {
608
+ const d = open1.dir;
609
+ iceStyle[d] = e.target.value;
610
+ try {
611
+ localStorage.setItem("kz-reply-style:" + d, iceStyle[d]);
612
+ } catch (e) {}
613
+ };
614
+ // Another draft in the picked style, told to differ from the one in the box; the box keeps its text until it lands.
615
+ if ($("ice-rg"))
616
+ $("ice-rg").onclick = async () => {
617
+ const it = open1,
618
+ r = iceReply;
619
+ r.again = true;
620
+ openSources();
621
+ const j = await post("/reply", { op: "draft", dir: it.dir, id: it.id, style: rStyle(it.dir), avoid: r.text });
622
+ if (iceReply !== r) return;
623
+ r.again = false;
624
+ if (j.text) r.text = j.text;
625
+ r.note = j.ok ? j.note || "" : "";
626
+ openSources().then(() => $("ice-reply")?.focus());
627
+ };
628
+ if ($("ice-reply")) {
629
+ const ta = $("ice-reply");
630
+ ta.oninput = () => {
631
+ iceReply.text = ta.value;
632
+ $("ice-rn").textContent = `${ta.value.length} chars`;
633
+ $("ice-rs").disabled = !ta.value.trim();
634
+ };
635
+ ta.onkeydown = (e) => {
636
+ if (e.key === "Escape") {
637
+ e.stopPropagation();
638
+ $("ice-rc").click();
639
+ }
640
+ };
641
+ $("ice-rc").onclick = () => {
642
+ iceReply = null;
643
+ openSources();
644
+ };
645
+ $("ice-rs").onclick = async () => {
646
+ const it = open1,
647
+ r = iceReply;
648
+ r.busy = true;
649
+ openSources();
650
+ const j = await post("/reply", { op: "send", dir: it.dir, id: it.id, text: r.text });
651
+ if (j.ok) {
652
+ iceReply = null;
653
+ toast("Reply posted");
654
+ } else if (j.sent) iceReply = null;
655
+ else r.busy = false;
656
+ load();
657
+ if (srcOpen()) openSources();
658
+ };
659
+ }
660
+
661
+ if ($("ice-ok")) {
662
+ const ok = () => {
663
+ const title = $("ice-title").value.trim();
664
+ if (!title) {
665
+ $("ice-title").focus();
666
+ return;
667
+ }
668
+ $("ice-ok").disabled = true;
669
+ iceAct("promote", open1, "Promoted to Ideas", { text: title, details: $("ice-details").value });
670
+ };
671
+ $("ice-ok").onclick = ok;
672
+ $("ice-cancel").onclick = () => {
673
+ iceForm = null;
674
+ openSources();
675
+ };
676
+ $("ice-title").onkeydown = (e) => {
677
+ if (e.key === "Enter") ok();
678
+ if (e.key === "Escape") $("ice-cancel").click();
679
+ };
680
+ $("ice-details").onkeydown = (e) => {
681
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) ok();
682
+ if (e.key === "Escape") $("ice-cancel").click();
683
+ };
684
+ }
685
+ // Keys, read by the page's keydown while the icebox shows. True when the key was used.
686
+ iceNav = (e) => {
687
+ if (e.altKey || e.ctrlKey || e.metaKey) return false;
688
+ const k = e.key,
689
+ at = order.findIndex((i) => iceKey(ice[i]) === iceCur),
690
+ i = order[at];
691
+ if (k === "Escape") {
692
+ if ($("src-sec")?.classList.contains("open")) {
693
+ srcShut();
694
+ return true;
695
+ }
696
+ if (iceForm) {
697
+ iceForm = null;
698
+ openSources();
699
+ return true;
700
+ }
701
+ if (iceSel.size) {
702
+ iceSel.clear();
703
+ openSources();
704
+ return true;
705
+ }
706
+ return false;
707
+ }
708
+ if (at < 0) return false;
709
+ if (k === "j" || k === "k") {
710
+ e.preventDefault();
711
+ const n = order[Math.max(0, Math.min(order.length - 1, at + (k === "j" ? 1 : -1)))];
712
+ iceCur = iceKey(ice[n]);
713
+ $("pbody")
714
+ .querySelectorAll(".ice-row")
715
+ .forEach((r) => r.classList.toggle("cur", +r.dataset.i === n));
716
+ $("pbody").querySelector(`.ice-row[data-i="${n}"]`)?.scrollIntoView({ block: "nearest" });
717
+ return true;
718
+ }
719
+ const b =
720
+ k === "o"
721
+ ? $("ice-o-" + i)
722
+ : k === "x"
723
+ ? $("ice-ck-" + i)
724
+ : k === "p"
725
+ ? iceSel.size
726
+ ? $("ice-bp")
727
+ : $("ice-pr-" + i)
728
+ : k === "d"
729
+ ? iceSel.size
730
+ ? $("ice-bd")
731
+ : $("ice-ds-" + i)
732
+ : null;
733
+ if (!b || b.disabled) return false;
734
+ e.preventDefault();
735
+ b.click();
736
+ return true;
737
+ };
738
+ $("pbody")
739
+ .querySelectorAll("[data-chk]")
740
+ .forEach((b) => {
741
+ const s = list[+b.dataset.chk];
742
+ b.onclick = () => {
743
+ b.disabled = true;
744
+ b.innerHTML = ico("refresh") + " Checking…";
745
+ act({ op: "gather", dir: s.dir, url: s.url }, (j) => {
746
+ const r = j.results && j.results[0];
747
+ toast(
748
+ r
749
+ ? r.ok
750
+ ? `${s.label || host(s.url)}: ${r.note}${r.added ? " in the icebox" : ""}`
751
+ : `Failed: ${r.note}`
752
+ : "Checked",
753
+ );
754
+ });
755
+ };
756
+ });
757
+ // A social source is read by the agent, so Gather opens one on /kaizen gather.
758
+ $("pbody")
759
+ .querySelectorAll("[data-ag]")
760
+ .forEach((b) => {
761
+ const s = list[+b.dataset.ag];
762
+ b.onclick = () => {
763
+ b.disabled = true;
764
+ if (chatOn) b.innerHTML = ico("refresh") + " Gathering…";
765
+ act({ op: "agent", dir: s.dir, url: s.url }, (j) => {
766
+ const r = j.results && j.results[0];
767
+ toast(
768
+ j.via === "chatgpt"
769
+ ? r && r.ok
770
+ ? `${s.label || host(s.url)}: ${r.note}${r.added ? " in the icebox" : ""}`
771
+ : `Failed: ${r ? r.note : "no answer"}`
772
+ : "/kaizen gather opened in your terminal: " + via(j.agent),
773
+ );
774
+ });
775
+ };
776
+ });
777
+ $("pbody")
778
+ .querySelectorAll("[data-rm]")
779
+ .forEach((b) => {
780
+ const s = list[+b.dataset.rm];
781
+ b.onclick = () => {
782
+ if (
783
+ !confirm(
784
+ `Remove ${s.label || host(s.url)}? What it gathered stays; added again later, it may bring back ones you deleted or dismissed.`,
785
+ )
786
+ )
787
+ return;
788
+ act({ op: "remove", dir: list[+b.dataset.rm].dir, url: list[+b.dataset.rm].url }, () =>
789
+ toast("Source removed"),
790
+ );
791
+ };
792
+ });
793
+ // A row that went takes focus to the cursor row's title, never to whatever now holds its index.
794
+ const ci = ice.findIndex((it) => iceKey(it) === iceCur);
795
+ if (fPick) {
796
+ const at = ice.findIndex((it) => iceKey(it) === fPick[0]);
797
+ (at >= 0 ? $(fPick[1] + "-" + at) : ci >= 0 ? $("ice-o-" + ci) : null)?.focus();
798
+ }
799
+ const was = !fPick && typed[2] && $(typed[2]);
800
+ if (was) (was.disabled ? $(was.id.replace("src-mb-", "src-mv-").replace("src-cs-", "src-ct-")) : was)?.focus();
801
+ if ($("src-add")) {
802
+ // A social source is a platform and what to follow; the server builds its link, and
803
+ // nothing is gathered here: the agent reads it.
804
+ $("src-add").onclick = () => {
805
+ const social = srcKind === "social",
806
+ url = $("src-url").value.trim(),
807
+ q = $("src-q").value.trim(),
808
+ plat = $("src-plat").value;
809
+ if (social ? !q : !url) {
810
+ $(social ? "src-q" : "src-url").focus();
811
+ return;
812
+ }
813
+ const to = $("src-dir").value;
814
+ if (!to) {
815
+ $("src-dir").focus();
816
+ return;
817
+ }
818
+ $("src-add").disabled = true;
819
+ $("src-add").textContent = "Adding…";
820
+ (async () => {
821
+ const label = $("src-label").value.trim() || (social ? `${PLAT[plat]}: ${q}` : ""),
822
+ category = $("src-cat").value.trim();
823
+ const j = await post(
824
+ "/sources",
825
+ social
826
+ ? { op: "add", dir: to, platform: plat, query: q, label, category }
827
+ : { op: "add", dir: to, url, label, category },
828
+ );
829
+ if (j.ok && social) {
830
+ $("src-q").value = $("src-label").value = $("src-cat").value = "";
831
+ toast("Source added. Click Gather on it to have your agent read its posts.");
832
+ load();
833
+ } else if (j.ok) {
834
+ $("src-url").value = $("src-label").value = $("src-cat").value = "";
835
+ const g = await post("/sources", { op: "gather", dir: to, url }),
836
+ r = g.results && g.results[0];
837
+ toast(r ? (r.ok ? `Source added: ${r.note} in the icebox` : `Source added, but: ${r.note}`) : "Source added");
838
+ load();
839
+ } else toast(j.error || "Could not add source");
840
+ if (j.ok) srcAdd = false;
841
+ if (srcOpen()) openSources();
842
+ })();
843
+ };
844
+ $("src-url").onkeydown =
845
+ $("src-q").onkeydown =
846
+ $("src-label").onkeydown =
847
+ $("src-cat").onkeydown =
848
+ (e) => {
849
+ if (e.key === "Enter") $("src-add").click();
850
+ };
851
+ }
852
+ if ($("src-go"))
853
+ $("src-go").onclick = () => {
854
+ $("src-go").disabled = true;
855
+ $("src-go").innerHTML = ico("refresh") + " Gathering…";
856
+ act({ op: "gather", dir: dir || undefined }, (j) => {
857
+ const rs = j.results || [],
858
+ soc = (r) => list.some((s) => s.url === r.url && s.social),
859
+ n = rs.reduce((a, r) => a + r.added, 0),
860
+ bad = rs.filter((r) => !r.ok && !soc(r)).length,
861
+ ag = rs.filter(soc).length;
862
+ toast(
863
+ (n ? `${n} new in the icebox` : "Nothing new") +
864
+ (bad ? `, ${bad} source${bad === 1 ? "" : "s"} could not be read` : "") +
865
+ (ag ? `; ${ag} social for /kaizen gather` : ""),
866
+ );
867
+ });
868
+ };
869
+ }