@tiagojct/subsub 0.16.0 → 0.17.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,9 +79,14 @@ 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
+ codespan(token) {
87
+ const code = `<code>${escapeHtml(token.text)}</code>`;
88
+ return NOTE_PATH.test(token.text) ? noteLink(token.text, code) : code;
89
+ },
67
90
  image(token) {
68
91
  return escapeHtml(token.text || token.href || "");
69
92
  },
@@ -179,7 +202,6 @@ function renderHeader() {
179
202
  b.setAttribute("aria-pressed", String(b.dataset.mode === mode()));
180
203
  b.title = t(b.dataset.mode === "librarian" ? "librarianHint" : "researcherHint");
181
204
  }
182
- if (state?.profile) $("profile").value = $("profile-side").value = state.profile;
183
205
  $("starbuck-toggle").checked = Boolean(state?.starbuck);
184
206
  $("starbuck-label").title = t("starbuckHint");
185
207
  const m = state?.model ?? session?.model;
@@ -229,10 +251,7 @@ function renderActions() {
229
251
  const box = $("actions");
230
252
  box.replaceChildren();
231
253
  const shown = (a) =>
232
- hasCommand(a.cmd) &&
233
-
234
- (!a.needs || state?.[a.needs]) &&
235
- (!a.profiles || a.profiles.includes(state?.profile ?? "scholar"));
254
+ hasCommand(a.cmd) && (!a.needs || state?.[a.needs]);
236
255
  const groups = mode() === "librarian" ? [["library", "groupLibrary"]] : [["research", "groupResearch"], ["library", "groupLibrary"]];
237
256
  for (const [group, label] of groups) {
238
257
  const list = ACTIONS[group].filter(shown);
@@ -247,6 +266,7 @@ function actionButton(a) {
247
266
  type: "button",
248
267
  text: a[lang] ?? a.en,
249
268
  onclick: () => {
269
+ if (a.item) return $("library-search").focus();
250
270
  closeSide();
251
271
  if (a.send) sendMessage(`/${a.cmd}`);
252
272
  else {
@@ -269,6 +289,130 @@ function renderBanner() {
269
289
  } else b.hidden = true;
270
290
  }
271
291
 
292
+ // ---------------------------------------------------------------- library search
293
+
294
+ let libraryTimer = 0;
295
+ let libraryQuery = "";
296
+
297
+ function renderItems(items, message) {
298
+ const ul = $("items");
299
+ ul.replaceChildren();
300
+ if (message) return ul.append(el("li", { class: "muted", text: message }));
301
+ const researcher = mode() === "researcher";
302
+ for (const it of items) {
303
+ const ref = it.citekey || it.key;
304
+ const run = (cmd) => () => {
305
+ closeSide();
306
+ sendMessage(`/${cmd} ${ref}`);
307
+ };
308
+ ul.append(
309
+ el(
310
+ "li",
311
+ {},
312
+ el("div", { class: "item-title", text: it.title || it.key }),
313
+ el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey].filter(Boolean).join(" · ") }),
314
+ el(
315
+ "div",
316
+ { class: "item-actions" },
317
+ researcher ? el("button", { type: "button", text: t("readItem"), onclick: run("read") }) : null,
318
+ researcher ? el("button", { type: "button", text: t("noteItem"), onclick: run("lit-note") }) : null,
319
+ el("a", { href: `zotero://select/library/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
320
+ ),
321
+ ),
322
+ );
323
+ }
324
+ }
325
+
326
+ function searchLibrary() {
327
+ const q = $("library-search").value.replace(/\s+/g, " ").trim();
328
+ libraryQuery = q;
329
+ clearTimeout(libraryTimer);
330
+ if (!q) return renderItems([]);
331
+ libraryTimer = setTimeout(async () => {
332
+ renderItems([], t("searching"));
333
+ try {
334
+ const data = await api(`/api/library?q=${encodeURIComponent(q)}`);
335
+ if (data.q !== libraryQuery) return; // an older search answered late
336
+ if (data.error) renderItems([], data.error);
337
+ else renderItems(data.items ?? [], data.items?.length ? "" : t("noItems"));
338
+ } catch (err) {
339
+ if (q === libraryQuery) renderItems([], err.message);
340
+ }
341
+ }, 350);
342
+ }
343
+
344
+ // ---------------------------------------------------------------- notes pane
345
+
346
+ let noteShown = null; // the note on screen, or null for the list
347
+ let notesList = [];
348
+
349
+ function showPane(open) {
350
+ $("note-pane").hidden = !open;
351
+ document.querySelector(".app").classList.toggle("note-open", open);
352
+ $("notes-btn").setAttribute("aria-expanded", String(open));
353
+ }
354
+
355
+ function renderNoteList() {
356
+ const body = $("note-body");
357
+ const q = $("note-filter").value.trim().toLowerCase();
358
+ const list = notesList.filter((n) => !q || n.rel.toLowerCase().includes(q));
359
+ const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", year: "numeric" });
360
+ body.replaceChildren(
361
+ list.length
362
+ ? el(
363
+ "ul",
364
+ { class: "note-list" },
365
+ list.map((n) =>
366
+ el(
367
+ "li",
368
+ {},
369
+ 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)) })),
370
+ ),
371
+ ),
372
+ )
373
+ : el("p", { class: "muted small", text: t("noNotes") }),
374
+ );
375
+ }
376
+
377
+ async function showNoteList() {
378
+ noteShown = null;
379
+ $("note-name").textContent = t("notes");
380
+ $("note-back").hidden = true;
381
+ $("note-app").hidden = true;
382
+ $("note-filter").hidden = false;
383
+ showPane(true);
384
+ try {
385
+ notesList = (await api("/api/notes")).notes ?? [];
386
+ } catch (err) {
387
+ notesList = [];
388
+ note(err.message, "warning");
389
+ }
390
+ renderNoteList();
391
+ }
392
+
393
+ /** Show a note in the pane; anything that is not a note in the Sub-Sub folder goes to the system, as before. */
394
+ async function openNote(path) {
395
+ let data;
396
+ try {
397
+ data = await api(`/api/note?path=${encodeURIComponent(path)}`);
398
+ } catch {
399
+ return api("/api/open", { path }).catch((err) => note(err.message, "warning"));
400
+ }
401
+ noteShown = data;
402
+ $("note-name").textContent = data.name;
403
+ $("note-name").title = data.path;
404
+ $("note-back").hidden = false;
405
+ $("note-app").hidden = false;
406
+ $("note-filter").hidden = true;
407
+ const front = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(data.text);
408
+ const content = el("div", { class: "md" });
409
+ content.innerHTML = md(front ? data.text.slice(front[0].length) : data.text);
410
+ attachCopyButtons(content);
411
+ $("note-body").replaceChildren(front ? el("details", { class: "front" }, el("summary", { text: t("properties") }), el("pre", { text: front[1] })) : "", content);
412
+ $("note-body").scrollTop = 0;
413
+ showPane(true);
414
+ }
415
+
272
416
  let loadedSessions = [];
273
417
 
274
418
  function renderSessionsList(filterText = "") {
@@ -283,12 +427,30 @@ function renderSessionsList(filterText = "") {
283
427
  return;
284
428
  }
285
429
  const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
430
+ // shortcut: the browser's own prompt and confirm; move to the page's dialog if they look out of place.
431
+ const rename = (s) => async () => {
432
+ const name = window.prompt(t("renamePrompt"), s.name || s.first || "");
433
+ if (!name?.trim()) return;
434
+ await api("/api/session/name", { path: s.path, name }).catch((err) => note(err.message, "warning"));
435
+ loadSessions();
436
+ };
437
+ const trash = (s) => async () => {
438
+ if (!window.confirm(t("trashConfirm"))) return;
439
+ await api("/api/session/trash", { path: s.path }).catch((err) => note(err.message, "warning"));
440
+ loadSessions();
441
+ };
286
442
  for (const s of filtered) {
287
443
  const current = session?.sessionFile === s.path;
288
444
  ul.append(
289
445
  el(
290
446
  "li",
291
447
  { class: current ? "current" : "" },
448
+ el(
449
+ "span",
450
+ { class: "session-tools" },
451
+ el("button", { type: "button", text: t("rename"), onclick: rename(s) }),
452
+ current ? null : el("button", { type: "button", text: t("trash"), onclick: trash(s) }),
453
+ ),
292
454
  el(
293
455
  "button",
294
456
  {
@@ -333,18 +495,30 @@ function exportConversation() {
333
495
  note(t("noConversations"), "warning");
334
496
  return;
335
497
  }
498
+ const fence = (x) => `\`\`\`text\n${x.length > 3000 ? `${x.slice(0, 3000)}\n...` : x}\n\`\`\``;
336
499
  for (const m of items) {
337
500
  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`);
501
+ const b = m.querySelector(".bubble");
502
+ // A long message is folded: export its full text, not the summary line.
503
+ const text = (b?.tagName === "DETAILS" ? b.querySelector("div")?.innerText : b?.innerText) ?? "";
504
+ if (text && text !== "/subsub-refresh") msgs.push(`### ${t("exportUser")}\n\n${text}\n`);
340
505
  } 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`);
506
+ const parts = [];
507
+ for (const c of m.children) {
508
+ if (c.classList.contains("md")) parts.push(c.innerText);
509
+ else if (c.classList.contains("tool")) {
510
+ const label = c.querySelector(".label")?.textContent ?? "";
511
+ const args = c.querySelector(".args")?.textContent ?? "";
512
+ const out = c.querySelector("pre")?.innerText;
513
+ parts.push(`> ${label}${args ? `: ${args}` : ""}${out ? `\n\n${fence(out)}` : ""}`);
514
+ }
515
+ }
516
+ if (parts.length) msgs.push(`### Sub-Sub\n\n${parts.join("\n\n")}\n`);
343
517
  }
344
518
  }
345
519
  if (!msgs.length) return;
346
520
  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")}`;
521
+ const content = `# ${t("exportHeading")} (${date})\n\n${t("exportMode")}: ${t(mode())}\n\n---\n\n${msgs.join("\n---\n\n")}`;
348
522
  const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
349
523
  const url = URL.createObjectURL(blob);
350
524
  const a = document.createElement("a");
@@ -521,7 +695,7 @@ function updateTool(row, name, args) {
521
695
  text: t("open"),
522
696
  onclick: (e) => {
523
697
  e.preventDefault();
524
- api("/api/open", { path: args.path }).catch((err) => note(err.message, "warning"));
698
+ openNote(args.path);
525
699
  },
526
700
  }),
527
701
  );
@@ -847,7 +1021,7 @@ function dialogTitle(d) {
847
1021
  // The program sends the title in the user's language; both forms get the same friendly title.
848
1022
  if ((m = raw.match(/^(?:apply|aplicar) (\S+)\?$/))) return t("apply", { x: toolLabel(m[1]) });
849
1023
  if ((m = raw.match(/^(?:run|executar) (\S+)\?$/))) return t("run", { x: toolLabel(m[1]) });
850
- if (raw === "undo?" || raw === "anular?") return t("undo");
1024
+ if (raw === "undo?" || raw === "anular?") return t("undoAsk");
851
1025
  if ((m = raw.match(/^(?:write the Starbuck report to|escrever o relatório do Starbuck em) (.+)\?$/))) return t("starbuckReport", { x: m[1] });
852
1026
  if ((m = raw.match(/^(write|edit) (.+)\?$/))) return t("write", { x: m[2] });
853
1027
  return raw;
@@ -895,6 +1069,7 @@ function nextDialog() {
895
1069
  body.replaceChildren();
896
1070
  const buttons = $("dialog-buttons");
897
1071
  buttons.replaceChildren();
1072
+ buttons.classList.toggle("choices", d.method === "select" && (d.options ?? []).some((o) => o.length > 24));
898
1073
  const answer = async (reply) => {
899
1074
  try {
900
1075
  await api("/api/dialog", { id: d.id, ...reply });
@@ -1120,12 +1295,20 @@ let suggestIndex = 0;
1120
1295
  function suggest() {
1121
1296
  const box = $("suggest");
1122
1297
  const v = input.value;
1298
+ // "/lit-note " (a command and a space): show what it needs.
1299
+ const chosen = /^\/(\S+) $/.exec(v) && commands.find((c) => c.name === v.slice(1, -1));
1300
+ if (chosen?.hint) {
1301
+ box.replaceChildren(el("div", { class: "hint-row" }, el("span", { class: "name", text: `/${chosen.name}` }), el("span", { class: "desc", text: chosen.hint })));
1302
+ box.hidden = false;
1303
+ box.list = null;
1304
+ return;
1305
+ }
1123
1306
  if (!/^\/\S*$/.test(v)) {
1124
1307
  box.hidden = true;
1125
1308
  return;
1126
1309
  }
1127
1310
  const q = v.slice(1).toLowerCase();
1128
- const list = commands.filter((c) => c.name !== "subsub-refresh" && c.name.toLowerCase().startsWith(q)).slice(0, 12);
1311
+ const list = commands.filter((c) => !c.name.startsWith("subsub-") && c.name.toLowerCase().startsWith(q)).slice(0, 12);
1129
1312
  if (!list.length) {
1130
1313
  box.hidden = true;
1131
1314
  return;
@@ -1141,7 +1324,7 @@ function suggest() {
1141
1324
  "aria-selected": String(i === suggestIndex),
1142
1325
  onclick: () => pick(c),
1143
1326
  },
1144
- el("span", { class: "name", text: `/${c.name}` }),
1327
+ el("span", { class: "name", text: c.hint ? `/${c.name} ${c.hint}` : `/${c.name}` }),
1145
1328
  el("span", { class: "desc", text: (c.description ?? "").replace(/^Sub-Sub:\s*/, "") }),
1146
1329
  ),
1147
1330
  ),
@@ -1151,8 +1334,8 @@ function suggest() {
1151
1334
  }
1152
1335
  function pick(c) {
1153
1336
  input.value = `/${c.name} `;
1154
- $("suggest").hidden = true;
1155
1337
  input.focus();
1338
+ suggest();
1156
1339
  }
1157
1340
 
1158
1341
  input.addEventListener("input", () => {
@@ -1206,7 +1389,6 @@ for (const b of document.querySelectorAll(".modes button")) {
1206
1389
  if (b.dataset.mode !== mode()) sendMessage(`/${b.dataset.mode}`);
1207
1390
  });
1208
1391
  }
1209
- $("profile").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
1210
1392
  $("starbuck-toggle").addEventListener("change", async (e) => {
1211
1393
  const on = e.target.checked;
1212
1394
  e.target.disabled = true;
@@ -1220,7 +1402,6 @@ $("starbuck-toggle").addEventListener("change", async (e) => {
1220
1402
  e.target.disabled = false;
1221
1403
  }
1222
1404
  });
1223
- $("profile-side").addEventListener("change", (e) => sendMessage(`/profile ${e.target.value}`));
1224
1405
  $("model-btn").addEventListener("click", showModels);
1225
1406
  $("model-btn-side").addEventListener("click", () => {
1226
1407
  closeSide();
@@ -1251,6 +1432,22 @@ $("theme-btn-side")?.addEventListener("click", () => {
1251
1432
  toggleTheme();
1252
1433
  });
1253
1434
  $("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
1435
+ $("library-search").addEventListener("input", searchLibrary);
1436
+ $("notes-btn").addEventListener("click", () => ($("note-pane").hidden ? showNoteList() : showPane(false)));
1437
+ $("note-back").addEventListener("click", showNoteList);
1438
+ $("note-close").addEventListener("click", () => showPane(false));
1439
+ $("note-app").addEventListener("click", () => noteShown && api("/api/open", { path: noteShown.path }).catch((err) => note(err.message, "warning")));
1440
+ $("note-filter").addEventListener("input", renderNoteList);
1441
+ $("note-pane").addEventListener("keydown", (e) => {
1442
+ if (e.key === "Escape") showPane(false);
1443
+ });
1444
+ // Links to notes, in replies and in notes: [[wikilinks]], .md paths in code, relative .md links.
1445
+ document.addEventListener("click", (e) => {
1446
+ const a = e.target.closest?.("a[data-note]");
1447
+ if (!a) return;
1448
+ e.preventDefault();
1449
+ openNote(a.dataset.note);
1450
+ });
1254
1451
  $("export-btn")?.addEventListener("click", exportConversation);
1255
1452
  $("quit-btn").addEventListener("click", async () => {
1256
1453
  try {
package/web/i18n.js CHANGED
@@ -3,7 +3,6 @@
3
3
 
4
4
  export const STRINGS = {
5
5
  en: {
6
- profile: "Profile",
7
6
  model: "Model",
8
7
  noModel: "Connect a model",
9
8
  noModelSend: "No model is connected yet. Choose a provider and save its key; your message is still in the box.",
@@ -20,7 +19,7 @@ export const STRINGS = {
20
19
  close: "Close",
21
20
  apply: "Apply {x}?",
22
21
  run: "Run {x}?",
23
- undo: "Undo this change?",
22
+ undoAsk: "Undo this change?",
24
23
  write: "Allow writing to {x}?",
25
24
  starbuckReport: "Write the Starbuck report to {x}?",
26
25
  approveHint: "Nothing changes until you select Yes.",
@@ -31,9 +30,7 @@ export const STRINGS = {
31
30
  toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
32
31
  serversDown: "Not running: {x}",
33
32
  working: "Working",
34
- using: "Using: {x}",
35
33
  failed: "failed",
36
- result: "Result",
37
34
  open: "Open",
38
35
  stopped: "Stopped.",
39
36
  agentExit: "Sub-Sub stopped.",
@@ -91,7 +88,6 @@ export const STRINGS = {
91
88
  quitDone: "Sub-Sub is closed. You can close this tab.",
92
89
  earlier: "Earlier messages were summarised.",
93
90
  menu: "Menu",
94
- commands: "Commands",
95
91
  error: "Error",
96
92
  theme: "Theme",
97
93
  themeAuto: "System",
@@ -99,7 +95,6 @@ export const STRINGS = {
99
95
  themeDark: "Dark",
100
96
  themeToggle: "Toggle theme",
101
97
  layoutWide: "Wide",
102
- layoutFocus: "Focus",
103
98
  toggleWidth: "Toggle wide layout",
104
99
  copy: "Copy",
105
100
  copied: "Copied",
@@ -107,6 +102,25 @@ export const STRINGS = {
107
102
  searchConversations: "Search conversations",
108
103
  export: "Export",
109
104
  exportTitle: "Export conversation",
105
+ exportHeading: "Sub-Sub conversation",
106
+ exportUser: "You",
107
+ exportMode: "Mode",
108
+ notes: "Notes",
109
+ notesHint: "The notes in your Sub-Sub folder",
110
+ allNotes: "All notes",
111
+ properties: "Properties",
112
+ filterNotes: "Filter notes",
113
+ noNotes: "No notes yet.",
114
+ openInApp: "Open in app",
115
+ librarySearch: "Search your library",
116
+ searching: "Searching...",
117
+ noItems: "Nothing found.",
118
+ readItem: "Read",
119
+ noteItem: "Note",
120
+ rename: "Rename",
121
+ renamePrompt: "A new name for this conversation",
122
+ trash: "Delete",
123
+ trashConfirm: "Move this conversation to the trash folder (~/.subsub/trash)? You can move it back from there.",
110
124
  confirmShortcutHint: "Y to approve, N to decline",
111
125
  starbuck: "Reference checks (Starbuck)",
112
126
  starbuckHint: "Checks that cited works exist, match their citation and were not retracted. Sub-Sub restarts when you change this.",
@@ -128,7 +142,6 @@ export const STRINGS = {
128
142
  groupLibrary: "Library",
129
143
  },
130
144
  pt: {
131
- profile: "Perfil",
132
145
  model: "Modelo",
133
146
  noModel: "Ligar um modelo",
134
147
  noModelSend: "Ainda não há um modelo ligado. Escolha um fornecedor e guarde a chave; a sua mensagem continua na caixa.",
@@ -145,7 +158,7 @@ export const STRINGS = {
145
158
  close: "Fechar",
146
159
  apply: "Confirmar: {x}?",
147
160
  run: "Executar {x}?",
148
- undo: "Desfazer esta alteração?",
161
+ undoAsk: "Desfazer esta alteração?",
149
162
  write: "Permitir escrever em {x}?",
150
163
  starbuckReport: "Escrever o relatório do Starbuck em {x}?",
151
164
  approveHint: "Nada muda até selecionar Sim.",
@@ -156,9 +169,7 @@ export const STRINGS = {
156
169
  toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
157
170
  serversDown: "Parados: {x}",
158
171
  working: "A trabalhar",
159
- using: "A usar: {x}",
160
172
  failed: "falhou",
161
- result: "Resultado",
162
173
  open: "Abrir",
163
174
  stopped: "Parado.",
164
175
  agentExit: "O Sub-Sub parou.",
@@ -216,7 +227,6 @@ export const STRINGS = {
216
227
  quitDone: "O Sub-Sub está fechado. Pode fechar este separador.",
217
228
  earlier: "As mensagens anteriores foram resumidas.",
218
229
  menu: "Menu",
219
- commands: "Comandos",
220
230
  error: "Erro",
221
231
  theme: "Tema",
222
232
  themeAuto: "Sistema",
@@ -224,7 +234,6 @@ export const STRINGS = {
224
234
  themeDark: "Escuro",
225
235
  themeToggle: "Mudar tema",
226
236
  layoutWide: "Largo",
227
- layoutFocus: "Foco",
228
237
  toggleWidth: "Mudar largura do ecrã",
229
238
  copy: "Copiar",
230
239
  copied: "Copiado",
@@ -232,6 +241,25 @@ export const STRINGS = {
232
241
  searchConversations: "Procurar conversas",
233
242
  export: "Exportar",
234
243
  exportTitle: "Exportar conversa",
244
+ exportHeading: "Conversa do Sub-Sub",
245
+ exportUser: "Utilizador",
246
+ exportMode: "Modo",
247
+ notes: "Notas",
248
+ notesHint: "As notas da sua pasta do Sub-Sub",
249
+ allNotes: "Todas as notas",
250
+ properties: "Propriedades",
251
+ filterNotes: "Filtrar notas",
252
+ noNotes: "Ainda não há notas.",
253
+ openInApp: "Abrir na aplicação",
254
+ librarySearch: "Pesquisar na biblioteca",
255
+ searching: "A pesquisar...",
256
+ noItems: "Nada encontrado.",
257
+ readItem: "Ler",
258
+ noteItem: "Nota",
259
+ rename: "Mudar o nome",
260
+ renamePrompt: "Um novo nome para esta conversa",
261
+ trash: "Apagar",
262
+ trashConfirm: "Mover esta conversa para a pasta do lixo (~/.subsub/trash)? Pode trazê-la de volta a partir daí.",
235
263
  confirmShortcutHint: "S para aprovar, N para recusar",
236
264
  starbuck: "Verificação de referências (Starbuck)",
237
265
  starbuckHint: "Verifica se as obras citadas existem, correspondem à citação e não foram retratadas. O Sub-Sub reinicia quando muda esta opção.",
@@ -255,23 +283,26 @@ export const STRINGS = {
255
283
  };
256
284
 
257
285
  /**
258
- * Buttons in the side panel, in two groups. `fill` puts the command in the message box; `send` runs it at once.
259
- * The Librarian sees the Library group only. `profiles` and `needs` limit a button to some profiles or add-ons.
286
+ * Buttons in the side panel, in two groups. A button puts the command in the message box; `send` runs it at once;
287
+ * `item` opens the library search, whose results have their own buttons.
288
+ * The Librarian sees the Library group only. `needs` limits a button to an add-on.
260
289
  */
261
290
  export const ACTIONS = {
262
291
  research: [
263
- { cmd: "lit", fill: true, en: "Literature review", pt: "Revisão da literatura" },
264
- { cmd: "compare", fill: true, profiles: ["scholar", "author", "editor"], en: "Compare sources", pt: "Comparar fontes" },
265
- { cmd: "lit-note", fill: true, en: "Literature note", pt: "Nota de leitura" },
266
- { cmd: "synthesis", fill: true, en: "Synthesis", pt: "Síntese" },
267
- { cmd: "gaps", fill: true, en: "What is missing", pt: "O que falta" },
268
- { cmd: "manuscript", fill: true, en: "Check a manuscript", pt: "Verificar um manuscrito" },
269
- { cmd: "verify", fill: true, needs: "starbuck", en: "Check references", pt: "Verificar referências" },
270
- { cmd: "review", fill: true, profiles: ["author", "editor"], en: "Review a manuscript", pt: "Rever um manuscrito" },
292
+ { cmd: "read", send: true, en: "Read a paper", pt: "Ler um artigo" },
293
+ { cmd: "lit", en: "Literature review", pt: "Revisão da literatura" },
294
+ { cmd: "lit reading list:", en: "Reading list", pt: "Lista de leitura" },
295
+ { cmd: "compare", en: "Compare sources", pt: "Comparar fontes" },
296
+ { cmd: "lit-note", item: true, en: "Literature note", pt: "Nota de leitura" },
297
+ { cmd: "synthesis", en: "Synthesis", pt: "Síntese" },
298
+ { cmd: "gaps", en: "What is missing", pt: "O que falta" },
299
+ { cmd: "manuscript", en: "Check a manuscript", pt: "Verificar um manuscrito" },
300
+ { cmd: "verify", needs: "starbuck", en: "Check references", pt: "Verificar referências" },
301
+ { cmd: "review", en: "Review a manuscript", pt: "Rever um manuscrito" },
271
302
  { cmd: "alert", send: true, en: "New publications", pt: "Novas publicações" },
272
- { cmd: "request-copy", fill: true, en: "Ask an author for a copy", pt: "Pedir uma cópia ao autor" },
273
- { cmd: "ai-statement", fill: true, en: "Statement on AI use", pt: "Declaração de uso de IA" },
274
- { cmd: "digest", send: true, profiles: ["scholar", "author", "editor"], en: "Digest of the week", pt: "Resumo da semana" },
303
+ { cmd: "request-copy", en: "Ask an author for a copy", pt: "Pedir uma cópia ao autor" },
304
+ { cmd: "ai-statement", en: "Statement on AI use", pt: "Declaração de uso de IA" },
305
+ { cmd: "digest", send: true, en: "Digest of the week", pt: "Resumo da semana" },
275
306
  ],
276
307
  library: [
277
308
  { cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
package/web/index.html CHANGED
@@ -21,14 +21,7 @@
21
21
  </div>
22
22
  <div class="status">
23
23
  <span class="library" id="library"><span class="library-dot" aria-hidden="true"></span><span class="library-text"></span></span>
24
- <label class="profile wide-only"><span class="visually-hidden" data-i18n="profile">Profile</span>
25
- <select id="profile">
26
- <option value="reader">Reader</option>
27
- <option value="scholar">Scholar</option>
28
- <option value="author">Author</option>
29
- <option value="editor">Editor</option>
30
- </select>
31
- </label>
24
+ <button type="button" class="chip-btn" id="notes-btn" aria-controls="note-pane" aria-expanded="false" data-i18n="notes" data-i18n-title="notesHint">Notes</button>
32
25
  <button type="button" class="chip-btn model-btn wide-only" id="model-btn"></button>
33
26
  <button type="button" class="icon-btn wide-only" id="width-btn" aria-pressed="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h16M4 12l3.5-3.5M4 12l3.5 3.5M20 12l-3.5-3.5M20 12l-3.5 3.5"/></svg><span class="visually-hidden" id="width-btn-text">Wide</span></button>
34
27
  <button type="button" class="icon-btn" id="theme-btn"></button>
@@ -38,14 +31,6 @@
38
31
  <aside class="side" id="side">
39
32
  <button type="button" class="new-btn" id="new-btn"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span data-i18n="newConversation">New conversation</span></button>
40
33
  <section class="phone-only settings">
41
- <label><span data-i18n="profile">Profile</span>
42
- <select id="profile-side">
43
- <option value="reader">Reader</option>
44
- <option value="scholar">Scholar</option>
45
- <option value="author">Author</option>
46
- <option value="editor">Editor</option>
47
- </select>
48
- </label>
49
34
  <label><span data-i18n="model">Model</span>
50
35
  <button type="button" class="secondary model-btn" id="model-btn-side"></button>
51
36
  </label>
@@ -53,6 +38,11 @@
53
38
  <button type="button" class="secondary" id="theme-btn-side"></button>
54
39
  </label>
55
40
  </section>
41
+ <section>
42
+ <h2 data-i18n="groupLibrary">Library</h2>
43
+ <input type="search" id="library-search" class="search" data-i18n-placeholder="librarySearch" placeholder="Search your library" autocomplete="off">
44
+ <ul class="items" id="items" aria-live="polite"></ul>
45
+ </section>
56
46
  <section>
57
47
  <h2 data-i18n="actions">Actions</h2>
58
48
  <div class="actions" id="actions"></div>
@@ -91,6 +81,17 @@
91
81
  </div>
92
82
  </form>
93
83
  </main>
84
+
85
+ <aside class="note-pane" id="note-pane" hidden>
86
+ <div class="note-head">
87
+ <button type="button" class="link-btn" id="note-back" data-i18n="allNotes">All notes</button>
88
+ <span class="note-name" id="note-name"></span>
89
+ <button type="button" class="link-btn" id="note-app" data-i18n="openInApp" hidden>Open in app</button>
90
+ <button type="button" class="link-btn" id="note-close" data-i18n="close">Close</button>
91
+ </div>
92
+ <input type="search" id="note-filter" class="search" data-i18n-placeholder="filterNotes" placeholder="Filter notes" autocomplete="off">
93
+ <div class="note-body" id="note-body"></div>
94
+ </aside>
94
95
  </div>
95
96
 
96
97
  <dialog id="dialog" class="modal">