@tiagojct/subsub 0.16.0 → 0.18.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/app.js CHANGED
@@ -7,7 +7,7 @@ import { ACTIONS, STRINGS, SUGGESTIONS, TOOLS } from "/i18n.js";
7
7
  // ---------------------------------------------------------------- state
8
8
 
9
9
  let lang = "en";
10
- let state = null; // Sub-Sub's own state: mode, profile, model, library, down
10
+ let state = null; // Sub-Sub's own state: mode, model, library, down
11
11
  let session = null; // pi's session state
12
12
  let busy = false;
13
13
  let commands = [];
@@ -49,9 +49,27 @@ async function api(path, body) {
49
49
  // ---------------------------------------------------------------- markdown (no raw HTML, safe links)
50
50
 
51
51
  const SAFE_LINK = /^(https?:|mailto:|zotero:|obsidian:)/i;
52
+ /** A path or name that the notes pane can open: a .md file without a scheme. */
53
+ const NOTE_PATH = /^(?![a-z][\w+.-]*:)[^\n]*\.md$/i;
54
+ const noteLink = (target, inner) => `<a href="#" data-note="${escapeHtml(target)}">${inner}</a>`;
52
55
  function escapeHtml(s) {
53
56
  return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
54
57
  }
58
+ // [[citekey]] and [[name|label]], as in Obsidian: a link to the note in the notes pane.
59
+ marked.use({
60
+ extensions: [
61
+ {
62
+ name: "wikilink",
63
+ level: "inline",
64
+ start: (src) => src.indexOf("[["),
65
+ tokenizer(src) {
66
+ const m = /^\[\[([^\]|\n#]+)(?:#[^\]|\n]*)?(?:\|([^\]\n]+))?\]\]/.exec(src);
67
+ if (m) return { type: "wikilink", raw: m[0], target: m[1].trim(), label: (m[2] ?? m[1]).trim() };
68
+ },
69
+ renderer: (tok) => noteLink(tok.target, escapeHtml(tok.label)),
70
+ },
71
+ ],
72
+ });
55
73
  marked.use({
56
74
  gfm: true,
57
75
  breaks: false,
@@ -61,10 +79,25 @@ marked.use({
61
79
  },
62
80
  link(token) {
63
81
  const text = this.parser.parseInline(token.tokens);
82
+ if (NOTE_PATH.test(decodeURI(token.href ?? ""))) return noteLink(decodeURI(token.href), text);
64
83
  if (!SAFE_LINK.test(token.href ?? "")) return text;
65
84
  return `<a href="${escapeHtml(token.href)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
66
85
  },
86
+ code(token) {
87
+ return (token.lang === "mermaid" && flowHtml(token.text)) || false;
88
+ },
89
+ codespan(token) {
90
+ const code = `<code>${escapeHtml(token.text)}</code>`;
91
+ return NOTE_PATH.test(token.text) ? noteLink(token.text, code) : code;
92
+ },
67
93
  image(token) {
94
+ // In the notes pane, an image next to the note (a figure cut out of the PDF) is shown;
95
+ // anywhere else, and for any other address, only its description.
96
+ const href = decodeURI(token.href ?? "");
97
+ if (noteDir && /^(?![a-z][\w+.-]*:|[\\/])[^?#]+\.(png|jpe?g|gif|webp)$/i.test(href)) {
98
+ const src = `/api/note-file?path=${encodeURIComponent(`${noteDir}/${href}`)}`;
99
+ return `<img class="note-img" src="${escapeHtml(src)}" alt="${escapeHtml(token.text ?? "")}" loading="lazy">`;
100
+ }
68
101
  return escapeHtml(token.text || token.href || "");
69
102
  },
70
103
  table(token) {
@@ -87,6 +120,45 @@ marked.use({
87
120
  },
88
121
  },
89
122
  });
123
+ /**
124
+ * The flow of works that /lit writes as Mermaid (a chain of boxes, with dotted branches to the side
125
+ * for what was left out), drawn as boxes. Any other diagram stays as code; Obsidian draws it.
126
+ */
127
+ function flowHtml(src) {
128
+ const lines = String(src).split("\n").map((l) => l.trim()).filter(Boolean);
129
+ if (!/^(flowchart|graph)\s+(TD|TB)\b/.test(lines.shift() ?? "")) return null;
130
+ const label = {};
131
+ const next = {};
132
+ const side = {};
133
+ const into = new Set();
134
+ for (const line of lines) {
135
+ const n = /^(\w+)\["([^"]*)"\]$/.exec(line);
136
+ if (n) {
137
+ label[n[1]] = n[2];
138
+ continue;
139
+ }
140
+ const m = /^(\w+)(?:\["([^"]*)"\])?\s*(-->|-\.->)\s*(\w+)(?:\["([^"]*)"\])?$/.exec(line);
141
+ if (!m) return null;
142
+ const [, a, la, arrow, b, lb] = m;
143
+ if (la !== undefined) label[a] = la;
144
+ if (lb !== undefined) label[b] = lb;
145
+ if (arrow === "-->") {
146
+ if (next[a]) return null; // a branch of the main chain: not this kind of diagram
147
+ next[a] = b;
148
+ into.add(b);
149
+ } else (side[a] ??= []).push(b);
150
+ }
151
+ const rows = [];
152
+ const seen = new Set();
153
+ for (let id = Object.keys(next).find((k) => !into.has(k)); id && !seen.has(id); id = next[id]) {
154
+ seen.add(id);
155
+ const sides = (side[id] ?? []).map((s) => `<div class="flow-side">${escapeHtml(label[s] ?? s)}</div>`).join("");
156
+ rows.push(`<div class="flow-row"><div class="flow-box">${escapeHtml(label[id] ?? id)}</div>${sides}</div>`);
157
+ }
158
+ if (rows.length < 2) return null;
159
+ return `<div class="flow">${rows.join("")}</div>`;
160
+ }
161
+
90
162
  function md(text) {
91
163
  try {
92
164
  return marked.parse(String(text ?? ""));
@@ -179,7 +251,6 @@ function renderHeader() {
179
251
  b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
180
252
  b.title = t(b.dataset.mode === "librarian" ? "librarianHint" : "researcherHint");
181
253
  }
182
- if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
183
254
  $("starbuck-toggle").checked = Boolean(state?.starbuck);
184
255
  $("starbuck-label").title = t("starbuckHint");
185
256
  const m = state?.model ?? session?.model;
@@ -229,10 +300,7 @@ function renderActions() {
229
300
  const box = $("actions");
230
301
  box.replaceChildren();
231
302
  const shown = (a) =>
232
- hasCommand(a.cmd) &&
233
-
234
- (!a.needs || state?.[a.needs]) &&
235
- (!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
303
+ hasCommand(a.cmd) && (!a.needs || state?.[a.needs]);
236
304
  const groups = mode() === "librarian" ? [["library", "groupLibrary"]] : [["research", "groupResearch"], ["library", "groupLibrary"]];
237
305
  for (const [group, label] of groups) {
238
306
  const list = ACTIONS[group].filter(shown);
@@ -247,6 +315,7 @@ function actionButton(a) {
247
315
  type: "button",
248
316
  text: a[lang] ?? a.en,
249
317
  onclick: () => {
318
+ if (a.item) return $("library-search").focus();
250
319
  closeSide();
251
320
  if (a.send) sendMessage(`/${a.cmd}`);
252
321
  else {
@@ -269,6 +338,134 @@ function renderBanner() {
269
338
  } else b.hidden = true;
270
339
  }
271
340
 
341
+ // ---------------------------------------------------------------- library search
342
+
343
+ let libraryTimer = 0;
344
+ let libraryQuery = "";
345
+
346
+ function renderItems(items, message) {
347
+ const ul = $("items");
348
+ ul.replaceChildren();
349
+ if (message) return ul.append(el("li", { class: "muted", text: message }));
350
+ const researcher = mode() === "researcher";
351
+ for (const it of items) {
352
+ const ref = it.citekey || it.key;
353
+ const run = (cmd) => () => {
354
+ closeSide();
355
+ sendMessage(`/${cmd} ${ref}`);
356
+ };
357
+ ul.append(
358
+ el(
359
+ "li",
360
+ {},
361
+ el("div", { class: "item-title", text: it.title || it.key }),
362
+ el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey].filter(Boolean).join(" · ") }),
363
+ el(
364
+ "div",
365
+ { class: "item-actions" },
366
+ researcher ? el("button", { type: "button", text: t("readItem"), onclick: run("read") }) : null,
367
+ researcher ? el("button", { type: "button", text: t("noteItem"), onclick: run("lit-note") }) : null,
368
+ el("a", { href: `zotero://select/library/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
369
+ ),
370
+ ),
371
+ );
372
+ }
373
+ }
374
+
375
+ function searchLibrary() {
376
+ const q = $("library-search").value.replace(/\s+/g, " ").trim();
377
+ libraryQuery = q;
378
+ clearTimeout(libraryTimer);
379
+ if (!q) return renderItems([]);
380
+ libraryTimer = setTimeout(async () => {
381
+ renderItems([], t("searching"));
382
+ try {
383
+ const data = await api(`/api/library?q=${encodeURIComponent(q)}`);
384
+ if (data.q !== libraryQuery) return; // an older search answered late
385
+ if (data.error) renderItems([], data.error);
386
+ else renderItems(data.items ?? [], data.items?.length ? "" : t("noItems"));
387
+ } catch (err) {
388
+ if (q === libraryQuery) renderItems([], err.message);
389
+ }
390
+ }, 350);
391
+ }
392
+
393
+ // ---------------------------------------------------------------- notes pane
394
+
395
+ let noteShown = null; // the note on screen, or null for the list
396
+ let noteDir = ""; // while a note is rendered: its folder, for the images next to it
397
+ let notesList = [];
398
+
399
+ function showPane(open) {
400
+ $("note-pane").hidden = !open;
401
+ document.querySelector(".app").classList.toggle("note-open", open);
402
+ $("notes-btn").setAttribute("aria-expanded", String(open));
403
+ }
404
+
405
+ function renderNoteList() {
406
+ const body = $("note-body");
407
+ const q = $("note-filter").value.trim().toLowerCase();
408
+ const list = notesList.filter((n) => !q || n.rel.toLowerCase().includes(q));
409
+ const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", year: "numeric" });
410
+ body.replaceChildren(
411
+ list.length
412
+ ? el(
413
+ "ul",
414
+ { class: "note-list" },
415
+ list.map((n) =>
416
+ el(
417
+ "li",
418
+ {},
419
+ el("button", { type: "button", onclick: () => openNote(n.path) }, el("span", { class: "rel", text: n.rel }), el("span", { class: "when", text: fmt.format(new Date(n.modified)) })),
420
+ ),
421
+ ),
422
+ )
423
+ : el("p", { class: "muted small", text: t("noNotes") }),
424
+ );
425
+ }
426
+
427
+ async function showNoteList() {
428
+ noteShown = null;
429
+ $("note-name").textContent = t("notes");
430
+ $("note-back").hidden = true;
431
+ $("note-app").hidden = true;
432
+ $("note-filter").hidden = false;
433
+ showPane(true);
434
+ try {
435
+ notesList = (await api("/api/notes")).notes ?? [];
436
+ } catch (err) {
437
+ notesList = [];
438
+ note(err.message, "warning");
439
+ }
440
+ renderNoteList();
441
+ }
442
+
443
+ /** Show a note in the pane; anything that is not a note in the Sub-Sub folder goes to the system, as before. */
444
+ async function openNote(path) {
445
+ let data;
446
+ try {
447
+ data = await api(`/api/note?path=${encodeURIComponent(path)}`);
448
+ } catch {
449
+ return api("/api/open", { path }).catch((err) => note(err.message, "warning"));
450
+ }
451
+ noteShown = data;
452
+ $("note-name").textContent = data.name;
453
+ $("note-name").title = data.path;
454
+ $("note-back").hidden = false;
455
+ $("note-app").hidden = false;
456
+ $("note-filter").hidden = true;
457
+ const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(data.text);
458
+ const content = el("div", { class: "md" });
459
+ noteDir = data.path.replace(/[\\/][^\\/]*$/, "");
460
+ content.innerHTML = md(front ? data.text.slice(front[0].length) : data.text);
461
+ noteDir = "";
462
+ attachCopyButtons(content);
463
+ attachTableButtons(content, data.name.replace(/\.md$/i, ""));
464
+ $("note-body").replaceChildren(front ? el("details", { class: "front" }, el("summary", { text: t("properties") }), el("pre", { text: front[1] })) : "", content);
465
+ $("note-body").scrollTop = 0;
466
+ showPane(true);
467
+ }
468
+
272
469
  let loadedSessions = [];
273
470
 
274
471
  function renderSessionsList(filterText = "") {
@@ -283,12 +480,30 @@ function renderSessionsList(filterText = "") {
283
480
  return;
284
481
  }
285
482
  const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
483
+ // shortcut: the browser's own prompt and confirm; move to the page's dialog if they look out of place.
484
+ const rename = (s) => async () => {
485
+ const name = window.prompt(t("renamePrompt"), s.name || s.first || "");
486
+ if (!name?.trim()) return;
487
+ await api("/api/session/name", { path: s.path, name }).catch((err) => note(err.message, "warning"));
488
+ loadSessions();
489
+ };
490
+ const trash = (s) => async () => {
491
+ if (!window.confirm(t("trashConfirm"))) return;
492
+ await api("/api/session/trash", { path: s.path }).catch((err) => note(err.message, "warning"));
493
+ loadSessions();
494
+ };
286
495
  for (const s of filtered) {
287
496
  const current = session?.sessionFile === s.path;
288
497
  ul.append(
289
498
  el(
290
499
  "li",
291
500
  { class: current ? "current" : "" },
501
+ el(
502
+ "span",
503
+ { class: "session-tools" },
504
+ el("button", { type: "button", text: t("rename"), onclick: rename(s) }),
505
+ current ? null : el("button", { type: "button", text: t("trash"), onclick: trash(s) }),
506
+ ),
292
507
  el(
293
508
  "button",
294
509
  {
@@ -333,29 +548,64 @@ function exportConversation() {
333
548
  note(t("noConversations"), "warning");
334
549
  return;
335
550
  }
551
+ const fence = (x) => `\`\`\`text\n${x.length > 3000 ? `${x.slice(0, 3000)}\n...` : x}\n\`\`\``;
336
552
  for (const m of items) {
337
553
  if (m.classList.contains("user")) {
338
- const text = m.querySelector(".bubble")?.innerText ?? "";
339
- if (text && text !== "/subsub-refresh") msgs.push(`### User\n\n${text}\n`);
554
+ const b = m.querySelector(".bubble");
555
+ // A long message is folded: export its full text, not the summary line.
556
+ const text = (b?.tagName === "DETAILS" ? b.querySelector("div")?.innerText : b?.innerText) ?? "";
557
+ if (text && text !== "/subsub-refresh") msgs.push(`### ${t("exportUser")}\n\n${text}\n`);
340
558
  } else if (m.classList.contains("assistant")) {
341
- const text = Array.from(m.querySelectorAll(".md")).map((d) => d.innerText).join("\n\n");
342
- if (text) msgs.push(`### Sub-Sub\n\n${text}\n`);
559
+ const parts = [];
560
+ for (const c of m.children) {
561
+ if (c.classList.contains("md")) parts.push(c.innerText);
562
+ else if (c.classList.contains("tool")) {
563
+ const label = c.querySelector(".label")?.textContent ?? "";
564
+ const args = c.querySelector(".args")?.textContent ?? "";
565
+ const out = c.querySelector("pre")?.innerText;
566
+ parts.push(`> ${label}${args ? `: ${args}` : ""}${out ? `\n\n${fence(out)}` : ""}`);
567
+ }
568
+ }
569
+ if (parts.length) msgs.push(`### Sub-Sub\n\n${parts.join("\n\n")}\n`);
343
570
  }
344
571
  }
345
572
  if (!msgs.length) return;
346
573
  const date = new Date().toISOString().slice(0, 10);
347
- const content = `# Sub-Sub Research Session (${date})\n\n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
348
- const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
349
- const url = URL.createObjectURL(blob);
350
- const a = document.createElement("a");
351
- a.href = url;
352
- a.download = `Sub-Sub-${date}.md`;
574
+ const content = `# ${t("exportHeading")} (${date})\n\n${t("exportMode")}: ${t(mode())}\n\n---\n\n${msgs.join("\n---\n\n")}`;
575
+ download(content, `Sub-Sub-${date}.md`, "text/markdown;charset=utf-8");
576
+ }
577
+
578
+ /** Save text as a file in the downloads folder. */
579
+ function download(content, name, type) {
580
+ const url = URL.createObjectURL(new Blob([content], { type }));
581
+ const a = el("a", { href: url, download: name });
353
582
  document.body.append(a);
354
583
  a.click();
355
584
  a.remove();
356
585
  URL.revokeObjectURL(url);
357
586
  }
358
587
 
588
+ /** A table as CSV for Excel, Rayyan or Covidence. Excel in Portuguese expects semicolons; the BOM keeps the accents. */
589
+ function tableCsv(table) {
590
+ const sep = lang === "pt" ? ";" : ",";
591
+ const cell = (c) => {
592
+ const v = c.textContent.replace(/\s+/g, " ").trim(); // not innerText: the header is upper case only in CSS
593
+ return /[",;\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v;
594
+ };
595
+ return `\ufeff${[...table.rows].map((r) => [...r.cells].map(cell).join(sep)).join("\r\n")}\r\n`;
596
+ }
597
+
598
+ /** A "Download CSV" button under each table (evidence tables, extraction sheets, matrices). */
599
+ function attachTableButtons(root, name) {
600
+ const wraps = [...root.querySelectorAll(".md-table-wrap")];
601
+ wraps.forEach((wrap, i) => {
602
+ if (wrap.nextElementSibling?.classList.contains("table-actions")) return;
603
+ const file = `${name}${wraps.length > 1 ? `-${i + 1}` : ""}.csv`;
604
+ const btn = el("button", { type: "button", class: "msg-action-btn", text: t("downloadCsv"), onclick: () => download(tableCsv(wrap.querySelector("table")), file, "text/csv;charset=utf-8") });
605
+ wrap.after(el("div", { class: "table-actions" }, btn));
606
+ });
607
+ }
608
+
359
609
  function closeSide() {
360
610
  $("side").classList.remove("open");
361
611
  $("menu-btn").setAttribute("aria-expanded", "false");
@@ -521,7 +771,7 @@ function updateTool(row, name, args) {
521
771
  text: t("open"),
522
772
  onclick: (e) => {
523
773
  e.preventDefault();
524
- api("/api/open", { path: args.path }).catch((err) => note(err.message, "warning"));
774
+ openNote(args.path);
525
775
  },
526
776
  }),
527
777
  );
@@ -627,6 +877,7 @@ function renderAssistantFinal(message, msg = startAssistant()) {
627
877
  const node = el("div", { class: "md" });
628
878
  node.innerHTML = md(c.text);
629
879
  attachCopyButtons(node);
880
+ attachTableButtons(node, `Sub-Sub-${new Date().toISOString().slice(0, 10)}`);
630
881
  msg.box.append(node);
631
882
  } else if (c.type === "toolCall") {
632
883
  const row = toolRow(c.id, c.name, c.arguments);
@@ -847,7 +1098,7 @@ function dialogTitle(d) {
847
1098
  // The program sends the title in the user's language; both forms get the same friendly title.
848
1099
  if ((m = raw.match(/^(?:apply|aplicar) (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
849
1100
  if ((m = raw.match(/^(?:run|executar) (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
850
- if (raw === "undo?" || raw === "anular?") return t("undo");
1101
+ if (raw === "undo?" || raw === "anular?") return t("undoAsk");
851
1102
  if ((m = raw.match(/^(?:write the Starbuck report to|escrever o relatório do Starbuck em) (.+)\?$/))) return t("starbuckReport", { x: m[1] });
852
1103
  if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
853
1104
  return raw;
@@ -895,6 +1146,7 @@ function nextDialog() {
895
1146
  body.replaceChildren();
896
1147
  const buttons = $("dialog-buttons");
897
1148
  buttons.replaceChildren();
1149
+ buttons.classList.toggle("choices", d.method === "select" && (d.options ?? []).some((o) => o.length > 24));
898
1150
  const answer = async (reply) => {
899
1151
  try {
900
1152
  await api("/api/dialog", { id: d.id, ...reply });
@@ -1120,12 +1372,20 @@ let suggestIndex = 0;
1120
1372
  function suggest() {
1121
1373
  const box = $("suggest");
1122
1374
  const v = input.value;
1375
+ // "/lit-note " (a command and a space): show what it needs.
1376
+ const chosen = /^\/(\S+) $/.exec(v) && commands.find((c) => c.name === v.slice(1, -1));
1377
+ if (chosen?.hint) {
1378
+ box.replaceChildren(el("div", { class: "hint-row" }, el("span", { class: "name", text: `/${chosen.name}` }), el("span", { class: "desc", text: chosen.hint })));
1379
+ box.hidden = false;
1380
+ box.list = null;
1381
+ return;
1382
+ }
1123
1383
  if (!/^\/\S*$/.test(v)) {
1124
1384
  box.hidden = true;
1125
1385
  return;
1126
1386
  }
1127
1387
  const q = v.slice(1).toLowerCase();
1128
- const list = commands.filter((c) => c.name !== "subsub-refresh" && c.name.toLowerCase().startsWith(q)).slice(0, 12);
1388
+ const list = commands.filter((c) => !c.name.startsWith("subsub-") && c.name.toLowerCase().startsWith(q)).slice(0, 12);
1129
1389
  if (!list.length) {
1130
1390
  box.hidden = true;
1131
1391
  return;
@@ -1141,7 +1401,7 @@ function suggest() {
1141
1401
  "aria-selected": String(i === suggestIndex),
1142
1402
  onclick: () => pick(c),
1143
1403
  },
1144
- el("span", { class: "name", text: `/${c.name}` }),
1404
+ el("span", { class: "name", text: c.hint ? `/${c.name} ${c.hint}` : `/${c.name}` }),
1145
1405
  el("span", { class: "desc", text: (c.description ?? "").replace(/^Sub-Sub:\s*/, "") }),
1146
1406
  ),
1147
1407
  ),
@@ -1151,8 +1411,8 @@ function suggest() {
1151
1411
  }
1152
1412
  function pick(c) {
1153
1413
  input.value = `/${c.name} `;
1154
- $("suggest").hidden = true;
1155
1414
  input.focus();
1415
+ suggest();
1156
1416
  }
1157
1417
 
1158
1418
  input.addEventListener("input", () => {
@@ -1206,7 +1466,6 @@ for (const b of document.querySelectorAll(".modes button")) {
1206
1466
  if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
1207
1467
  });
1208
1468
  }
1209
- $("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
1210
1469
  $("starbuck-toggle").addEventListener("change", async (e) => {
1211
1470
  const on = e.target.checked;
1212
1471
  e.target.disabled = true;
@@ -1220,7 +1479,6 @@ $("starbuck-toggle").addEventListener("change", async (e) => {
1220
1479
  e.target.disabled = false;
1221
1480
  }
1222
1481
  });
1223
- $("profile-side").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
1224
1482
  $("model-btn").addEventListener("click", showModels);
1225
1483
  $("model-btn-side").addEventListener("click", () => {
1226
1484
  closeSide();
@@ -1251,6 +1509,22 @@ $("theme-btn-side")?.addEventListener("click", () => {
1251
1509
  toggleTheme();
1252
1510
  });
1253
1511
  $("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
1512
+ $("library-search").addEventListener("input", searchLibrary);
1513
+ $("notes-btn").addEventListener("click", () => ($("note-pane").hidden ? showNoteList() : showPane(false)));
1514
+ $("note-back").addEventListener("click", showNoteList);
1515
+ $("note-close").addEventListener("click", () => showPane(false));
1516
+ $("note-app").addEventListener("click", () => noteShown && api("/api/open", { path: noteShown.path }).catch((err) => note(err.message, "warning")));
1517
+ $("note-filter").addEventListener("input", renderNoteList);
1518
+ $("note-pane").addEventListener("keydown", (e) => {
1519
+ if (e.key === "Escape") showPane(false);
1520
+ });
1521
+ // Links to notes, in replies and in notes: [[wikilinks]], .md paths in code, relative .md links.
1522
+ document.addEventListener("click", (e) => {
1523
+ const a = e.target.closest?.("a[data-note]");
1524
+ if (!a) return;
1525
+ e.preventDefault();
1526
+ openNote(a.dataset.note);
1527
+ });
1254
1528
  $("export-btn")?.addEventListener("click", exportConversation);
1255
1529
  $("quit-btn").addEventListener("click", async () => {
1256
1530
  try {