@@ -377,7 +503,7 @@ export function AiPanel({ pdf, docId, persist, docName, context, onClearContext,
)}
{messages.length === 0 && (
-
Ask anything about {docName.replace(/\.pdf$/i, "")} , or select text in the document and choose Ask AI .
+
Ask anything about {docName.replace(/\.pdf$/i, "")} , or select text in the document and choose Ask AI . Add other open documents with + .
{(context ? ["Explain this in simple terms", "Why does this matter?", "Summarize this passage"]
: ["Summarize this document", "What are the key takeaways?", "What questions does this leave open?"]).map((q) => (
@@ -392,6 +518,7 @@ export function AiPanel({ pdf, docId, persist, docName, context, onClearContext,
<>
{m.context &&
p. {m.context.page}: {m.context.text.length > 220 ? m.context.text.slice(0, 220) + "…" : m.context.text} }
{m.content}
+ {!!m.notes?.length &&
{m.notes.map((note, i) =>
{note}
)}
}
>
) : m.error ? (
@@ -406,7 +533,7 @@ export function AiPanel({ pdf, docId, persist, docName, context, onClearContext,
) : m.content ? (
<>
{m.content}}>
-
+
{(!busy || i < messages.length - 1) &&
}
>
@@ -429,6 +556,48 @@ export function AiPanel({ pdf, docId, persist, docName, context, onClearContext,
)}
+ {adding &&
Opening {adding}…
}
+ {attachmentError &&
{attachmentError}
}
+
+
+
setAddOpen(open => !open)}>
+
+
+ {addOpen && (
+
{
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
+ event.preventDefault();
+ const items = [...event.currentTarget.querySelectorAll('[role="menuitem"]')];
+ const at = items.indexOf(document.activeElement as HTMLElement);
+ items[(at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
+ }}>
+ {addable.length ? addable.map((doc, i) => (
+ { setAddOpen(false); void addAttachment(doc, addableNames[i].name); addButtonRef.current?.focus(); }}>
+
+ {addableNames[i].name.replace(/\.pdf$/i, "")}
+
+ )) :
+ {full ? `You can add up to ${MAX_ATTACHMENTS} documents` : "Open another PDF in a tab to add it"}
+ }
+
+ )}
+
+ {attachments.map(attachment => {
+ const open = openIds.has(attachment.contentId);
+ return (
+
+ {attachment.name.replace(/\.pdf$/i, "")}{!open && " (not open)"}
+ removeAttachment(attachment.contentId)}>
+
+
+
+ );
+ })}
+
diff --git a/src/aiAttachments.test.ts b/src/aiAttachments.test.ts
new file mode 100644
index 0000000..6789553
--- /dev/null
+++ b/src/aiAttachments.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+
+import { MAX_DOC_CHARS, distinctAttachmentNames, documentNotes, shareBudget } from "./aiAttachments";
+
+const page = (n: number, text: string) => `[Page ${n}]\n${text}\n\n`;
+const pages = (text: string, count: number) => Array.from({ length: count }, (_, i) => page(i + 1, text)).join("");
+const doc = (name: string, text: string, truncated = false, recognized = false): AiDocument => ({ name, text, truncated, recognized });
+
+describe("distinctAttachmentNames", () => {
+ it("keeps distinct names and content identities unchanged", () => {
+ const attachment = { name: "Other.pdf", contentId: "other" };
+ expect(distinctAttachmentNames("Main.pdf", [attachment])[0]).toBe(attachment);
+ });
+
+ it("gives same-name documents separate names for their chips and citations", () => {
+ const attachments = [{ name: "Report.pdf", contentId: "a" }, { name: "Report.pdf", contentId: "b" }];
+ expect(distinctAttachmentNames("Report.pdf", attachments)).toEqual([
+ { name: "Report (2).pdf", contentId: "a" }, { name: "Report (3).pdf", contentId: "b" },
+ ]);
+ expect(attachments.map(a => a.name)).toEqual(["Report.pdf", "Report.pdf"]);
+ });
+
+ it("matches citation spelling and preserves names that already have a number", () => {
+ expect(distinctAttachmentNames("REPORT.pdf", [
+ { name: "Report", contentId: "a" }, { name: "Report (2).pdf", contentId: "b" },
+ ])).toEqual([{ name: "Report (3)", contentId: "a" }, { name: "Report (2).pdf", contentId: "b" }]);
+ });
+});
+
+describe("shareBudget", () => {
+ it("leaves documents within the budget untouched", () => {
+ const a = doc("a.pdf", page(1, "hello") + page(2, "world"));
+ expect(shareBudget([a])).toEqual([a]);
+ const b = doc("b.pdf", page(1, "more"), true);
+ expect(shareBudget([a, b], 1000)).toEqual([a, b]);
+ });
+
+ it("splits the budget evenly between two long documents, at page boundaries", () => {
+ const a = doc("a.pdf", pages("aaaa", 4));
+ const b = doc("b.pdf", pages("bbbb", 4));
+ const result = shareBudget([a, b], 60);
+ expect(result[0].text).toBe(pages("aaaa", 2));
+ expect(result[0]).toMatchObject({ name: "a.pdf", truncated: true, recognized: false });
+ expect(result[1].text).toBe(pages("bbbb", 2));
+ expect(result[1]).toMatchObject({ name: "b.pdf", truncated: true, recognized: false });
+ expect(result[0]).not.toBe(a); // new objects; the inputs are untouched
+ expect(a.text).toBe(pages("aaaa", 4));
+ });
+
+ it("lets a short document keep its text and gives its share to the long one", () => {
+ const short = doc("short.pdf", pages("aaaa", 2));
+ const long = doc("long.pdf", pages("bbbb", 10));
+ const result = shareBudget([short, long], 90);
+ expect(result[0].text).toBe(short.text);
+ expect(result[0].truncated).toBe(false);
+ expect(result[1].text).toBe(pages("bbbb", 4));
+ expect(result[1].truncated).toBe(true);
+ });
+
+ it("never cuts a page in half", () => {
+ const a = doc("a.pdf", page(1, "words of the first page") + page(2, "words of the second page") + page(3, "words of the third"));
+ const result = shareBudget([a], 40);
+ expect(result[0].text).toBe(page(1, "words of the first page"));
+ expect(result[0].text.endsWith("\n\n")).toBe(true);
+ expect(result[0].text).not.toContain("[Page 2]");
+ });
+
+ it("sends a document with no text when even its first page doesn't fit its share", () => {
+ const huge = doc("huge.pdf", page(1, "x".repeat(100)));
+ const result = shareBudget([huge], 20);
+ expect(result[0].text).toBe("");
+ expect(result[0]).toMatchObject({ name: "huge.pdf", truncated: true });
+ });
+
+ it("keeps an empty document empty and gives its share to the others", () => {
+ const empty = doc("empty.pdf", "");
+ const scanned = doc("scan.pdf", page(1, "aaaa"), false, true);
+ const result = shareBudget([empty, scanned], 15);
+ expect(result[0]).toEqual(empty);
+ expect(result[1].text).toBe(page(1, "aaaa"));
+ expect(result[1].recognized).toBe(true);
+ });
+
+ it("keeps the truncated flag of documents it doesn't shorten", () => {
+ const pre = doc("pre.pdf", page(1, "aaaa"), true);
+ expect(shareBudget([pre], 100)).toEqual([pre]);
+ });
+
+ it("shares the default budget between documents", () => {
+ // Three pages of 90k characters each: two such documents don't fit in the default budget together.
+ const body = "a".repeat(90_000);
+ const a = doc("a.pdf", pages(body, 3));
+ const b = doc("b.pdf", pages(body, 3));
+ const result = shareBudget([a, b]);
+ expect(result[0].text).toBe(pages(body, 2));
+ expect(result[1].text).toBe(pages(body, 2));
+ expect(result.map(d => d.text).join("").length).toBeLessThanOrEqual(MAX_DOC_CHARS);
+ });
+});
+
+describe("documentNotes", () => {
+ it("notes documents that were cut or have no text", () => {
+ const notes = documentNotes([
+ doc("normal.pdf", page(1, "text")),
+ doc("cut.pdf", page(1, "text"), true),
+ doc("scan.pdf", ""),
+ ]);
+ expect(notes).toEqual([
+ "Only the first pages of cut.pdf were sent.",
+ "scan.pdf has no readable text yet. If it's a scan, wait for text recognition to finish.",
+ ]);
+ });
+
+ it("says when none of a document fit", () => {
+ expect(documentNotes([doc("huge.pdf", "", true)])).toEqual([
+ "huge.pdf is too long to include alongside the other documents, so none of it was sent.",
+ ]);
+ });
+});
diff --git a/src/aiAttachments.ts b/src/aiAttachments.ts
new file mode 100644
index 0000000..3838a8f
--- /dev/null
+++ b/src/aiAttachments.ts
@@ -0,0 +1,79 @@
+// Ask AI can read other open documents along with the one its conversation belongs to.
+
+export type DocText = { text: string; truncated: boolean; recognized: boolean };
+
+/** Characters of document text sent with a question, shared by every document in it. */
+export const MAX_DOC_CHARS = 400_000;
+
+/** Documents that can be added to one conversation; the main process accepts no more. */
+export const MAX_ATTACHMENTS = 20;
+
+/** A document added to a conversation. Saved with the conversation; its text is read again when asked. */
+export interface Attachment { name: string; contentId: string }
+
+/** Same-name PDFs need separate names in the prompt and citations; keep those names with the conversation. */
+export function distinctAttachmentNames
(mainName: string, attachments: T[]): T[] {
+ const key = (name: string) => name.trim().replace(/\.pdf$/i, "").toLowerCase();
+ const reserved = new Set([mainName, ...attachments.map(a => a.name)].map(key));
+ const seen = new Set([key(mainName)]);
+ return attachments.map(attachment => {
+ if (!seen.has(key(attachment.name))) { seen.add(key(attachment.name)); return attachment; }
+ const extension = attachment.name.match(/\.pdf$/i)?.[0] ?? "";
+ const stem = extension ? attachment.name.slice(0, -extension.length) : attachment.name;
+ let number = 2, name: string;
+ do { name = `${stem} (${number++})${extension}`; } while (reserved.has(key(name)));
+ reserved.add(key(name));
+ seen.add(key(name));
+ return { ...attachment, name };
+ });
+}
+
+/** Fit every document of a question into one shared budget, cutting at page boundaries. */
+export function shareBudget(documents: AiDocument[], budget = MAX_DOC_CHARS): AiDocument[] {
+ const blocks = documents.map(doc => pageBlocks(doc.text));
+ // Water-filling: documents shorter than an equal share keep all their text; the rest split what is left.
+ const lengths = documents.map(doc => doc.text.length);
+ let pending = documents.map((_, i) => i);
+ let remaining = budget;
+ const caps = documents.map(() => 0);
+ while (pending.length > 0) {
+ const share = remaining / pending.length;
+ const keepers = pending.filter(i => lengths[i] <= share);
+ if (keepers.length === 0) break;
+ for (const i of keepers) caps[i] = Infinity;
+ pending = pending.filter(i => !keepers.includes(i));
+ remaining -= keepers.reduce((sum, i) => sum + lengths[i], 0);
+ }
+ for (const i of pending) caps[i] = remaining / pending.length;
+ return documents.map((doc, i) => {
+ if (caps[i] === Infinity) return doc;
+ let text = "";
+ for (const block of blocks[i]) {
+ if (text.length + block.length > caps[i]) break;
+ text += block;
+ }
+ // A document whose first page alone doesn't fit its share is sent without text.
+ return { ...doc, text, truncated: true };
+ });
+}
+
+/** Notes to show under the chat for documents the question could not fully include. */
+export function documentNotes(documents: AiDocument[]): string[] {
+ return documents.flatMap(doc => {
+ if (doc.truncated && doc.text === "") return [`${doc.name} is too long to include alongside the other documents, so none of it was sent.`];
+ if (doc.truncated) return [`Only the first pages of ${doc.name} were sent.`];
+ if (doc.text === "") return [`${doc.name} has no readable text yet. If it's a scan, wait for text recognition to finish.`];
+ return [];
+ });
+}
+
+/** Whole "[Page N]" blocks; a final page cut short by extraction counts as one block. */
+function pageBlocks(text: string): string[] {
+ const starts: number[] = [];
+ // Only markers at the start of a line, so "[Page 3]" quoted inside a page's own text is not a boundary.
+ const marker = /(?<=^|\n)\[Page \d+\]\n/g;
+ for (let match = marker.exec(text); match; match = marker.exec(text)) starts.push(match.index);
+ if (starts.length === 0) return text === "" ? [] : [text];
+ if (starts[0] > 0) starts.unshift(0); // keep any text before the first marker with the first page
+ return starts.map((start, i) => text.slice(start, starts[i + 1]));
+}
diff --git a/src/desktop.d.ts b/src/desktop.d.ts
index 497ffaf..3feaf65 100644
--- a/src/desktop.d.ts
+++ b/src/desktop.d.ts
@@ -73,14 +73,17 @@ interface FolioAi {
openRouterModels(): Promise<{ ok: boolean; models?: OpenRouterModel[]; error?: string }>;
customEndpoint(action: "save" | "check" | "remove", input?: { baseUrl: string; key: string }): Promise<{ ok: boolean; status?: CustomEndpointStatus; error?: string }>;
customModels(): Promise<{ ok: boolean; models?: OpenRouterModel[]; error?: string }>;
- ask(request: { id: string; provider: "chatgpt" | "claude" | "openrouter" | "custom"; model: string; document: { name: string; text: string; truncated: boolean; recognized: boolean };
- messages: AiMessage[] }): Promise;
+ /** `document` is the conversation's own document. `attachments` are other open documents added to it. */
+ ask(request: { id: string; provider: "chatgpt" | "claude" | "openrouter" | "custom"; model: string; document: AiDocument;
+ attachments?: AiDocument[]; messages: AiMessage[] }): Promise;
cancel(id: string): Promise;
onEvent(callback: (event: AiEvent) => void): () => void;
}
interface OpenRouterModel { id: string; name: string; contextLength: number | null }
+interface AiDocument { name: string; text: string; truncated: boolean; recognized: boolean }
+
interface CustomEndpointStatus { configured: boolean; baseUrl: string; hasKey: boolean }
interface Window {
diff --git a/src/documentTabs.test.ts b/src/documentTabs.test.ts
new file mode 100644
index 0000000..3a0142f
--- /dev/null
+++ b/src/documentTabs.test.ts
@@ -0,0 +1,132 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from "vitest";
+import { DocumentTabs, type Reader } from "./documentTabs";
+
+function reader(text: string, locked = false) {
+ return {
+ open: vi.fn(),
+ flush: async () => {},
+ text: () => Promise.resolve({ text, truncated: false, recognized: false }),
+ locked: () => locked,
+ goTo: vi.fn(),
+ } satisfies Reader;
+}
+
+function twoTabs(lockedB = false) {
+ const tabs = new DocumentTabs(document.createElement("div"));
+ tabs.library();
+ tabs.open([{ kind: "sample", name: "A.pdf" }, { kind: "sample", name: "B.pdf" }]);
+ const [a, b] = tabs.all.map((tab) => tab.key);
+ const readerB = reader("[Page 1]\nB text", lockedB);
+ tabs.bind(a, reader("[Page 1]\nA text"));
+ tabs.bind(b, readerB);
+ tabs.opened(a, "id-a", "A.pdf");
+ tabs.opened(b, "id-b", "B.pdf");
+ return { tabs, a, b, readerB };
+}
+
+describe("DocumentTabs for Ask AI", () => {
+ it("offers unvisited tabs and loads a chosen one without switching the active document", async () => {
+ const tabs = new DocumentTabs(document.createElement("div"));
+ tabs.open([{ kind: "sample", name: "A.pdf" }, { kind: "sample", name: "B.pdf" }]);
+ const [a, b] = tabs.all.map(tab => tab.key);
+ expect(tabs.documents(a)).toEqual([{ key: b, name: "B.pdf", locked: false }]);
+ const prepared = tabs.prepareDocument(b);
+ expect(tabs.active).toBe(a);
+ expect(tabs.all[1].frame?.inert).toBe(true);
+ expect(tabs.all[1].frame?.classList.contains("active")).toBe(false);
+ let finish!: () => void;
+ const readerB = reader("B text", true);
+ readerB.open.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
+ tabs.bind(b, readerB);
+ tabs.opened(b, "id-b", "B.pdf");
+ let settled = false;
+ void prepared.then(() => { settled = true; });
+ await Promise.resolve();
+ expect(settled).toBe(false); // opened() precedes OCR restoration and the reader's password flag.
+ finish();
+ expect(await prepared).toEqual({ key: b, name: "B.pdf", contentId: "id-b", locked: true });
+ expect(tabs.active).toBe(a);
+ });
+
+ it("settles an add when an unvisited tab closes before its reader binds", async () => {
+ const tabs = new DocumentTabs(document.createElement("div"));
+ tabs.open([{ kind: "sample", name: "A.pdf" }, { kind: "sample", name: "B.pdf" }]);
+ const b = tabs.all[1].key;
+ const prepared = tabs.prepareDocument(b);
+ await tabs.close(b);
+ expect(await prepared).toBeNull();
+ });
+
+ it("lets a locked or failed open finish without attaching an unreadable document", async () => {
+ const tabs = new DocumentTabs(document.createElement("div"));
+ tabs.open([{ kind: "sample", name: "A.pdf" }, { kind: "sample", name: "B.pdf" }]);
+ const b = tabs.all[1].key;
+ const prepared = tabs.prepareDocument(b);
+ tabs.bind(b, { open: async () => {}, flush: async () => {}, text: () => null });
+ expect(await prepared).toBeNull();
+ });
+
+ it("settles a rejected reader open without leaving a pending add", async () => {
+ const tabs = new DocumentTabs(document.createElement("div"));
+ tabs.open([{ kind: "sample", name: "A.pdf" }, { kind: "sample", name: "Broken.pdf" }]);
+ const b = tabs.all[1].key;
+ const prepared = tabs.prepareDocument(b);
+ tabs.bind(b, { open: async () => { throw new Error("bad file"); }, flush: async () => {} });
+ expect(await prepared).toBeNull();
+ expect(tabs.documents(tabs.active)).toEqual([]);
+ });
+
+ it("lists documents open in other tabs and reads their text", async () => {
+ const { tabs, a, b } = twoTabs(true);
+ expect(tabs.documents(a)).toEqual([{ key: b, name: "B.pdf", contentId: "id-b", locked: true }]);
+ expect(await tabs.documentText("id-b")).toEqual({ text: "[Page 1]\nB text", truncated: false, recognized: false, name: "B.pdf", locked: true });
+ });
+
+ it("stops matching a document once it is edited, and matches the edited version", async () => {
+ const { tabs, a } = twoTabs();
+ tabs.revised(tabs.all[1].key, "id-b2");
+ expect(await tabs.documentText("id-b")).toBeNull();
+ expect(tabs.documents(a).map((doc) => doc.contentId)).toEqual(["id-b2"]);
+ // The tab still counts as the file it opened, so the library's dirty check keeps working.
+ expect(tabs.all[1].contentId).toBe("id-b");
+ });
+
+ it("switches to the tab showing a cited page", () => {
+ const { tabs, a, b, readerB } = twoTabs();
+ tabs.select(a);
+ expect(tabs.showPage("id-b", 3)).toBe(true);
+ expect(tabs.active).toBe(b);
+ expect(readerB.goTo).toHaveBeenCalledWith(3);
+ expect(tabs.showPage("missing", 1)).toBe(false);
+ });
+
+ it("forgets a document when its tab closes", async () => {
+ const { tabs, a, b } = twoTabs();
+ await tabs.close(b);
+ expect(tabs.documents(a)).toEqual([]);
+ expect(await tabs.documentText("id-b")).toBeNull();
+ expect(tabs.showPage("id-b", 1)).toBe(false);
+ });
+
+ it("lists identical live content only once even when an edited tab and a copy are open", () => {
+ const { tabs, a, b } = twoTabs();
+ tabs.revised(b, "edited-b");
+ tabs.open([{ kind: "sample", name: "Copy.pdf" }]);
+ const copy = tabs.active;
+ tabs.bind(copy, reader("B text"));
+ tabs.opened(copy, "edited-b", "Copy.pdf");
+ expect(tabs.documents(a).filter(doc => doc.contentId === "edited-b")).toHaveLength(1);
+ });
+
+ it.each(["edit", "close"])("discards text if its tab changes during extraction: %s", async change => {
+ const { tabs, b } = twoTabs();
+ let finish!: (text: { text: string; truncated: boolean; recognized: boolean }) => void;
+ tabs.bind(b, { ...reader(""), text: () => new Promise(resolve => { finish = resolve; }) });
+ const text = tabs.documentText("id-b");
+ if (change === "edit") tabs.revised(b, "new-b");
+ else await tabs.close(b);
+ finish({ text: "stale private text", truncated: false, recognized: false });
+ expect(await text).toBeNull();
+ });
+});
diff --git a/src/documentTabs.ts b/src/documentTabs.ts
index f3119b7..df6c179 100644
--- a/src/documentTabs.ts
+++ b/src/documentTabs.ts
@@ -1,5 +1,6 @@
import { activeDocument, setActiveDocument } from "./session";
import { getRecent } from "./recent";
+import type { DocText } from "./aiAttachments";
export type OpenSource =
| { kind: "bytes"; name: string; data: Uint8Array; note?: string; password?: string }
@@ -9,11 +10,20 @@ export type OpenSource =
| { kind: "sample" | "converting"; name: string }
| { kind: "error"; name: string; message: string };
-interface Reader {
- open(source: OpenSource): void;
+export interface Reader {
+ open(source: OpenSource): void | Promise;
flush(): Promise;
+ /** Text of the open document for Ask AI, or null while nothing is open. */
+ text?(): Promise | null;
+ /** True for a password-protected document, whose text must not be saved with a conversation. */
+ locked?(): boolean;
+ goTo?(page: number): void;
}
+/** An open document that Ask AI can add to another tab's conversation. */
+export interface OpenDocument { key: number; name: string; contentId?: string; locked: boolean }
+type PreparedDocument = OpenDocument & { contentId: string };
+
export interface ReaderContext { tabs: DocumentTabs; key: number }
export interface ReaderFrame extends HTMLIFrameElement { readerContext?: ReaderContext; disposeReader?: () => void }
@@ -21,10 +31,14 @@ interface Tab {
key: number;
name: string;
contentId?: string;
+ /** Content of the bytes on screen. An in-place edit changes it while contentId keeps the opened file. */
+ liveId?: string;
dirty: boolean;
frame?: ReaderFrame;
reader?: Reader;
source?: OpenSource;
+ opening?: Promise;
+ preparing?: Set<(document: PreparedDocument | null | Promise) => void>;
closing?: boolean;
}
@@ -54,6 +68,52 @@ export class DocumentTabs {
get all(): readonly Tab[] { return this.entries; }
snapshot = () => this.revision;
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
+
+ /** Documents open in other tabs. Tabs are identified by content, so a closed and reopened file matches again,
+ * and an edited document no longer matches what was added before the edit. */
+ documents(except: number): OpenDocument[] {
+ const seen = new Set([this.entry(except)?.liveId]);
+ return this.entries.flatMap(tab => {
+ if (tab.key === except || tab.closing || !tab.name || (tab.liveId && seen.has(tab.liveId))) return [];
+ if (tab.liveId) seen.add(tab.liveId);
+ return [{ key: tab.key, name: tab.name, ...(tab.liveId ? { contentId: tab.liveId } : {}), locked: !!tab.reader?.locked?.() }];
+ });
+ }
+ private prepared(tab: Tab): PreparedDocument | null {
+ if (!this.entries.includes(tab) || tab.closing || tab.source || tab.opening || !tab.liveId || !tab.reader?.text) return null;
+ return { key: tab.key, name: tab.name, contentId: tab.liveId, locked: !!tab.reader.locked?.() };
+ }
+ private finishPreparing(tab: Tab, document = this.prepared(tab)) {
+ for (const resolve of tab.preparing ?? []) resolve(document);
+ tab.preparing?.clear();
+ }
+ /** Load a chosen tab in the background. Wait for the reader, including password/OCR restoration, before adding it. */
+ prepareDocument(key: number): Promise {
+ const tab = this.entry(key);
+ if (!tab || tab.closing) return Promise.resolve(null);
+ if (this.prepared(tab) || (tab.reader && !tab.source && !tab.opening)) return Promise.resolve(this.prepared(tab));
+ const ready = new Promise(resolve => (tab.preparing ??= new Set()).add(resolve));
+ this.ensureFrame(tab);
+ return ready;
+ }
+ private byContent(contentId: string) { return this.entries.find(tab => !tab.closing && tab.liveId === contentId); }
+ /** The current text of an open document, or null when no tab shows it any more. */
+ async documentText(contentId: string): Promise<(DocText & { name: string; locked: boolean }) | null> {
+ const tab = this.byContent(contentId);
+ const text = tab?.reader?.text?.();
+ if (!tab || !text) return null;
+ const result = await text;
+ if (!this.entries.includes(tab) || tab.closing || tab.liveId !== contentId) return null;
+ return { ...result, name: tab.name, locked: !!tab.reader?.locked?.() };
+ }
+ /** Switch to the tab showing a document and go to a page. False when it is no longer open. */
+ showPage(contentId: string, page: number) {
+ const tab = this.byContent(contentId);
+ if (!tab) return false;
+ this.select(tab.key);
+ tab.reader?.goTo?.(page);
+ return true;
+ }
private changed() {
this.revision++;
const selected = this.entry(this.active);
@@ -66,6 +126,22 @@ export class DocumentTabs {
this.entries.push(tab);
return tab;
}
+ private ensureFrame(tab: Tab) {
+ if (tab.frame) return tab.frame;
+ const frame: ReaderFrame = document.createElement("iframe");
+ frame.readerContext = { tabs: this, key: tab.key };
+ frame.className = "document-reader";
+ frame.inert = tab.key !== this.active;
+ frame.classList.toggle("active", !frame.inert);
+ frame.title = tab.name || "Library";
+ frame.name = "folio-reader-" + tab.key;
+ frame.allowFullscreen = true;
+ frame.src = new URL("index.html?reader", location.href).href;
+ frame.addEventListener("error", () => this.finishPreparing(tab, null));
+ tab.frame = frame;
+ this.root.append(frame);
+ return frame;
+ }
select(key: number, focusTab = false) {
const next = this.entry(key);
if (!next || next.closing) return;
@@ -76,18 +152,7 @@ export class DocumentTabs {
tab.frame.inert = tab !== next;
tab.frame.classList.toggle("active", tab === next);
}
- if (!next.frame) {
- const frame: ReaderFrame = document.createElement("iframe");
- frame.readerContext = { tabs: this, key };
- frame.className = "document-reader active";
- frame.title = next.name || "Library";
- frame.name = "folio-reader-" + key;
- frame.allowFullscreen = true;
- frame.src = new URL("index.html?reader", location.href).href;
- next.frame = frame;
- this.root.append(frame);
- }
- next.frame.focus({ preventScroll: true });
+ this.ensureFrame(next).focus({ preventScroll: true });
setActiveDocument(next.contentId ?? null);
this.changed();
}
@@ -113,7 +178,21 @@ export class DocumentTabs {
if (!tab.reader || !tab.source) return;
const source = tab.source;
tab.source = undefined;
- tab.reader.open(source);
+ const opening = Promise.resolve().then(() => {
+ if (!tab.closing && this.entries.includes(tab)) return tab.reader!.open(source);
+ });
+ tab.opening = opening;
+ void opening.then(() => {
+ if (tab.opening !== opening) return;
+ tab.opening = undefined;
+ this.finishPreparing(tab);
+ this.changed();
+ }, () => {
+ if (tab.opening !== opening) return;
+ tab.opening = undefined;
+ this.failed(tab.key);
+ this.finishPreparing(tab, null);
+ });
}
bind(key: number, reader: Reader) {
const tab = this.entry(key);
@@ -133,17 +212,25 @@ export class DocumentTabs {
// Recent, notes and chat are keyed by content. Two editors for identical bytes would overwrite each other.
const other = this.entries.find(candidate => candidate !== tab && !candidate.closing && candidate.contentId === id);
if (other) {
+ for (const resolve of tab.preparing ?? []) resolve(this.prepareDocument(other.key));
+ tab.preparing?.clear();
if (this.active === key) this.select(other.key);
void this.close(key);
return false;
}
tab.name = name;
tab.contentId = id;
+ tab.liveId = id;
if (tab.frame) tab.frame.title = name;
if (this.active === key) setActiveDocument(id);
this.changed();
return true;
}
+ /** The tab now shows an edited version of its document. */
+ revised(key: number, id: string) {
+ const tab = this.entry(key);
+ if (tab && !tab.closing && tab.liveId !== id) { tab.liveId = id; this.changed(); }
+ }
markDirty(key: number, dirty: boolean) {
const tab = this.entry(key);
if (tab && tab.dirty !== dirty) { tab.dirty = dirty; this.changed(); }
@@ -156,6 +243,7 @@ export class DocumentTabs {
const tab = this.entry(key);
if (!tab || tab.closing || (tab.dirty && !window.confirm(`Close “${tab.name}” and discard its unsaved changes?`))) return;
tab.closing = true;
+ this.finishPreparing(tab, null);
try {
await tab.reader?.flush();
} catch {
diff --git a/src/reading-tools.css b/src/reading-tools.css
index d098435..4dcfc89 100644
--- a/src/reading-tools.css
+++ b/src/reading-tools.css
@@ -549,6 +549,78 @@
.context-chip button {
color: var(--text-3);
}
+/* Documents added to a question sit beside the "+" picker, in the passage chip's clothes. */
+.ai-notes p {
+ margin: 0;
+ font-size: 11.5px;
+ line-height: 1.4;
+ color: var(--text-3);
+}
+.ai-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 6px;
+}
+.ai-add {
+ position: relative;
+ flex: none;
+}
+.ai-add .pill-btn {
+ width: 26px;
+ height: 26px;
+}
+/* The picker sits above the composer, so its menu opens upward, away from it. */
+.ai-add .menu.dropdown {
+ top: auto;
+ bottom: calc(100% + 8px);
+ left: 0;
+ right: auto;
+ min-width: 230px;
+ max-width: 300px;
+}
+.ai-add .menu-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ai-add .menu button:disabled,
+.ai-add .menu button[aria-disabled="true"] {
+ color: var(--text-3);
+ cursor: default;
+}
+.context-chip.attachment {
+ display: inline-flex;
+ align-items: center;
+ max-width: 170px;
+ padding: 5px 5px 5px 9px;
+}
+.context-chip.attachment span {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.context-chip.attachment button {
+ display: grid;
+ place-items: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+}
+.context-chip.attachment button:not(:disabled):hover {
+ background: var(--hover);
+ color: var(--text);
+}
+.context-chip.attachment button:disabled {
+ opacity: 0.4;
+}
+/* A dashed, faded chip: the document's tab has closed, so it is skipped until opened again. */
+.context-chip.attachment.missing {
+ color: var(--text-3);
+ border: 1px dashed var(--line-strong);
+}
.composer {
display: flex;
align-items: flex-end;
diff --git a/src/recent.ts b/src/recent.ts
index b49ad6b..d58b853 100644
--- a/src/recent.ts
+++ b/src/recent.ts
@@ -1,4 +1,5 @@
// Recently opened files, kept locally in IndexedDB so they can be reopened with one click.
+import type { Attachment } from "./aiAttachments";
export interface RecentFile {
id: string;
@@ -301,10 +302,15 @@ export interface StoredAiMessage {
}
export async function getAiHistory(id: string) {
- const { value, generation } = await readStored<{ messages: StoredAiMessage[] }>(AI, id);
- return { messages: Array.isArray(value?.messages) ? value.messages : [], generation };
+ const { value, generation } = await readStored<{ messages: StoredAiMessage[]; attachments?: Attachment[] }>(AI, id);
+ // Attached documents are kept by name and content id only; their text is read again from open tabs.
+ const attachments = Array.isArray(value?.attachments)
+ ? value.attachments.filter((a) => typeof a?.name === "string" && typeof a.contentId === "string") : [];
+ return { messages: Array.isArray(value?.messages) ? value.messages : [], attachments, generation };
}
-export function saveAiHistory(id: string, messages: StoredAiMessage[], generation: number) {
- return saveStored(AI, id, messages.length ? { id, messages, updatedAt: Date.now() } : undefined, generation);
+export function saveAiHistory(id: string, messages: StoredAiMessage[], generation: number, attachments: Attachment[] = []) {
+ return saveStored(AI, id, messages.length
+ ? { id, messages, ...(attachments.length ? { attachments: attachments.map(({ name, contentId }) => ({ name, contentId })) } : {}), updatedAt: Date.now() }
+ : undefined, generation);
}
diff --git a/src/testing/pdfjs.ts b/src/testing/pdfjs.ts
index 6d085ff..08a0197 100644
--- a/src/testing/pdfjs.ts
+++ b/src/testing/pdfjs.ts
@@ -33,6 +33,11 @@ async function loadViewer() {
return import("pdfjs-dist/legacy/web/pdf_viewer.mjs");
}
+// Cold imports of the real engine can take several seconds under parallel test load. Initialize the shared fixture
+// before tests start, so each test's timeout measures its PDF operations rather than the first library import.
+await loadCore();
+if (typeof document !== "undefined") await loadViewer();
+
export async function openPdf(bytes: Uint8Array, password?: string) {
const pdfjsLib = await loadCore();
return pdfjsLib.getDocument({ data: bytes.slice(), password, useSystemFonts: true }).promise;