@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/CHANGELOG.md +28 -0
- package/README.md +9 -12
- package/dist/bench.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +1 -7
- package/dist/doctor.js +1 -2
- package/dist/feedback.js +55 -0
- package/dist/init.js +7 -20
- package/dist/look.js +2 -2
- package/dist/notes.js +41 -11
- package/dist/prompt.js +4 -6
- package/dist/read.js +89 -0
- package/dist/roles.js +4 -10
- package/dist/subsub.js +161 -53
- package/dist/web.js +139 -9
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/gaps.md +1 -1
- package/prompts/lit.md +28 -9
- package/roles/researcher.md +8 -3
- package/src/bench.ts +1 -1
- package/src/cli.ts +2 -2
- package/src/config.ts +2 -12
- package/src/doctor.ts +1 -2
- package/src/feedback.ts +70 -0
- package/src/init.ts +6 -31
- package/src/look.ts +2 -4
- package/src/notes.ts +44 -10
- package/src/prompt.ts +6 -8
- package/src/read.ts +108 -0
- package/src/roles.ts +5 -12
- package/src/subsub.ts +154 -57
- package/src/usage.ts +1 -1
- package/src/web.ts +129 -9
- package/templates/Zotero agent.md +2 -2
- package/web/app.css +72 -2
- package/web/app.js +297 -23
- package/web/i18n.js +61 -25
- package/web/index.html +17 -16
- package/dist/profiles.js +0 -96
- package/src/profiles.ts +0 -118
package/src/web.ts
CHANGED
|
@@ -16,16 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
18
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
19
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
|
+
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
|
|
20
20
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
|
-
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
22
|
+
import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
|
|
23
23
|
import { StringDecoder } from "node:string_decoder";
|
|
24
24
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
25
25
|
import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
|
|
26
26
|
import { TESTED_MODELS } from "./init.ts";
|
|
27
27
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
28
|
-
import { obsidianRoot } from "./layout.ts";
|
|
28
|
+
import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.ts";
|
|
29
|
+
import { field, frontMatter } from "./notes.ts";
|
|
29
30
|
import { realish, within } from "./paths.ts";
|
|
30
31
|
|
|
31
32
|
// ---------------------------------------------------------------- options
|
|
@@ -199,9 +200,49 @@ const TYPES: Record<string, string> = {
|
|
|
199
200
|
".ico": "image/x-icon",
|
|
200
201
|
};
|
|
201
202
|
|
|
203
|
+
/** Files that the notes pane shows. */
|
|
204
|
+
const NOTE_TYPES = new Set([".md", ".markdown"]);
|
|
205
|
+
|
|
206
|
+
/** Images that a note shows (a figure cut out of a PDF, for example). */
|
|
207
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
208
|
+
|
|
202
209
|
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
203
210
|
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
204
211
|
|
|
212
|
+
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
213
|
+
export function allowedFile(raw: string, cwd: string, vault?: string): string | undefined {
|
|
214
|
+
// expand() resolves against the process folder: use it for "~" only.
|
|
215
|
+
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
216
|
+
const real = realish(target);
|
|
217
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
218
|
+
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
222
|
+
export function wikilinkTarget(name: string, root: string): string | undefined {
|
|
223
|
+
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
224
|
+
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
228
|
+
export function listNotes(root: string, max = 300): Array<{ path: string; rel: string; modified: number }> {
|
|
229
|
+
const out: Array<{ path: string; rel: string; modified: number }> = [];
|
|
230
|
+
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
231
|
+
let names: string[] = [];
|
|
232
|
+
try {
|
|
233
|
+
names = readdirSync(join(root, d), { recursive: true }) as string[];
|
|
234
|
+
} catch {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
for (const n of names) {
|
|
238
|
+
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith("."))) continue;
|
|
239
|
+
const path = join(root, d, n);
|
|
240
|
+
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
244
|
+
}
|
|
245
|
+
|
|
205
246
|
const CSP = [
|
|
206
247
|
"default-src 'none'",
|
|
207
248
|
"script-src 'self'",
|
|
@@ -214,6 +255,14 @@ const CSP = [
|
|
|
214
255
|
"frame-ancestors 'none'",
|
|
215
256
|
].join("; ");
|
|
216
257
|
|
|
258
|
+
function readText(path: string): string {
|
|
259
|
+
try {
|
|
260
|
+
return readFileSync(path, "utf8");
|
|
261
|
+
} catch {
|
|
262
|
+
return "";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
217
266
|
function same(a: string, b: string): boolean {
|
|
218
267
|
const x = Buffer.from(a);
|
|
219
268
|
const y = Buffer.from(b);
|
|
@@ -300,6 +349,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
300
349
|
const agentDir = agentDirFor(env, home);
|
|
301
350
|
const cwd = chooseCwd(process.cwd(), home, cfg.vault, opts.here);
|
|
302
351
|
const lang = uiLanguage(cfg.language);
|
|
352
|
+
const notesRoot = cfg.vault ?? cwd;
|
|
303
353
|
const token = randomBytes(24).toString("base64url");
|
|
304
354
|
const webDir = join(PACKAGE_DIR, "web");
|
|
305
355
|
const bin = join(PACKAGE_DIR, "bin", "subsub.js");
|
|
@@ -313,6 +363,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
313
363
|
const clients = new Set<ServerResponse>();
|
|
314
364
|
const dialogs = new Map<string, any>();
|
|
315
365
|
let subsubState: unknown = null;
|
|
366
|
+
let lastFind: { q?: string } | null = null;
|
|
316
367
|
let busy = false;
|
|
317
368
|
let lastSeen = Date.now();
|
|
318
369
|
let restarting = false;
|
|
@@ -347,6 +398,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
347
398
|
subsubState = null;
|
|
348
399
|
}
|
|
349
400
|
broadcast({ type: "subsub_state", state: subsubState });
|
|
401
|
+
} else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
|
|
402
|
+
try {
|
|
403
|
+
lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
404
|
+
} catch {
|
|
405
|
+
lastFind = null;
|
|
406
|
+
}
|
|
350
407
|
} else if (ev.method === "notify") {
|
|
351
408
|
broadcast(ev);
|
|
352
409
|
}
|
|
@@ -388,13 +445,23 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
388
445
|
out.session = await agent.request({ type: "get_state" });
|
|
389
446
|
lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
|
|
390
447
|
out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
|
|
391
|
-
|
|
448
|
+
const commands = ((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands;
|
|
449
|
+
// RPC has no argument hints: read them from the prompt files.
|
|
450
|
+
out.commands = commands.map((c) => {
|
|
451
|
+
const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
|
|
452
|
+
const hint = front && field(front, "argument-hint");
|
|
453
|
+
return hint ? { ...c, hint } : c;
|
|
454
|
+
});
|
|
392
455
|
} catch (err) {
|
|
393
456
|
out.error = (err as Error).message;
|
|
394
457
|
}
|
|
395
458
|
return out;
|
|
396
459
|
}
|
|
397
460
|
|
|
461
|
+
async function currentSession(): Promise<string | undefined> {
|
|
462
|
+
return ((await agent.request({ type: "get_state" })) as { sessionFile?: string }).sessionFile;
|
|
463
|
+
}
|
|
464
|
+
|
|
398
465
|
async function sessions(): Promise<unknown[]> {
|
|
399
466
|
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
400
467
|
const list = await SessionManager.list(cwd);
|
|
@@ -521,7 +588,8 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
521
588
|
const message = String(body.message ?? "").trim();
|
|
522
589
|
if (!message) return send(res, 400, { error: "empty message" });
|
|
523
590
|
try {
|
|
524
|
-
|
|
591
|
+
// A command answers only after its dialogs close (/read, /undo), so wait longer than the default.
|
|
592
|
+
await agent.request({ type: "prompt", message, ...(busy ? { streamingBehavior: "followUp" } : {}) }, 60 * 60_000);
|
|
525
593
|
} catch (err) {
|
|
526
594
|
// No provider yet: the page opens the model dialog instead of showing pi's text.
|
|
527
595
|
if (/no api key|no model|no models available/i.test((err as Error).message)) return send(res, 409, { error: "nomodel" });
|
|
@@ -609,14 +677,66 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
609
677
|
if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
|
|
610
678
|
});
|
|
611
679
|
}
|
|
680
|
+
case "GET /api/notes":
|
|
681
|
+
return send(res, 200, { root: notesRoot, notes: listNotes(notesRoot) });
|
|
682
|
+
case "GET /api/note": {
|
|
683
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
684
|
+
const wiki = !/[\\/]/.test(raw) && !/\.\w+$/.test(raw) ? wikilinkTarget(raw, notesRoot) : undefined;
|
|
685
|
+
// A relative path is tried in the working folder, then in the Sub-Sub folder (Literature/x.md).
|
|
686
|
+
const file = allowedFile(wiki ?? raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
|
|
687
|
+
if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
688
|
+
if (!NOTE_TYPES.has(extname(file).toLowerCase())) return send(res, 415, { error: "not a note" });
|
|
689
|
+
if (statSync(file).size > 2_000_000) return send(res, 413, { error: "too large to show" });
|
|
690
|
+
return send(res, 200, { path: file, name: basename(file), text: readFileSync(file, "utf8") });
|
|
691
|
+
}
|
|
692
|
+
case "GET /api/note-file": {
|
|
693
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
694
|
+
const file = allowedFile(raw, cwd, cfg.vault) ?? allowedFile(raw, notesRoot, cfg.vault);
|
|
695
|
+
if (!file) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
696
|
+
const type = IMAGE_TYPES[extname(file).toLowerCase()];
|
|
697
|
+
if (!type) return send(res, 415, { error: "not an image" });
|
|
698
|
+
if (statSync(file).size > 20_000_000) return send(res, 413, { error: "too large to show" });
|
|
699
|
+
res.writeHead(200, { ...common, "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
700
|
+
return void createReadStream(file).pipe(res);
|
|
701
|
+
}
|
|
702
|
+
case "GET /api/library": {
|
|
703
|
+
const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
704
|
+
if (!q) return send(res, 200, { q, items: [] });
|
|
705
|
+
await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
|
|
706
|
+
return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
|
|
707
|
+
}
|
|
708
|
+
case "POST /api/session/name": {
|
|
709
|
+
const body = await readJson(req);
|
|
710
|
+
const name = String(body.name ?? "").replace(/\s+/g, " ").trim();
|
|
711
|
+
if (!name || name.length > 120) return send(res, 400, { error: "a name of 1 to 120 characters" });
|
|
712
|
+
const known = (await sessions()) as Array<{ path: string }>;
|
|
713
|
+
if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
|
|
714
|
+
if (body.path === (await currentSession())) await agent.request({ type: "set_session_name", name });
|
|
715
|
+
else {
|
|
716
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
717
|
+
SessionManager.open(String(body.path)).appendSessionInfo(name);
|
|
718
|
+
}
|
|
719
|
+
return send(res, 200, { ok: true });
|
|
720
|
+
}
|
|
721
|
+
case "POST /api/session/trash": {
|
|
722
|
+
const body = await readJson(req);
|
|
723
|
+
const known = (await sessions()) as Array<{ path: string }>;
|
|
724
|
+
if (!known.some((x) => x.path === body.path)) return send(res, 404, { error: "no such session" });
|
|
725
|
+
if (body.path === (await currentSession())) return send(res, 409, { error: "the open conversation" });
|
|
726
|
+
// Nothing is deleted: the file moves to a trash folder next to the agent folder, in the same
|
|
727
|
+
// subfolder as under sessions/, so that moving it back is one step.
|
|
728
|
+
const rel = relative(join(agentDir, "sessions"), String(body.path));
|
|
729
|
+
const to = join(dirname(agentDir), "trash", rel.startsWith("..") ? basename(rel) : rel);
|
|
730
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
731
|
+
renameSync(String(body.path), to);
|
|
732
|
+
return send(res, 200, { ok: true });
|
|
733
|
+
}
|
|
612
734
|
case "POST /api/open": {
|
|
613
735
|
const body = await readJson(req);
|
|
614
|
-
const target = resolve(cwd, expand(String(body.path ?? "")));
|
|
615
736
|
const vault = cfg.vault;
|
|
616
737
|
// Judge the real file, so a link inside the Sub-Sub folder cannot open a document outside it.
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
if (!inside || !existsSync(target)) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
738
|
+
const target = allowedFile(String(body.path ?? ""), cwd, vault);
|
|
739
|
+
if (!target) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
620
740
|
// Documents only: a file that the model wrote must never be run.
|
|
621
741
|
if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
|
|
622
742
|
if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
@@ -41,10 +41,10 @@ Shared rules for Sub-Sub. Sub-Sub reads this file at the start of every session.
|
|
|
41
41
|
- Create a note only when the user asks. Ask before creating more than 10 notes.
|
|
42
42
|
- Write from the full text (get_fulltext) when it exists. Otherwise use the abstract and the Zotero notes, and write "(from abstract)" after the summary.
|
|
43
43
|
- Front matter field evidence: full text, abstract or metadata (what you read). Page numbers only with evidence: full text. Sub-Sub refuses a literature note without this field.
|
|
44
|
-
- Do not copy the abstract in full. Give page
|
|
44
|
+
- Do not copy the abstract in full. Give the page of a quote as (p. N) right after it, when you read the full text; Sub-Sub links it to that page of the PDF in Zotero.
|
|
45
45
|
- If a note for the citekey exists, do not overwrite it. Add a dated section or ask.
|
|
46
46
|
- After the note is written, offer to link it in Zotero with attach_note.
|
|
47
|
-
-
|
|
47
|
+
- /read writes a reading note instead: quotes with their place and questions, which the user answers.
|
|
48
48
|
|
|
49
49
|
Template:
|
|
50
50
|
|
package/web/app.css
CHANGED
|
@@ -195,6 +195,8 @@ select:hover { border-color: var(--rule-strong); }
|
|
|
195
195
|
}
|
|
196
196
|
.app.side-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
|
|
197
197
|
.app.side-collapsed .side { display: none; }
|
|
198
|
+
.app.note-open { grid-template-columns: var(--side-w) minmax(0, 1fr) minmax(20rem, 36%); }
|
|
199
|
+
.app.note-open.side-collapsed { grid-template-columns: 0 minmax(0, 1fr) minmax(20rem, 40%); }
|
|
198
200
|
|
|
199
201
|
/* ---------------------------------------------------------------- top bar */
|
|
200
202
|
|
|
@@ -274,6 +276,8 @@ select:hover { border-color: var(--rule-strong); }
|
|
|
274
276
|
/* ---------------------------------------------------------------- side panel */
|
|
275
277
|
|
|
276
278
|
.side {
|
|
279
|
+
/* The containing block of the switch's hidden input: without it, the page itself scrolls. */
|
|
280
|
+
position: relative;
|
|
277
281
|
border-right: 1px solid var(--rule);
|
|
278
282
|
background: var(--surface);
|
|
279
283
|
padding: 0.9rem 0.75rem 0.75rem;
|
|
@@ -558,6 +562,7 @@ body.layout-full { --measure: 72rem; }
|
|
|
558
562
|
.msg-action-btn { font-size: 0.75rem; padding: 0.15rem 0.45rem; color: var(--faint); }
|
|
559
563
|
.msg-action-btn:hover { color: var(--text); background: var(--surface); }
|
|
560
564
|
.msg-action-btn.copied { color: var(--add); }
|
|
565
|
+
.table-actions { display: flex; justify-content: flex-end; margin: -0.35rem 0 0.9rem; }
|
|
561
566
|
|
|
562
567
|
/* Tool calls: one quiet line each; the result opens below. */
|
|
563
568
|
.tool { margin: 0.35rem 0; font-size: 0.82rem; color: var(--muted); }
|
|
@@ -736,7 +741,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
736
741
|
padding: 0.3rem;
|
|
737
742
|
z-index: 5;
|
|
738
743
|
}
|
|
739
|
-
.suggest button { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
|
|
744
|
+
.suggest button, .suggest .hint-row { display: flex; align-items: baseline; gap: 0.6rem; width: 100%; text-align: left; padding: 0.38rem 0.55rem; border-radius: 5px; }
|
|
740
745
|
.suggest button[aria-selected="true"], .suggest button:hover { background: var(--accent-soft); }
|
|
741
746
|
.suggest .name { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); white-space: nowrap; }
|
|
742
747
|
.suggest .desc { color: var(--muted); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -792,6 +797,10 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
792
797
|
|
|
793
798
|
.modal-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; }
|
|
794
799
|
.modal-buttons button { min-width: 5.5rem; }
|
|
800
|
+
/* Long choices (the reasons of /read) read better as a list than as a row of buttons. */
|
|
801
|
+
.modal-buttons.choices { flex-direction: column; align-items: stretch; }
|
|
802
|
+
.modal-buttons.choices button { text-align: left; }
|
|
803
|
+
.modal-buttons.choices .ghost { align-self: flex-end; }
|
|
795
804
|
.modal-shortcut-hint { font-size: 0.78rem; color: var(--faint); margin-right: auto; }
|
|
796
805
|
.modal-head { display: flex; justify-content: space-between; align-items: center; }
|
|
797
806
|
.modal input[type="text"], .modal input[type="password"], .modal textarea, .modal select {
|
|
@@ -845,9 +854,68 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
845
854
|
.side .phone-only { display: none; }
|
|
846
855
|
.settings { display: grid; gap: 0.6rem; padding: 0 0.3rem; }
|
|
847
856
|
.settings label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: var(--muted); }
|
|
848
|
-
.settings
|
|
857
|
+
.settings button { width: 100%; text-align: left; color: var(--text); }
|
|
849
858
|
.settings .model-btn { font-family: var(--mono); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
850
859
|
|
|
860
|
+
/* ---------------------------------------------------------------- the flow of works (/lit) */
|
|
861
|
+
|
|
862
|
+
.flow { display: grid; gap: 1rem; margin: 1rem 0; font-family: var(--sans); }
|
|
863
|
+
.flow-row { display: grid; grid-template-columns: minmax(0, 15rem) repeat(auto-fit, minmax(0, 13rem)); gap: 1.4rem; align-items: center; }
|
|
864
|
+
.flow-box { position: relative; padding: 0.45rem 0.7rem; border: 1px solid var(--rule-strong); border-radius: var(--radius); background: var(--raised); font-size: 0.84rem; text-align: center; }
|
|
865
|
+
.flow-row + .flow-row .flow-box::before { content: ""; position: absolute; left: 50%; top: calc(-1rem - 1px); height: 1rem; border-left: 1px solid var(--muted); }
|
|
866
|
+
.flow-side { position: relative; padding: 0.35rem 0.6rem; border: 1px dashed var(--rule-strong); border-radius: var(--radius); color: var(--muted); font-size: 0.78rem; }
|
|
867
|
+
.flow-side::before { content: ""; position: absolute; right: 100%; top: 50%; width: 1.4rem; border-top: 1px dashed var(--muted); }
|
|
868
|
+
|
|
869
|
+
/* ---------------------------------------------------------------- library search and notes */
|
|
870
|
+
|
|
871
|
+
#notes-btn { font-family: var(--sans); }
|
|
872
|
+
#notes-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
|
|
873
|
+
|
|
874
|
+
.items { list-style: none; margin: 0; padding: 0; max-height: 18rem; overflow-y: auto; }
|
|
875
|
+
.items:empty { display: none; }
|
|
876
|
+
.items > li { padding: 0.4rem 0.5rem; border-radius: 5px; }
|
|
877
|
+
.items > li:hover { background: var(--bg); }
|
|
878
|
+
.items > li.muted { font-size: 0.82rem; }
|
|
879
|
+
.item-title { font-size: 0.86rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
880
|
+
.item-meta { font-size: 0.72rem; color: var(--faint); margin-top: 0.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
881
|
+
.item-actions { display: flex; gap: 0.6rem; margin-top: 0.2rem; }
|
|
882
|
+
.item-actions button, .item-actions a { font-size: 0.76rem; color: var(--accent); padding: 0; text-decoration: none; }
|
|
883
|
+
.item-actions button:hover, .item-actions a:hover { text-decoration: underline; text-underline-offset: 0.18em; }
|
|
884
|
+
|
|
885
|
+
.sessions li { position: relative; }
|
|
886
|
+
.session-tools { position: absolute; top: 0.3rem; right: 0.3rem; display: none; gap: 0.3rem; background: var(--raised); border-radius: 4px; }
|
|
887
|
+
.sessions li:hover .session-tools, .sessions li:focus-within .session-tools { display: flex; }
|
|
888
|
+
/* No hover on a touch screen: the buttons stay under the conversation. */
|
|
889
|
+
@media (hover: none) { .session-tools { display: flex; position: static; justify-content: flex-end; background: none; } }
|
|
890
|
+
.session-tools button { width: auto; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: var(--muted); }
|
|
891
|
+
.session-tools button:hover { color: var(--text); background: var(--bg); }
|
|
892
|
+
|
|
893
|
+
.note-pane {
|
|
894
|
+
grid-row: 2;
|
|
895
|
+
grid-column: 3;
|
|
896
|
+
border-left: 1px solid var(--rule);
|
|
897
|
+
background: var(--surface);
|
|
898
|
+
display: flex;
|
|
899
|
+
flex-direction: column;
|
|
900
|
+
min-height: 0;
|
|
901
|
+
min-width: 0;
|
|
902
|
+
}
|
|
903
|
+
.note-pane[hidden] { display: none; }
|
|
904
|
+
.note-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
|
|
905
|
+
.note-name { flex: 1; min-width: 0; font-size: 0.84rem; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
906
|
+
.note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; }
|
|
907
|
+
.note-body { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem 1.5rem; }
|
|
908
|
+
.note-body .front { font-size: 0.74rem; color: var(--muted); margin: 0 0 1rem; }
|
|
909
|
+
.note-body .front summary { cursor: pointer; }
|
|
910
|
+
.note-body .front pre { background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); padding: 0.5rem 0.7rem; margin: 0.4rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
911
|
+
.note-list { list-style: none; margin: 0; padding: 0; }
|
|
912
|
+
.note-list button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; border-radius: 5px; display: grid; gap: 0.1rem; }
|
|
913
|
+
.note-list button:hover { background: var(--bg); }
|
|
914
|
+
.note-list .rel { font-size: 0.84rem; overflow-wrap: anywhere; }
|
|
915
|
+
.note-list .when { font-size: 0.72rem; color: var(--faint); }
|
|
916
|
+
.note-body img.note-img { display: block; max-width: 100%; height: auto; margin: 0.8rem 0; border: 1px solid var(--rule); border-radius: var(--radius); background: #fff; }
|
|
917
|
+
a[data-note] { color: var(--accent); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 0.18em; cursor: pointer; }
|
|
918
|
+
|
|
851
919
|
@media (max-width: 64rem) {
|
|
852
920
|
.composer-hint { display: none; }
|
|
853
921
|
}
|
|
@@ -865,6 +933,8 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
|
|
|
865
933
|
box-shadow: var(--shadow);
|
|
866
934
|
}
|
|
867
935
|
.side.open { transform: none; }
|
|
936
|
+
.app.note-open, .app.note-open.side-collapsed { grid-template-columns: minmax(0, 1fr); }
|
|
937
|
+
.note-pane { position: fixed; z-index: 25; inset: 0; border-left: 0; }
|
|
868
938
|
.top { padding: 0.5rem 0.75rem; gap: 0.5rem; }
|
|
869
939
|
.wide-only { display: none !important; }
|
|
870
940
|
.library .library-text { display: none; }
|