kaizen-agent 1.42.2 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/board/dom.ts CHANGED
@@ -2,13 +2,25 @@
2
2
  export const $ = <T extends HTMLElement = any>(id: string) => document.getElementById(id) as T;
3
3
  export const esc = (s) =>
4
4
  String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
5
- export const ico = (n) => `<svg><use href="#i-${n}"/></svg>`;
5
+ // Trusted markup. Everything `html` interpolates is escaped unless it is a Safe.
6
+ export class Safe {
7
+ constructor(readonly s: string) {}
8
+ toString() {
9
+ return this.s;
10
+ }
11
+ }
12
+ export const raw = (s) => new Safe(String(s));
13
+ const part = (v): string => (v instanceof Safe ? v.s : Array.isArray(v) ? v.map(part).join("") : v == null || v === false ? "" : esc(v));
14
+ // Escape-by-default template: html`<b>${title}</b>` cannot inject markup. Keep attributes double-quoted.
15
+ export const html = (strs: TemplateStringsArray, ...vals) => raw(strs.reduce((a, s, i) => a + part(vals[i - 1]) + s));
16
+ export const ico = (n) => raw(`<svg><use href="#i-${n}"/></svg>`);
6
17
  // Placeholder rows for a panel whose body is still being fetched.
7
- export const SKEL =
18
+ export const SKEL = raw(
8
19
  '<div aria-busy="true" aria-label="Loading">' +
9
20
  '<span class="skel" style="height:16px;width:35%;margin:20px 0 14px"></span>' +
10
21
  '<span class="skel" style="height:44px;margin-bottom:10px"></span>'.repeat(4) +
11
- "</div>";
22
+ "</div>",
23
+ );
12
24
  // Files as {name, data-URL}, the shape /note and /icebox take.
13
25
  export const dataUrls = (files: File[]) =>
14
26
  Promise.all(
@@ -22,10 +34,10 @@ export const dataUrls = (files: File[]) =>
22
34
  ),
23
35
  );
24
36
  // A modal form: the native <dialog>, removed from the page once closed.
25
- export function modal(html: string) {
37
+ export function modal(body: string | Safe) {
26
38
  const dl = document.createElement("dialog");
27
39
  dl.className = "kz";
28
- dl.innerHTML = `<form class="inl" method="dialog">${html}</form>`;
40
+ dl.innerHTML = `<form class="inl" method="dialog">${body}</form>`;
29
41
  dl.onclose = () => dl.remove();
30
42
  // A tap on the backdrop. Braces matter: an on* handler that returns false cancels the click,
31
43
  // which stopped every submit button inside the form.
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
2
2
  import { where } from "./agent-term.ts";
3
3
  import { post } from "./api.ts";
4
4
  import { key, via } from "./cards.ts";
5
- import { $ } from "./dom.ts";
5
+ import { $, html, raw } from "./dom.ts";
6
6
  import { md } from "./markdown.ts";
7
7
  import { openRun } from "./panel.ts";
8
8
  import { askNotify, toast } from "./toast.ts";
@@ -35,17 +35,15 @@ export function fixForm(c) {
35
35
  return;
36
36
  const el = document.createElement("div");
37
37
  el.className = "inl fix";
38
- el.innerHTML =
39
- `<div class="q">Fix findings</div><div class="q dim">Opens your agent in this project, told to fix the ticked findings and recheck them.</div>${c.status === "waiting" ? `<div class="q dim">A session is already waiting on this run in a terminal; this opens another one.</div>` : ""}` +
38
+ el.innerHTML = html`<div class="q">Fix findings</div><div class="q dim">Opens your agent in this project, told to fix the ticked findings and recheck them.</div>${c.status === "waiting" ? html`<div class="q dim">A session is already waiting on this run in a terminal; this opens another one.</div>` : ""}${
40
39
  fs
41
40
  .map((f) => {
42
41
  const x = f.done ? " (closed)" : sent(f) ? " (sent)" : "";
43
- return `<label class="${f.done ? "off done" : x ? "off" : ""}"><input type="checkbox" data-n="${f.n}" ${x ? "checked disabled" : fixOff.has(k(f.n)) ? "" : "checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g, "")}${x}</span></label>`;
42
+ return html`<label class="${f.done ? "off done" : x ? "off" : ""}"><input type="checkbox" data-n="${f.n}" ${x ? "checked disabled" : fixOff.has(k(f.n)) ? "" : "checked"}><span>${f.n}. ${raw(md(f.text).replace(/^<p>|<\/p>$/g, ""))}${x}</span></label>`;
44
43
  })
45
- .join("") +
46
- (open.length
47
- ? `<div class="row"><button class="btn ghost" data-a="all"></button><button class="btn pri" data-a="s"></button></div>`
48
- : "");
44
+ }${open.length
45
+ ? html`<div class="row"><button class="btn ghost" data-a="all"></button><button class="btn pri" data-a="s"></button></div>`
46
+ : ""}`;
49
47
  if (!open.length) return $("pbody").appendChild(el);
50
48
  const go = el.querySelector("[data-a=s]"),
51
49
  allb = el.querySelector("[data-a=all]"),
package/web/board/git.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { store } from "./store.ts";
2
2
  import { post } from "./api.ts";
3
3
  import { key } from "./cards.ts";
4
- import { $, esc } from "./dom.ts";
4
+ import { $, html } from "./dom.ts";
5
5
  import { commitMsg } from "./markdown.ts";
6
6
  import { openRun } from "./panel.ts";
7
7
  import { toast } from "./toast.ts";
@@ -15,7 +15,7 @@ export function gitBtn(c) {
15
15
  // A run already pushed keeps saying so; work changed since is offered beside it.
16
16
  if (k && k.pushed)
17
17
  return (
18
- `<button class="btn" disabled title="${esc(k.sha)}">Committed &amp; pushed</button>` +
18
+ html`<button class="btn" disabled title="${k.sha}">Committed &amp; pushed</button>` +
19
19
  (g.files.length
20
20
  ? `<button class="btn" data-a="commit">Commit new changes</button>`
21
21
  : g.ahead > 0
@@ -31,7 +31,7 @@ export function gitBtn(c) {
31
31
  export function confirmCommit(c, msg) {
32
32
  const g = store.runDoc.git,
33
33
  foot = $("pfoot");
34
- foot.innerHTML = `<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${esc(g.branch || "this branch")} and push to ${esc(g.remote)}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${esc(g.files.join("\n"))}</pre>${g.shared && g.shared.length ? `<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${esc(g.shared.join("\n"))}</pre>` : ""}<textarea aria-label="Commit message" placeholder="feat(scope): what changed">${esc(msg || commitMsg(store.runDoc.impl) || c.title || c.text)}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Commit &amp; push</button></div></div>`;
34
+ foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${g.branch || "this branch"} and push to ${g.remote}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${g.files.join("\n")}</pre>${g.shared && g.shared.length ? html`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${g.shared.join("\n")}</pre>` : ""}<textarea aria-label="Commit message" placeholder="feat(scope): what changed">${msg || commitMsg(store.runDoc.impl) || c.title || c.text}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Commit &amp; push</button></div></div>`;
35
35
  const inp = foot.querySelector("textarea"),
36
36
  go = foot.querySelector("[data-a=s]");
37
37
  inp.focus();
@@ -3,7 +3,7 @@ import { termStop } from "./agent-term.ts";
3
3
  import { load, post } from "./api.ts";
4
4
  import { via } from "./cards.ts";
5
5
  import { mic, stopDictation } from "./dictation.ts";
6
- import { $, dataUrls, esc, ico, modal, SKEL } from "./dom.ts";
6
+ import { $, dataUrls, html, ico, modal, raw, SKEL } from "./dom.ts";
7
7
  import { ideaCombo, newDir, plogo, setShown, short, showable } from "./projects.ts";
8
8
  import { toast } from "./toast.ts";
9
9
  import { go, view } from "./views.ts";
@@ -167,7 +167,7 @@ export async function openSources() {
167
167
  // Sheets and Docs, whose site icon is generic): nothing but the host reaches anyone. A social
168
168
  // platform's mark is drawn here instead: Meta's favicons are same-origin only.
169
169
  const fav = (u, fb, soc) => {
170
- if (PLAT[soc]) return `<span class="fav">${ico("b-" + soc)}</span>`;
170
+ if (PLAT[soc]) return html`<span class="fav">${ico("b-" + soc)}</span>`;
171
171
  let h;
172
172
  try {
173
173
  const x = new URL(u);
@@ -180,7 +180,7 @@ export async function openSources() {
180
180
  : u.includes("docs.google.com/document")
181
181
  ? "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico"
182
182
  : h + "/favicon.ico";
183
- return `<span class="fav">${src ? `<img src="${esc(src)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">` : ""}${ico(fb)}</span>`;
183
+ return html`<span class="fav">${src ? html`<img src="${src}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">` : ""}${ico(fb)}</span>`;
184
184
  };
185
185
  // What a source is called when nothing names it: its platform, else its site.
186
186
  const platOf = (s) => PLAT[s?.social] || (s ? host(s.url).replace(/^www\./, "") : "") || "Other";
@@ -201,12 +201,7 @@ export async function openSources() {
201
201
  // changes on every arrow key, and each change would have moved the source. The names are the picker's;
202
202
  // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
203
203
  const opts = (on, name) =>
204
- (on && !store.S.projects.some((p) => p.dir === on)
205
- ? `<option value="${esc(on)}" title="${esc(name || on)}" selected>${esc(short(name || on))}</option>`
206
- : "") +
207
- store.S.projects
208
- .map((p) => `<option value="${esc(p.dir)}" title="${esc(p.label)}"${p.dir === on ? " selected" : ""}>${esc(short(p.label))}</option>`)
209
- .join("");
204
+ html`${on && !store.S.projects.some((p) => p.dir === on) ? html`<option value="${on}" title="${name || on}" selected>${short(name || on)}</option>` : ""}${store.S.projects.map((p) => html`<option value="${p.dir}" title="${p.label}"${p.dir === on ? " selected" : ""}>${short(p.label)}</option>`)}`;
210
205
  const cards = list
211
206
  .map((s, i) => {
212
207
  const st = srcType(s),
@@ -214,30 +209,24 @@ export async function openSources() {
214
209
  // A social source is never read by kaizen, so its not-ok is the agent's turn, not a failure.
215
210
  const statusHtml =
216
211
  s.social && (!s.last || (!s.last.ok && /gathered by your agent/i.test(s.last.note)))
217
- ? `<span class="src-badge dim">${ico("at")}${chatOn ? "Gathered with ChatGPT" : "Gathered by your agent"}</span>`
212
+ ? html`<span class="src-badge dim">${ico("at")}${chatOn ? "Gathered with ChatGPT" : "Gathered by your agent"}</span>`
218
213
  : !s.last
219
- ? `<span class="src-badge dim">Not gathered yet</span>`
214
+ ? html`<span class="src-badge dim">Not gathered yet</span>`
220
215
  : s.last.ok
221
- ? `<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>`
222
- : `<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>`;
216
+ ? html`<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>${s.last.note}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`
217
+ : html`<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: ${s.last.note}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`;
223
218
  const kind = st === "sheet" ? "sheet" : st === "doc" ? "doc" : st === "social" ? "at" : "link",
224
219
  bare = s.url.replace(/^https?:\/\/(www\.)?/, "");
225
- return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><span class="src-ico" title="${st === "social" ? esc(PLAT[s.social] || "social") : st}">${fav(s.url, kind, s.social)}</span><div class="src-info"><span class="src-title" title="${esc(title)}">${esc(title)}</span><a class="src-url" href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(bare)}</span>${ico("ext")}</a></div><div class="src-acts">${s.social ? `<button class="ib sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${esc(title)} with your agent" title="${chatOn ? "Gather with your ChatGPT plan" : "Gather: opens your agent on /kaizen gather"}">${ico("refresh")}</button>` : `<button class="ib sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check now">${ico("refresh")}</button>`}<button class="ib sm danger" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-status">${statusHtml}</div><div class="src-fields"><label class="src-f" title="Category"><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="${esc(platOf(s))}" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm pri" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></label><label class="src-f" title="Project"><span>Project</span>${plogo(s.dir, s.project || "")}<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 pri" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></label>${st === "sheet" || /github\.com/.test(s.url) ? `<label class="src-wb"><input type="checkbox" id="src-wb-${i}" data-wb="${i}"${s.writeback ? " checked" : ""}> Write back</label>` : ""}</div></div>`;
226
- })
227
- .join("");
228
- const fails = ((d && d.failed) || [])
229
- .map((f) => `<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`)
230
- .join("");
231
- const emptyState = `<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No sources yet</h4></div>`;
220
+ return html`<div class="src-card" id="src-card-${i}"><div class="src-hd"><span class="src-ico" title="${st === "social" ? (PLAT[s.social] || "social") : st}">${fav(s.url, kind, s.social)}</span><div class="src-info"><span class="src-title" title="${title}">${title}</span><a class="src-url" href="${s.url}" target="_blank" rel="noopener noreferrer" title="${s.url}"><span>${bare}</span>${ico("ext")}</a></div><div class="src-acts">${s.social ? html`<button class="ib sm" id="src-ag-${i}" data-ag="${i}" aria-label="Gather ${title} with your agent" title="${chatOn ? "Gather with your ChatGPT plan" : "Gather: opens your agent on /kaizen gather"}">${ico("refresh")}</button>` : html`<button class="ib sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${title} now" title="Check now">${ico("refresh")}</button>`}<button class="ib sm danger" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${title}" title="Remove source">${ico("trash")}</button></div></div><div class="src-status">${statusHtml}</div><div class="src-fields"><label class="src-f" title="Category"><span>Category</span><input class="sel" id="src-ct-${i}" data-ct="${i}" data-k="${s.dir + " " + s.url}" value="${s.category || ""}" placeholder="${platOf(s)}" maxlength="40" aria-label="Category of ${title}"><button class="btn sm pri" id="src-cs-${i}" aria-label="Save the category of ${title}" disabled>Save</button></label><label class="src-f" title="Project"><span>Project</span>${plogo(s.dir, s.project || "")}<select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${s.dir + " " + s.url}" aria-label="Project of ${title}">${opts(s.dir, s.project)}</select><button class="btn sm pri" id="src-mb-${i}" aria-label="Move ${title} to the chosen project" disabled>Move</button></label>${st === "sheet" || /github\.com/.test(s.url) ? html`<label class="src-wb"><input type="checkbox" id="src-wb-${i}" data-wb="${i}"${s.writeback ? " checked" : ""}> Write back</label>` : ""}</div></div>`;
221
+ });
222
+ const fails = ((d && d.failed) || []).map((f) => html`<p style="color:var(--dim)">The sources of ${f} could not be read.</p>`);
223
+ const emptyState = html`<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No sources yet</h4></div>`;
232
224
  const social = srcKind === "social";
233
225
  const addOn = !list.length || srcAdd || [typed[0], typed[1], typed[4], typed[6]].some(Boolean);
234
- 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="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(
226
+ const addBox = html`<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="${String(!social)}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${String(social)}">Social</button></div></div><label for="src-url"${social ? " hidden" : ""}><span>Link</span><input class="sel" id="src-url" placeholder="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(
235
227
  PLAT,
236
228
  )
237
- .map(([k, v]) => `<option value="${k}">${v}</option>`)
238
- .join(
239
- "",
240
- )}</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="end"></span><button class="btn pri" id="src-add">Add source</button></div></div>`;
229
+ .map(([k, v]) => html`<option value="${k}">${v}</option>`)}</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 ? "" : html`<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="end"></span><button class="btn pri" id="src-add">Add source</button></div></div>`;
241
230
  // The inbox: one row per request, by project when showing all, then by category,
242
231
  // narrowed to one source by its chip. Checked, open and cursor items are kept by key.
243
232
  const srcName = (it) => {
@@ -302,9 +291,9 @@ export async function openSources() {
302
291
  const many = byProj.size > 1;
303
292
  // Filters are searchable pickers (the header's combo): a button, then a search over the list.
304
293
  const pick = (id, label, cur, rows) =>
305
- `<div class="combo ice-combo" id="${id}"><button class="sel" aria-label="${label}" aria-haspopup="listbox" aria-expanded="false">${cur}</button><div class="menu-pop" hidden><input class="sel" aria-label="Search ${label.toLowerCase()}" placeholder="Search…" spellcheck="false" autocomplete="off"><div class="pick-list" role="listbox">${rows}</div></div></div>`;
306
- const opt = (v, on, html, q, cls = "") =>
307
- `<button type="button" role="option" data-v="${esc(v)}" data-q="${esc(q.toLowerCase())}" class="${on ? "on" : ""}${cls}" aria-selected="${on}">${html}</button>`;
294
+ html`<div class="combo ice-combo" id="${id}"><button class="sel" aria-label="${label}" aria-haspopup="listbox" aria-expanded="false">${cur}</button><div class="menu-pop" hidden><input class="sel" aria-label="Search ${label.toLowerCase()}" placeholder="Search…" spellcheck="false" autocomplete="off"><div class="pick-list" role="listbox">${rows}</div></div></div>`;
295
+ const opt = (v, on, inner, q, cls = "") =>
296
+ html`<button type="button" role="option" data-v="${v}" data-q="${q.toLowerCase()}" class="${on ? "on" : ""}${cls}" aria-selected="${String(on)}">${inner}</button>`;
308
297
  // Sources group by platform: two Threads searches sit under one Threads.
309
298
  const srcGroups = new Map();
310
299
  for (const [k, { it }] of bySrc) {
@@ -314,91 +303,84 @@ export async function openSources() {
314
303
  }
315
304
  const srcIco = (it) => fav(it.source, "link", srcOf(it)?.social);
316
305
  const curSrc = iceSrc && [...bySrc].find(([k]) => k === iceSrc)?.[1].it;
317
- const chips =
306
+ const chips = html`${
318
307
  // Dismissed and Done are hidden until picked, so even a lone one is a choice.
319
- (byCat.size > 1 || byCat.has("Dismissed") || byCat.has("Done")
308
+ byCat.size > 1 || byCat.has("Dismissed") || byCat.has("Done")
320
309
  ? pick(
321
310
  "ice-fc",
322
311
  "Category",
323
- `<span>${esc(iceCat || "All categories")}</span>`,
324
- opt("", !iceCat, `<span>All categories</span>`, "all categories") +
325
- [...byCat].map(([k, { n }]) => opt(k, iceCat === k, `<span>${esc(k)}</span><i>${n}</i>`, k)).join(""),
312
+ html`<span>${iceCat || "All categories"}</span>`,
313
+ html`${opt("", !iceCat, html`<span>All categories</span>`, "all categories")}${[...byCat].map(([k, { n }]) => opt(k, iceCat === k, html`<span>${k}</span><i>${n}</i>`, k))}`,
326
314
  )
327
- : "") +
328
- (bySrc.size > 1
315
+ : ""}${
316
+ bySrc.size > 1
329
317
  ? pick(
330
318
  "ice-fs",
331
319
  "Source",
332
- curSrc ? `${srcIco(curSrc)}<span>${esc(srcName(curSrc))}</span>` : `<span>All sources</span>`,
333
- opt("", !iceSrc, `<span>All sources</span>`, "all sources") +
334
- [...srcGroups]
335
- .map(
336
- ([g, xs]) =>
337
- `<div class="grp" data-g="${esc(g.toLowerCase())}">${srcIco(xs[0][1])}${esc(g)}</div>` +
338
- xs.map(([k, it]) => opt(k, iceSrc === k, `${srcIco(it)}<span>${esc(srcName(it).toLowerCase().startsWith(g.toLowerCase() + ":") ? srcName(it).slice(g.length + 1).trim() : srcName(it))}</span><i>${bySrc.get(k).n}</i>`, g + " " + srcName(it), " sub")).join(""),
339
- )
340
- .join(""),
320
+ curSrc ? html`${srcIco(curSrc)}<span>${srcName(curSrc)}</span>` : html`<span>All sources</span>`,
321
+ html`${opt("", !iceSrc, html`<span>All sources</span>`, "all sources")}${[...srcGroups].map(
322
+ ([g, xs]) =>
323
+ html`<div class="grp" data-g="${g.toLowerCase()}">${srcIco(xs[0][1])}${g}</div>${xs.map(([k, it]) => opt(k, iceSrc === k, html`${srcIco(it)}<span>${srcName(it).toLowerCase().startsWith(g.toLowerCase() + ":") ? srcName(it).slice(g.length + 1).trim() : srcName(it)}</span><i>${bySrc.get(k).n}</i>`, g + " " + srcName(it), " sub"))}`,
324
+ )}`,
341
325
  )
342
- : "");
343
- 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" || iceCat === "Done" ? `${order.length} ${iceCat.toLowerCase()}` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span><span class="end"></span>${chips ? `<details class="ice-filt"><summary class="ib${iceCat || iceSrc ? " on" : ""}" aria-label="Filter requests" title="Filter requests">${ico("filter")}</summary><div class="ice-fpop">${chips}</div></details>` : ""}<button class="btn ghost sm" id="ice-new" title="Add a request by hand">${ico("plus")} Add</button><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>`;
326
+ : ""}`;
327
+ const bar = html`<div class="ice-bar">${order.length ? html`<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" || iceCat === "Done" ? `${order.length} ${iceCat.toLowerCase()}` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span><span class="end"></span>${String(chips) ? html`<details class="ice-filt"><summary class="ib${iceCat || iceSrc ? " on" : ""}" aria-label="Filter requests" title="Filter requests">${ico("filter")}</summary><div class="ice-fpop">${chips}</div></details>` : ""}<button class="btn ghost sm" id="ice-new" title="Add a request by hand">${ico("plus")} Add</button><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="${String(srcShow ?? !list.length)}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
344
328
  const bulk = iceSel.size
345
- ? `<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="Promote (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>`
329
+ ? html`<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="Promote (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>`
346
330
  : "";
347
331
  // The open item's form: a clean title for the idea's card and the person's own details;
348
332
  // what was gathered is added to the idea's notes by the server.
349
333
  const form = (it) =>
350
- `<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" aria-label="Details"></textarea><div class="ice-form-acts"><span class="end"></span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
334
+ html`<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${iceKey(it)}" maxlength="200" value="${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" aria-label="Details"></textarea><div class="ice-form-acts"><span class="end"></span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
351
335
  // A gathered post can be answered: Reply drafts, the box is the approval, Send reply posts that text.
352
336
  const replyBox = (it) => {
353
337
  if (!it.post) return "";
354
- if (it.replied) return `<span class="hint" style="margin:0">You replied to this post ${ago(it.replied)}.</span>`;
338
+ if (it.replied) return html`<span class="hint" style="margin:0">You replied to this post ${ago(it.replied)}.</span>`;
355
339
  const r = iceReply && iceReply.k === iceKey(it) ? iceReply : null;
356
340
  const style = (d) =>
357
- `<select class="sel" id="ice-rst" aria-label="Reply style"${d ? " disabled" : ""}>${Object.entries(
341
+ html`<select class="sel" id="ice-rst" aria-label="Reply style"${d ? " disabled" : ""}>${Object.entries(
358
342
  STYLES,
359
343
  )
360
- .map(([v, l]) => `<option value="${v}"${v === rStyle(it.dir) ? " selected" : ""}>${l}</option>`)
361
- .join("")}</select>`;
344
+ .map(([v, l]) => html`<option value="${v}"${v === rStyle(it.dir) ? " selected" : ""}>${l}</option>`)}</select>`;
362
345
  if (!r)
363
- 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>`;
364
- if (r.wait) return `<div class="ice-form-acts"><span class="hint">Drafting…</span></div>`;
365
- 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>`;
346
+ return html`<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>`;
347
+ if (r.wait) return html`<div class="ice-form-acts"><span class="hint">Drafting…</span></div>`;
348
+ return html`<div class="ice-form"><label for="ice-reply" class="hint" style="margin:0">Your reply${r.note ? ` · ${r.note}` : ""}</label><textarea class="sel" id="ice-reply" rows="3" maxlength="2000" aria-label="Your reply"${r.busy || r.again ? " disabled" : ""}>${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 ? html`${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>`;
366
349
  };
367
350
  const row = (i) => {
368
351
  const it = ice[i],
369
352
  k = iceKey(it),
370
353
  open = iceForm === k;
371
- return (
372
- `<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" title="${esc(it.project || "")}">${plogo(it.dir, it.project || "")}${esc(short(it.project || ""))}</span>` : ""}${it.status === "done" && it.written ? `<span title="Written back ${esc(ago(it.written))}">written back</span>` : ""}${it.status === "done" && it.writeError ? `<span title="${esc(it.writeError)}">write-back failed</span>` : ""}${it.status === "promoted" ? `<span title="Promoted to Ideas: ${esc(it.idea || "")}">promoted</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra">${it.status === "promoted" || it.status === "done" ? "" : `<button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button>`}${it.status === "promoted" || it.status === "done" ? "" : 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>` +
373
- (open
374
- ? `<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)}${icePro === k ? form(it) : ""}</div>`
375
- : "")
376
- );
354
+ const head = html`<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${k}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${it.text}"><span class="ice-src" title="${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="${String(open)}" title="${it.text}">${it.text}</button><span class="ice-meta">${many ? html`<span class="nm" title="${it.project || ""}">${plogo(it.dir, it.project || "")}${short(it.project || "")}</span>` : ""}${it.status === "done" && it.written ? html`<span title="Written back ${ago(it.written)}">written back</span>` : ""}${it.status === "done" && it.writeError ? html`<span title="${it.writeError}">write-back failed</span>` : ""}${it.status === "promoted" ? html`<span title="Promoted to Ideas: ${it.idea || ""}">promoted</span>` : ""}${it.replied ? html`<span title="Replied ${ago(it.replied)}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra">${it.status === "promoted" || it.status === "done" ? "" : html`<button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas (p)" aria-label="Promote to Ideas: ${it.text}">${ico("plus")}</button>`}${it.status === "promoted" || it.status === "done" ? "" : it.status === "dismissed" ? html`<button class="btn ghost sm" id="ice-rs-${i}" data-rs="${i}" title="Restore to waiting" aria-label="Restore: ${it.text}">Restore</button>` : html`<button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${it.text}">${ico("x")}</button>`}</span></span></div>`;
355
+ const more = open
356
+ ? html`<div class="ice-open">${it.notes ? html`<div class="ice-notes">${it.notes}</div>` : ""}${(it.images || []).filter((u) => /^https:\/\//.test(u)).length ? html`<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 html`<a href="${src}" target="_blank" rel="noopener noreferrer"><img data-src="${src}" alt="" referrerpolicy="no-referrer"></a>`; })}</details>` : ""}<span class="hint" style="margin:0">From ${srcName(it)} · ${ago(it.at)}${it.post ? html` · <a href="${it.post}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${icePro === k ? form(it) : ""}</div>`
357
+ : "";
358
+ return html`${head}${more}`;
377
359
  };
378
360
  const inbox =
379
361
  waiting && !order.length
380
- ? `<div class="ice-zero"><h4>No matches</h4><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
362
+ ? html`<div class="ice-zero"><h4>No matches</h4><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
381
363
  : order.length
382
- ? `<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>`
383
- : `<div class="ice-zero"><h4>Nothing waiting</h4></div>`;
364
+ ? html`<div class="ice-list">${[...groups].map(([g, ix]) => html`<div class="ice-cat">${g}<i>${ix.length}</i></div>${ix.map(row)}`)}</div>`
365
+ : html`<div class="ice-zero"><h4>Nothing waiting</h4></div>`;
384
366
  // The sources, in the right-hand drawer: shut until asked for, open by itself while there
385
367
  // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
386
368
  const drawer = srcShow ?? !list.length,
387
369
  slide = drawer && !$("src-sec")?.classList.contains("open");
388
- 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="src-chips"><a id="src-to-settings" class="schip" role="button" tabindex="0" title="${d && d.every && d.every !== "off" ? `Checked every ${esc(d.every)}` : "Auto-check off"}: change in Settings">${ico("clock")}${d && d.every && d.every !== "off" ? esc(d.every) : "Off"}</a>${G.connected ? `<span class="schip on" title="ChatGPT${G.email ? ": " + esc(G.email) : ""}">${ico("ai")}ChatGPT<a id="cg-out" role="button" tabindex="0" aria-label="Disconnect ChatGPT" title="Disconnect">${ico("x")}</a></span>` : `<a id="cg-in" class="schip" role="button" tabindex="0" title="Connect ChatGPT">${ico("ai")}ChatGPT</a>`}${(d.google || {}).connected ? `<span class="schip on" title="Google connected">${ico("google")}Google<a id="gg-out" role="button" tabindex="0" aria-label="Disconnect Google" title="Disconnect">${ico("x")}</a></span>` : `<a id="gg-in" class="schip" role="button" tabindex="0" title="Connect Google">${ico("google")}Google</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>`;
370
+ const sec = html`<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="src-chips"><a id="src-to-settings" class="schip" role="button" tabindex="0" title="${d && d.every && d.every !== "off" ? `Checked every ${d.every}` : "Auto-check off"}: change in Settings">${ico("clock")}${d && d.every && d.every !== "off" ? (d.every) : "Off"}</a>${G.connected ? html`<span class="schip on" title="ChatGPT${G.email ? ": " + (G.email) : ""}">${ico("ai")}ChatGPT<a id="cg-out" role="button" tabindex="0" aria-label="Disconnect ChatGPT" title="Disconnect">${ico("x")}</a></span>` : html`<a id="cg-in" class="schip" role="button" tabindex="0" title="Connect ChatGPT">${ico("ai")}ChatGPT</a>`}${(d.google || {}).connected ? html`<span class="schip on" title="Google connected">${ico("google")}Google<a id="gg-out" role="button" tabindex="0" aria-label="Disconnect Google" title="Disconnect">${ico("x")}</a></span>` : html`<a id="gg-in" class="schip" role="button" tabindex="0" title="Connect Google">${ico("google")}Google</a>`}</div></div><div class="src-hda">${list.length ? html`<button class="btn sm" id="src-new" aria-expanded="${String(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 ? html`<div class="src-list">${cards}</div>` : emptyState}</div></aside>`;
389
371
  // First visit: offer the ChatGPT plan for social sources, or keep the agent.
390
372
  const onb =
391
373
  G.use === "ask" && !G.connected
392
- ? `<div class="cg-onb"><div class="acts"><b>Gather social posts with ChatGPT?</b><span class="end"></span><button class="btn ghost" id="cg-skip">Skip</button><button class="btn pri" id="cg-go">Connect</button></div></div>`
374
+ ? html`<div class="cg-onb"><div class="acts"><b>Gather social posts with ChatGPT?</b><span class="end"></span><button class="btn ghost" id="cg-skip">Skip</button><button class="btn pri" id="cg-go">Connect</button></div></div>`
393
375
  : "";
394
376
  // A rebuild replaces the drawer: keep where it was scrolled to.
395
377
  const at = $("src-sec")?.querySelector(".body")?.scrollTop ?? 0;
396
378
  $("pbody").innerHTML =
397
379
  d && d.error
398
- ? `<p style="color:var(--dim)">${esc(d.error)}</p>`
380
+ ? html`<p style="color:var(--dim)">${d.error}</p>`
399
381
  : !d
400
- ? `<p style="color:var(--dim)">The icebox could not be read.</p>`
401
- : onb + bar + bulk + fails + inbox + sec;
382
+ ? html`<p style="color:var(--dim)">The icebox could not be read.</p>`
383
+ : html`${onb}${bar}${bulk}${fails}${inbox}${sec}`;
402
384
  if (!d || d.error) return;
403
385
  const body = $("src-sec")?.querySelector(".body");
404
386
  if (body) body.scrollTop = at;
package/web/board/keys.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { store } from "./store.ts";
2
2
  import { load } from "./api.ts";
3
- import { focusedKey, key, nav, render } from "./cards.ts";
3
+ import { focusedKey, key, nav, newIdea, render } from "./cards.ts";
4
4
  import { $ } from "./dom.ts";
5
5
  import { iceNav, srcOpen } from "./icebox.ts";
6
6
  import { close, openRun } from "./panel.ts";
@@ -11,9 +11,10 @@ export function initKeys() {
11
11
  $("kbd").onclick = () => $("help").classList.add("on");
12
12
  $("help").onclick = () => $("help").classList.remove("on");
13
13
  document.addEventListener("click", () => {
14
- if (store.menuKey || store.clearMenu) {
14
+ if (store.menuKey || store.clearMenu || store.sevMenu) {
15
15
  store.menuKey = null;
16
16
  store.clearMenu = false;
17
+ store.sevMenu = false;
17
18
  render();
18
19
  }
19
20
  });
@@ -29,7 +30,8 @@ export function initKeys() {
29
30
  iceNav(e)
30
31
  )
31
32
  return;
32
- if (e.target.matches("input,select,textarea,[contenteditable]")) return;
33
+ // A dialog handles its own keys; Escape there must not also close the panel.
34
+ if (e.target.matches("input,select,textarea,[contenteditable]") || e.target.closest("dialog")) return;
33
35
  if (e.key === "?") {
34
36
  $("help").classList.toggle("on");
35
37
  return;
@@ -52,10 +54,9 @@ export function initKeys() {
52
54
  } else close();
53
55
  return;
54
56
  }
55
- if (e.key === "n") {
57
+ if (e.key === "n" && !document.querySelector("dialog[open]")) {
56
58
  if (view !== "board") go("board", true);
57
- store.mode = "new";
58
- render();
59
+ newIdea();
59
60
  }
60
61
  if (e.key === "a") {
61
62
  store.all = !store.all;
package/web/board/main.ts CHANGED
@@ -44,6 +44,7 @@ document.addEventListener(
44
44
  own.classList.remove("menu");
45
45
  store.menuKey = null;
46
46
  store.clearMenu = false;
47
+ store.sevMenu = false;
47
48
  }
48
49
  }
49
50
  },
@@ -5,7 +5,7 @@ import { APPROVAL, answer, confirmAnswers, confirmRevise, decide, decided, resum
5
5
  import { cardEl, key, render } from "./cards.ts";
6
6
  import { chat } from "./chat.ts";
7
7
  import { stopDictation } from "./dictation.ts";
8
- import { $, esc } from "./dom.ts";
8
+ import { $, html, raw } from "./dom.ts";
9
9
  import { fixForm, fixOff } from "./fixes.ts";
10
10
  import { commitPush, confirmCommit, gitBtn } from "./git.ts";
11
11
  import { ICON, WORD, need } from "./labels.ts";
@@ -51,7 +51,7 @@ export function open(c, keepTab) {
51
51
  $("pterm").hidden = true;
52
52
  if (c.kind === "idea") {
53
53
  store.runDoc = null;
54
- $("psub").innerHTML = `<span class="pst"><span class="st d-idea"><svg><use href="#s-idea"/></svg></span>Idea</span><span class="pst dim">${esc(c.where.split(/[\\/]/).pop() || c.where)}</span>`;
54
+ $("psub").innerHTML = html`<span class="pst"><span class="st d-idea"><svg><use href="#s-idea"/></svg></span>Idea</span><span class="pst dim">${c.where.split(/[\\/]/).pop() || c.where}</span>`;
55
55
  $("ptabs").innerHTML = "";
56
56
  ideaBody(c);
57
57
  const foot = $("pfoot");
@@ -97,7 +97,7 @@ function showProgress(c) {
97
97
  st: [...src.matchAll(/^\s*-\s*(todo|doing|done):/gm)].map((m) => m[1]),
98
98
  w: +/width:(\d+)%/.exec(h)[1],
99
99
  };
100
- $("pbody").innerHTML = h + (store.runDoc.backlog ? "<h3>Backlog</h3>" + md(store.runDoc.backlog, true) : "");
100
+ $("pbody").innerHTML = html`${raw(h)}${store.runDoc.backlog ? html`<h3>Backlog</h3>${raw(md(store.runDoc.backlog, true))}` : ""}`;
101
101
  const bar = $("pbody").querySelector(".pbar i");
102
102
  if (prev && prev.w !== lastProg.w) {
103
103
  bar.style.width = prev.w + "%";
@@ -129,9 +129,9 @@ export async function openRun(c, refresh, again) {
129
129
  if (refresh && ta && document.activeElement === ta) return;
130
130
  // The board's own status mark, then what it waits on: chips, not a dotted line.
131
131
  $("psub").innerHTML =
132
- `<span class="pst"><span class="st d-${ICON[c.status] || "idea"}"><svg><use href="#s-${ICON[c.status] || "idea"}"/></svg></span>${esc(cap(WORD[c.status] || c.status))}</span>` +
133
- (c.awaiting ? `<span class="pst dim">${esc(cap(need(c.awaiting)))}</span>` : "") +
134
- (c.parent ? `<span class="pst link" title="Filed by this run">${esc(c.parent)}</span>` : "");
132
+ html`<span class="pst"><span class="st d-${ICON[c.status] || "idea"}"><svg><use href="#s-${ICON[c.status] || "idea"}"/></svg></span>${cap(WORD[c.status] || c.status)}</span>` +
133
+ (c.awaiting ? html`<span class="pst dim">${cap(need(c.awaiting))}</span>` : "") +
134
+ (c.parent ? html`<span class="pst link" title="Filed by this run">${c.parent}</span>` : "");
135
135
  // Only tabs with something in them show. A card whose agent runs in the board gets its
136
136
  // terminal behind the header's terminal button, chosen once right after a launch from here.
137
137
  const tabs = TABS.filter(
@@ -162,7 +162,7 @@ export async function openRun(c, refresh, again) {
162
162
  openRun(c, true);
163
163
  };
164
164
  $("ptabs").innerHTML = tabs
165
- .map((t) => `<button class="${t[1] === store.tab ? "on" : ""}" data-k="${t[1]}">${t[0]}</button>`)
165
+ .map((t) => html`<button class="${t[1] === store.tab ? "on" : ""}" data-k="${t[1]}">${t[0]}</button>`)
166
166
  .join("");
167
167
  [...$("ptabs").children].forEach(
168
168
  (b) =>
@@ -180,15 +180,15 @@ export async function openRun(c, refresh, again) {
180
180
  chat(c, doc, ro);
181
181
  } else
182
182
  $("pbody").innerHTML = doc
183
- ? md(doc, k === "backlog")
184
- : `<p style="color:var(--dim)">No ${{ request: "request", plan: "plan", impl: "work report", preview: "preview", review: "review", backlog: "backlog" }[k]} yet.</p>`;
183
+ ? raw(md(doc, k === "backlog"))
184
+ : html`<p style="color:var(--dim)">No ${{ request: "request", plan: "plan", impl: "work report", preview: "preview", review: "review", backlog: "backlog" }[k]} yet.</p>`;
185
185
  if (k === "backlog") showProgress(c);
186
186
  if (k === "review") fixForm(c);
187
187
  // The notebook's notes that link here, each opening that note: above the run's own notes.
188
188
  if (k === "notes" && store.runDoc.linkedFrom && store.runDoc.linkedFrom.length)
189
189
  $("pbody").insertAdjacentHTML(
190
190
  "afterbegin",
191
- `<h3>Linked from notes</h3><ul class="lnk">${store.runDoc.linkedFrom.map((n) => `<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&amp;note=${encodeURIComponent(n)}">${esc(n)}</a></li>`).join("")}</ul>`,
191
+ html`<h3>Linked from notes</h3><ul class="lnk">${store.runDoc.linkedFrom.map((n) => html`<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&amp;note=${encodeURIComponent(n)}">${n}</a></li>`)}</ul>`,
192
192
  );
193
193
  // Only the panel the link asked for: another card opened meanwhile drops the mark.
194
194
  if (hit && !refresh) {
@@ -209,7 +209,7 @@ export async function openRun(c, refresh, again) {
209
209
  qs = pq || [];
210
210
  // Its in-card agent ended on a waiting or done run: reopen that conversation, nothing sent.
211
211
  const rs = c.pty && !c.pty.alive ? `<button class="btn" data-a="resume">Resume session</button>` : "";
212
- if (c.archived) foot.innerHTML = arch;
212
+ if (c.archived) foot.innerHTML = raw(arch);
213
213
  else if ((c.status === "waiting" || c.awaiting === "findings") && APPROVAL[c.awaiting]) {
214
214
  const sent = decided.has(answer(c)),
215
215
  asks = !pq
@@ -221,13 +221,13 @@ export async function openRun(c, refresh, again) {
221
221
  const more =
222
222
  gitBtn(c).replace(/class="btn( pri)?"/g, "") +
223
223
  `<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
224
- foot.innerHTML = `<span class="note">${sent ? "Sent to your agent." : APPROVAL[c.awaiting] + asks + " Answering here opens a new agent session."}</span><details class="more"><summary class="btn ghost">More</summary><div class="menu-pop">${more}</div></details>${rs}<span class="dec"><button class="btn" data-a="revise"${sent ? " disabled" : ""}>Revise</button><button class="btn pri" data-a="approve"${sent ? " disabled" : ""}>${c.awaiting === "findings" ? "Approve as-is" : "Approve"}</button></span>`;
224
+ foot.innerHTML = html`<span class="note">${sent ? "Sent to your agent." : APPROVAL[c.awaiting] + asks + " Answering here opens a new agent session."}</span><details class="more"><summary class="btn ghost">More</summary><div class="menu-pop">${raw(more)}</div></details>${raw(rs)}<span class="dec"><button class="btn" data-a="revise"${sent ? " disabled" : ""}>Revise</button><button class="btn pri" data-a="approve"${sent ? " disabled" : ""}>${c.awaiting === "findings" ? "Approve as-is" : "Approve"}</button></span>`;
225
225
  } else if (c.status === "waiting")
226
- foot.innerHTML = `<span class="note">Waiting on you: ${esc(need(c.awaiting))}. Answer in the ${c.pty && c.pty.alive ? "terminal (button at the top)" : "terminal running this run"}.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>${rs}`;
227
- else if (c.status === "done") foot.innerHTML = arch + rs;
228
- else if (c.status === "abandoned") foot.innerHTML = arch;
226
+ foot.innerHTML = html`<span class="note">Waiting on you: ${need(c.awaiting)}. Answer in the ${c.pty && c.pty.alive ? "terminal (button at the top)" : "terminal running this run"}.</span>${raw(arch)}<button class="btn ghost" data-a="ab">Abandon</button>${raw(rs)}`;
227
+ else if (c.status === "done") foot.innerHTML = raw(arch + rs);
228
+ else if (c.status === "abandoned") foot.innerHTML = raw(arch);
229
229
  else
230
- foot.innerHTML = `<span class="note">${c.pty && !c.pty.alive ? "Its agent ended before the stage finished. Resume continues it here." : c.status === "stalled" ? "No progress for a while. Resume it, or abandon it." : c.pty ? "" : "Running in your agent."}</span>${arch}<button class="btn ghost" data-a="ab">Abandon run</button>${resumable(c) ? `<button class="btn pri" data-a="resume">Resume</button>` : ""}`;
230
+ foot.innerHTML = html`<span class="note">${c.pty && !c.pty.alive ? "Its agent ended before the stage finished. Resume continues it here." : c.status === "stalled" ? "No progress for a while. Resume it, or abandon it." : c.pty ? "" : "Running in your agent."}</span>${raw(arch)}<button class="btn ghost" data-a="ab">Abandon run</button>${resumable(c) ? html`<button class="btn pri" data-a="resume">Resume</button>` : ""}`;
231
231
  foot.querySelectorAll("[data-a]").forEach(
232
232
  (b) =>
233
233
  (b.onclick = () => {
@@ -290,6 +290,12 @@ export function deep() {
290
290
  hit = m ? { type: m[1], n: +m[2], k: key(c) } : null;
291
291
  open(c, !!t);
292
292
  }
293
+ // A run's panel on the tab of one of its items, that item scrolled to and marked.
294
+ export function openAt(c, type, n) {
295
+ store.tab = type === "finding" ? "review" : "backlog";
296
+ hit = { type, n, k: key(c) };
297
+ open(c, true);
298
+ }
293
299
  // What md() leaves of a line as text: no backticks or stars, a link reduced to its words.
294
300
  function mark(h) {
295
301
  hit = null;