diff --git a/package.json b/package.json
index 814a18e..49da289 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check-extension.mjs",
- "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs"
+ "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs tests/chatgpt-summary-handoff.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
diff --git a/src/content/chatgpt-summary.js b/src/content/chatgpt-summary.js
index c33f010..6e393e2 100644
--- a/src/content/chatgpt-summary.js
+++ b/src/content/chatgpt-summary.js
@@ -7,17 +7,24 @@ function requestIdFromLocation() {
}
function findComposer() {
- const composer = (
- document.querySelector("#prompt-textarea") ||
- document.querySelector('[contenteditable="true"][data-lexical-editor="true"]') ||
- document.querySelector('[contenteditable="true"][role="textbox"]') ||
- document.querySelector("textarea")
- );
- if (!composer || !composer.isConnected || !composer.getClientRects().length) return null;
- if (composer instanceof HTMLTextAreaElement) {
- return !composer.disabled && !composer.readOnly ? composer : null;
- }
- return composer.isContentEditable ? composer : null;
+ const candidates = [
+ document.querySelector("#prompt-textarea"),
+ document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'),
+ document.querySelector('[contenteditable="true"][role="textbox"]'),
+ document.querySelector("textarea"),
+ ].filter((composer) => composer && composer.isConnected && (
+ composer instanceof HTMLTextAreaElement
+ ? !composer.disabled && !composer.readOnly
+ : composer.isContentEditable
+ ));
+ // The active tab has a visible editor. When the tab/window is backgrounded,
+ // layout rects may not be available, but the connected editor is still usable.
+ return candidates.find((composer) => composer.getClientRects().length) || candidates[0] || null;
+}
+
+function composerContainsPrompt(composer, prompt) {
+ const value = composer instanceof HTMLTextAreaElement ? composer.value : composer.textContent;
+ return value === prompt || (composer.innerText || "").replace(/\r\n/g, "\n") === prompt;
}
function findSendButton() {
@@ -66,18 +73,35 @@ function waitFor(getValue, timeoutMs) {
return new Promise((resolve) => {
let observer;
let timeout;
+ let finished = false;
const finish = (value) => {
- observer?.disconnect();
+ if (finished) return;
+ finished = true;
+ observer.disconnect();
window.clearTimeout(timeout);
+ document.removeEventListener("visibilitychange", check);
+ window.removeEventListener("focus", check);
resolve(value);
};
const check = () => {
+ if (finished) return;
const value = getValue();
if (value) finish(value);
};
+ const expire = () => {
+ // A background tab can be throttled before ChatGPT hydrates. Do not
+ // discard the pending prompt just because the user switched away.
+ if (document.visibilityState === "hidden" || document.hasFocus?.() === false) {
+ timeout = window.setTimeout(expire, timeoutMs);
+ } else {
+ finish(null);
+ }
+ };
observer = new MutationObserver(check);
observer.observe(document, { childList: true, subtree: true, attributes: true });
- timeout = window.setTimeout(() => finish(null), timeoutMs);
+ document.addEventListener("visibilitychange", check);
+ window.addEventListener("focus", check);
+ timeout = window.setTimeout(expire, timeoutMs);
check();
});
}
@@ -89,6 +113,43 @@ function clearRequestHash() {
}
+function currentConversationUrl() {
+ const url = new URL(window.location.href);
+ return url.origin === "https://chatgpt.com" && /^\/c\/[a-z0-9-]+\/?$/i.test(url.pathname)
+ ? url.origin + url.pathname.replace(/\/$/, "") : null;
+}
+
+// A fresh ChatGPT conversation obtains its /c/ URL after send. Record it only
+// once it exists; subsequent summary requests reuse the attached PDF there.
+async function rememberConversation(conversationKey) {
+ const url = await new Promise((resolve) => {
+ let elapsed = 0;
+ const check = () => {
+ const found = currentConversationUrl();
+ if (found || elapsed >= 10 * 60 * 1000) {
+ clearInterval(interval);
+ resolve(found);
+ }
+ };
+ const interval = setInterval(() => {
+ elapsed += 1000;
+ check();
+ }, 1000);
+ check();
+ });
+ if (url) {
+ await chrome.storage.local.set({
+ [conversationKey]: { status: "complete", url, updatedAt: Date.now() },
+ });
+ } else {
+ // Do not leave an indefinitely pending entry after navigation fails.
+ const stored = await chrome.storage.local.get(conversationKey);
+ if (stored[conversationKey]?.status === "pending") {
+ await chrome.storage.local.remove(conversationKey);
+ }
+ }
+}
+
function findPdfInput() {
return Array.from(document.querySelectorAll('input[type="file"]')).find((input) => {
const accepts = (input.accept || "").toLowerCase();
@@ -151,19 +212,17 @@ async function attachPdf(file) {
data.items.add(file);
input.files = data.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
- // An existing file selection is not enough: wait for ChatGPT's attachment
- // chip before allowing the summary to be submitted.
- const acknowledged = await waitFor(() => {
- const displayed = Array.from(document.querySelectorAll(
- '[data-testid*="attachment"], [data-testid*="file"], [title], [aria-label]',
- )).some((element) => (
- (element.textContent || "").includes(file.name) ||
- (element.getAttribute("title") || "").includes(file.name) ||
- (element.getAttribute("aria-label") || "").includes(file.name)
- ));
- return displayed && !document.querySelector('[role="progressbar"]');
- }, 90000);
- if (!acknowledged) throw new Error("ChatGPT did not confirm the PDF attachment");
+ // Wait only until ChatGPT registers the attachment. Its upload can remain
+ // in progress: ChatGPT queues the message and sends it once the PDF is ready.
+ // Restrict this check to attachment UI, not arbitrary labels or prompt text.
+ const acknowledged = await waitFor(() => Array.from(document.querySelectorAll(
+ '[data-testid*="attachment"], [data-testid*="file"]',
+ )).some((element) => (
+ (element.textContent || "").includes(file.name) ||
+ (element.getAttribute("title") || "").includes(file.name) ||
+ (element.getAttribute("aria-label") || "").includes(file.name)
+ )), 90000);
+ if (!acknowledged) throw new Error("ChatGPT did not register the PDF attachment");
}
async function submitPendingSummary() {
@@ -173,40 +232,51 @@ async function submitPendingSummary() {
const stored = await chrome.storage.local.get(storageKey);
const payload = stored[storageKey];
if (!payload?.prompt) return;
- let composer = await waitFor(findComposer, 20000);
- if (!composer) {
- await chrome.storage.local.remove(storageKey);
- clearRequestHash();
- return;
- }
+ let composer;
let port;
try {
+ composer = await waitFor(findComposer, 90000);
+ if (!composer) throw new Error("ChatGPT composer unavailable");
+ // Compose immediately, before transferring the PDF or waiting for its
+ // upload. A backgrounded or hydrated page must not lose the prompt.
+ setComposerText(composer, payload.prompt);
if (payload.attachmentRequired) {
const pdf = await receivePdf(requestId);
port = pdf.port;
await attachPdf(pdf.file);
}
- setComposerText(composer, payload.prompt);
const sendButton = await waitFor(() => {
const current = findComposer();
if (!current) return null;
- if (current !== composer) {
+ if (current !== composer || !composerContainsPrompt(current, payload.prompt)) {
composer = current;
setComposerText(composer, payload.prompt);
return null;
}
const button = findSendButton();
- return button && !button.disabled && button.getClientRects().length ? button : null;
- }, 30000);
+ return button && !button.disabled && button.isConnected !== false &&
+ (button.getClientRects().length || document.visibilityState === "hidden")
+ ? button : null;
+ }, 90000);
if (!sendButton) throw new Error("ChatGPT send button unavailable");
sendButton.click();
+ if (payload.attachmentRequired && payload.conversationKey) {
+ void rememberConversation(payload.conversationKey).catch(() => {});
+ }
port?.postMessage({ type: "finish" });
} catch (error) {
port?.postMessage({ type: "cancel" });
+ if (payload.attachmentRequired && payload.conversationKey) {
+ const current = await chrome.storage.local.get(payload.conversationKey);
+ if (current[payload.conversationKey]?.status === "pending") {
+ await chrome.storage.local.remove(payload.conversationKey);
+ }
+ }
// Fail visibly and leave an unsent draft; never summarize just a file path.
const draft = "PDF not attached automatically (" + error.message +
"). Attach it manually before sending.\n\n" + payload.prompt;
- setComposerText(findComposer() || composer, draft);
+ const failedComposer = findComposer() || composer;
+ if (failedComposer) setComposerText(failedComposer, draft);
} finally {
port?.disconnect();
await chrome.storage.local.remove(storageKey);
diff --git a/src/viewer.html b/src/viewer.html
index eeb4de1..ef156d0 100644
--- a/src/viewer.html
+++ b/src/viewer.html
@@ -103,7 +103,7 @@
-
diff --git a/src/viewer/sharing/summarize-with-chatgpt.js b/src/viewer/sharing/summarize-with-chatgpt.js
index 51bd00e..757a8da 100644
--- a/src/viewer/sharing/summarize-with-chatgpt.js
+++ b/src/viewer/sharing/summarize-with-chatgpt.js
@@ -3,6 +3,8 @@ import { pdfDocumentSessionReady } from "../pdf-document-session.js";
const summarizeButton = document.querySelector("#summarize-chatgpt");
const STORAGE_PREFIX = "pdf-viewer-chatgpt-summary:";
+const CONVERSATION_PREFIX = "pdf-viewer-chatgpt-conversation:";
+const PENDING_MAX_AGE_MS = 10 * 60 * 1000;
const CHATGPT_URL = "https://chatgpt.com/";
const defaultTitle = summarizeButton.title;
// Resolve PDF metadata while the viewer loads, before the user asks for a summary.
@@ -69,7 +71,7 @@ function registerPdfTransfer(requestId, filename) {
const port = chrome.runtime.connect({ name: "pdf-summary-source:" + requestId });
let stopped = false;
let awaitingAck;
- const expire = setTimeout(() => port.disconnect(), 120000);
+ const expire = setTimeout(() => port.disconnect(), PENDING_MAX_AGE_MS);
port.onDisconnect.addListener(() => {
stopped = true;
if (awaitingAck) awaitingAck.reject(new Error("PDF transfer disconnected"));
@@ -118,11 +120,46 @@ function registerPdfTransfer(requestId, filename) {
return port;
}
-async function openChatGPTSummary() {
+function savedConversationUrl(value) {
+ try {
+ const url = new URL(value);
+ return url.origin === "https://chatgpt.com" && /^\/c\/[a-z0-9-]+\/?$/i.test(url.pathname)
+ ? url.origin + url.pathname.replace(/\/$/, "") : null;
+ } catch {
+ return null;
+ }
+}
+
+async function pdfContentHash() {
+ const session = await pdfDocumentSessionReady;
+ if (!session?.document) throw new Error("PDF has not finished loading");
+ const bytes = await session.document.getData();
+ if (!bytes?.length || bytes.length > 512 * 1024 * 1024) {
+ throw new Error("PDF empty or over 512 MB attachment limit");
+ }
+ // Hash bytes, not the filename or URL: changed PDFs must not reuse an old attachment.
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
+}
+
+async function focusPendingSummary(entry) {
+ if (entry?.status !== "pending" || Date.now() - entry.createdAt > PENDING_MAX_AGE_MS) {
+ return false;
+ }
+ try {
+ const tab = await chrome.tabs.get(entry.tabId);
+ if (!tab) return false;
+ await chrome.windows.update(tab.windowId, { focused: true });
+ await chrome.tabs.update(tab.id, { active: true });
+ return true;
+ } catch {
+ return false; // The previous popup was closed.
+ }
+}
+
+async function openChatGPTSummary({ freshChat = false } = {}) {
const requestId = crypto.randomUUID();
const storageKey = `${STORAGE_PREFIX}${requestId}`;
- const chatgptUrl = new URL(CHATGPT_URL);
- chatgptUrl.hash = `pdf-viewer-summary=${encodeURIComponent(requestId)}`;
const screenWidth = window.screen.availWidth || window.screen.width || 1440;
const screenHeight = window.screen.availHeight || window.screen.height || 900;
@@ -148,6 +185,8 @@ async function openChatGPTSummary() {
}
let sourcePort;
+ let conversationKey;
+ let reserved = false;
try {
const fileUrl = await documentReferenceUrl;
if (!fileUrl) {
@@ -156,29 +195,54 @@ async function openChatGPTSummary() {
return;
}
const filename = pdfFilename(fileUrl);
- await chrome.storage.local.set({
- [storageKey]: {
- prompt: summaryPrompt(fileUrl, filename),
- createdAt: Date.now(),
- attachmentRequired: true,
- },
- });
- sourcePort = registerPdfTransfer(requestId, filename);
+ let existingConversation = null;
+ if (!freshChat) {
+ conversationKey = CONVERSATION_PREFIX + await pdfContentHash();
+ const stored = await chrome.storage.local.get(conversationKey);
+ existingConversation = stored[conversationKey];
+ if (await focusPendingSummary(existingConversation)) {
+ await chrome.tabs.remove(summaryTab.id);
+ return; // Focus the active upload rather than initiating another one.
+ }
+ }
+ const previousUrl = savedConversationUrl(existingConversation?.url);
+ const reuse = !freshChat && existingConversation?.status === "complete" && previousUrl;
+ const chatgptUrl = new URL(reuse || CHATGPT_URL);
+ chatgptUrl.hash = `pdf-viewer-summary=${encodeURIComponent(requestId)}`;
+ const payload = {
+ prompt: summaryPrompt(fileUrl, filename),
+ createdAt: Date.now(),
+ attachmentRequired: !reuse,
+ conversationKey: conversationKey || null,
+ };
+ const changes = { [storageKey]: payload };
+ if (conversationKey && !reuse) {
+ changes[conversationKey] = { status: "pending", tabId: summaryTab.id, createdAt: Date.now() };
+ reserved = true;
+ }
+ await chrome.storage.local.set(changes);
+ if (!reuse) sourcePort = registerPdfTransfer(requestId, filename);
await chrome.tabs.update(summaryTab.id, { url: chatgptUrl.href });
} catch (error) {
sourcePort?.disconnect();
await chrome.storage.local.remove(storageKey);
+ if (reserved) {
+ const stored = await chrome.storage.local.get(conversationKey);
+ if (stored[conversationKey]?.tabId === summaryTab.id) {
+ await chrome.storage.local.remove(conversationKey);
+ }
+ }
await chrome.tabs.remove(summaryTab.id).catch(() => {});
throw error;
}
}
-summarizeButton.addEventListener("click", async () => {
+summarizeButton.addEventListener("click", async (event) => {
dismissSummaryTooltip();
summarizeButton.disabled = true;
try {
- await openChatGPTSummary();
+ await openChatGPTSummary({ freshChat: Boolean(event?.shiftKey) });
} catch {
showTemporaryTitle("Could not open ChatGPT");
} finally {
diff --git a/tests/chatgpt-summary-handoff.test.mjs b/tests/chatgpt-summary-handoff.test.mjs
new file mode 100644
index 0000000..18805ff
--- /dev/null
+++ b/tests/chatgpt-summary-handoff.test.mjs
@@ -0,0 +1,188 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import vm from "node:vm";
+
+const code = await readFile(new URL("../src/content/chatgpt-summary.js", import.meta.url), "utf8");
+const prompt = "Summarize the attached PDF.\n\nSource URL: https://example.com/sample.pdf";
+const requestId = "12345678-1234-1234-1234-123456789abc";
+const storageKey = "pdf-viewer-chatgpt-summary:" + requestId;
+
+function setup({ background = false, hydrateDuringUpload = false, attachmentRequired = true } = {}) {
+ const observers = new Set();
+ const listeners = new Map();
+ const calls = [];
+ const uploads = [];
+ const state = { progress: true, chip: false, chipFilename: "", submitted: 0, sentPrompt: "" };
+ const notify = () => {
+ for (const observer of [...observers]) observer.callback();
+ };
+ class MockObserver {
+ constructor(callback) { this.callback = callback; }
+ observe() { observers.add(this); }
+ disconnect() { observers.delete(this); }
+ }
+ class MockTextarea {
+ constructor() { this.value = ""; this.isConnected = true; this.disabled = false; this.readOnly = false; }
+ focus() {}
+ getClientRects() { return background ? [] : [{}]; }
+ dispatchEvent() { notify(); }
+ }
+ let composer = new MockTextarea();
+ class MockTransfer {
+ constructor() {
+ const files = [];
+ this.items = { add(file) { files.push(file); } };
+ this.files = files;
+ }
+ }
+ class MockFile {
+ constructor(chunks, name) { this.chunks = chunks; this.name = name; }
+ }
+ const input = {
+ accept: ".pdf", disabled: false, files: [],
+ dispatchEvent(event) {
+ assert.equal(event.type, "change");
+ uploads.push(this.files[0]);
+ state.chip = true;
+ state.chipFilename = this.files[0].name;
+ if (hydrateDuringUpload) {
+ composer.isConnected = false;
+ composer = new MockTextarea();
+ }
+ notify();
+ },
+ };
+ const button = {
+ disabled: false, isConnected: true,
+ getAttribute(name) { return name === "aria-label" ? "Send message" : null; },
+ getClientRects() { return background ? [] : [{}]; },
+ click() {
+ state.submitted++;
+ state.sentPrompt = composer.value;
+ location.href = "https://chatgpt.com/c/" + requestId;
+ location.pathname = "/c/" + requestId;
+ location.hash = "";
+ notify();
+ },
+ };
+ const location = {
+ href: "https://chatgpt.com/#pdf-viewer-summary=" + requestId,
+ pathname: "/", search: "", hash: "#pdf-viewer-summary=" + requestId,
+ };
+ const window = {
+ location, history: { state: null, replaceState() { location.hash = ""; } },
+ getSelection() { return null; },
+ addEventListener(name, fn) { listeners.set("window-" + name, fn); },
+ removeEventListener(name) { listeners.delete("window-" + name); },
+ setTimeout, clearTimeout,
+ };
+ const document = {
+ visibilityState: background ? "hidden" : "visible",
+ hasFocus: () => !background,
+ querySelector(selector) {
+ if (selector === "#prompt-textarea") return composer;
+ return null;
+ },
+ querySelectorAll(selector) {
+ if (selector === 'input[type="file"]') return [input];
+ if (selector === "button") return [button];
+ if (selector.includes("data-testid")) {
+ return state.chip ? [{
+ textContent: state.chipFilename,
+ getAttribute() { return null; },
+ }] : [];
+ }
+ return [];
+ },
+ addEventListener(name, fn) { listeners.set("document-" + name, fn); },
+ removeEventListener(name) { listeners.delete("document-" + name); },
+ };
+ const chrome = {
+ storage: { local: {
+ async get(key) { return { [key]: { prompt, attachmentRequired, conversationKey: null } }; },
+ async remove(key) { calls.push(["remove", key]); },
+ async set(value) { calls.push(["store", value]); },
+ } },
+ runtime: {
+ connect(options) {
+ const handlers = new Map();
+ const port = {
+ name: options.name,
+ onMessage: { addListener(fn) { handlers.set("message", fn); } },
+ onDisconnect: { addListener(fn) { handlers.set("disconnect", fn); } },
+ postMessage(message) { calls.push(["port", message]); },
+ disconnect() { calls.push(["disconnect"]); },
+ deliver(message) { handlers.get("message")?.(message); },
+ };
+ calls.push(["connect", port.name]);
+ ports.push(port);
+ return port;
+ },
+ },
+ };
+ const ports = [];
+ class MockEvent { constructor(type) { this.type = type; } }
+ vm.runInNewContext(code, {
+ window, document, chrome, URL, URLSearchParams, Event: MockEvent,
+ InputEvent: MockEvent, HTMLTextAreaElement: MockTextarea,
+ DataTransfer: MockTransfer, File: MockFile, MutationObserver: MockObserver,
+ Uint8Array, atob, setInterval, clearInterval,
+ });
+ const flush = async () => {
+ for (let i = 0; i < 8; i++) await new Promise((resolve) => setImmediate(resolve));
+ };
+ return {
+ state, calls, uploads, ports, flush, getPrompt: () => composer.value, listeners,
+ sendPdf() {
+ const port = ports[0];
+ assert.ok(port, "target port connected");
+ port.deliver({ type: "begin", filename: "sample.pdf", byteLength: 4 });
+ port.deliver({ type: "chunk", data: "JVBERg==" });
+ port.deliver({ type: "end" });
+ },
+ };
+}
+
+test("inserts prompt before PDF upload, then clicks send while progress is still active", async () => {
+ const app = setup();
+ await app.flush();
+ assert.equal(app.getPrompt(), prompt);
+ assert.equal(app.uploads.length, 0);
+ assert.equal(app.state.submitted, 0);
+ app.sendPdf();
+ await app.flush();
+ assert.equal(app.uploads.length, 1);
+ assert.equal(app.state.progress, true);
+ assert.equal(app.state.submitted, 1);
+ assert.equal(app.state.sentPrompt, prompt);
+ assert.ok(app.calls.some(([action, msg]) => action === "port" && msg.type === "finish"));
+});
+
+test("background ChatGPT tab can attach and send without layout rects or focus", async () => {
+ const app = setup({ background: true });
+ await app.flush();
+ assert.equal(app.getPrompt(), prompt);
+ app.sendPdf();
+ await app.flush();
+ assert.equal(app.state.submitted, 1);
+ assert.equal(app.state.sentPrompt, prompt);
+});
+
+test("recovers the summary prompt after ChatGPT replaces the editor during upload", async () => {
+ const app = setup({ hydrateDuringUpload: true });
+ await app.flush();
+ assert.equal(app.getPrompt(), prompt);
+ app.sendPdf();
+ await app.flush();
+ assert.equal(app.state.submitted, 1);
+ assert.equal(app.state.sentPrompt, prompt);
+});
+
+test("reused summary skips the PDF relay and submits the prompt", async () => {
+ const app = setup({ attachmentRequired: false });
+ await app.flush();
+ assert.equal(app.ports.length, 0);
+ assert.equal(app.state.submitted, 1);
+ assert.equal(app.state.sentPrompt, prompt);
+});
diff --git a/tests/chatgpt-summary-popup.test.mjs b/tests/chatgpt-summary-popup.test.mjs
index 49731e1..90dab5a 100644
--- a/tests/chatgpt-summary-popup.test.mjs
+++ b/tests/chatgpt-summary-popup.test.mjs
@@ -10,6 +10,7 @@ function setup({ width = 1080, height = 1920, popupFails = false } = {}) {
let resolveUrl;
const listeners = new Map();
const calls = [];
+ const saved = {};
const button = {
title: "Summarize with ChatGPT",
attributes: {},
@@ -19,7 +20,11 @@ function setup({ width = 1080, height = 1920, popupFails = false } = {}) {
addEventListener(event, listener) { listeners.set(event, listener); },
};
const context = {
- URL, crypto: { randomUUID: () => "request" },
+ URL, Uint8Array, crypto: {
+ randomUUID: () => "request",
+ subtle: { async digest() { return new Uint8Array([18, 52, 86]).buffer; } },
+ },
+ pdfDocumentSessionReady: Promise.resolve({ document: { async getData() { return new Uint8Array([37, 80, 68, 70]); } } }),
setTimeout: () => 0, clearTimeout() {},
document: { querySelector: () => button },
window: { screen: { availWidth: width, availHeight: height } },
@@ -29,24 +34,26 @@ function setup({ width = 1080, height = 1920, popupFails = false } = {}) {
calls.push(["connect", options]);
return { onMessage: { addListener() {} }, onDisconnect: { addListener() {} }, disconnect() {} };
} },
- windows: { async create(options) {
+ windows: { async update(id, options) { calls.push(["focus", id, options]); }, async create(options) {
calls.push(["popup", options]);
if (popupFails) throw new Error("Popup unavailable");
return { tabs: [{ id: 7 }] };
} },
tabs: {
+ async get(id) { return { id, windowId: 9 }; },
async create(options) { calls.push(["tab", options]); return { id: 7 }; },
async update(id, options) { calls.push(["navigate", options]); },
async remove() { calls.push(["close"]); },
},
storage: { local: {
- async set(payload) { calls.push(["store", payload]); },
- async remove() { calls.push(["remove"]); },
+ async get(key) { return { [key]: saved[key] }; },
+ async set(payload) { Object.assign(saved, payload); calls.push(["store", payload]); },
+ async remove(key) { delete saved[key]; calls.push(["remove"]); },
} },
},
};
vm.runInNewContext(source, context);
- return { click: () => listeners.get("click")(), dispatch: (event) => listeners.get(event)(), resolveUrl, calls, button };
+ return { click: (event) => listeners.get("click")(event), dispatch: (event) => listeners.get(event)(), resolveUrl, calls, saved, button };
}
test("opens the portrait popup before PDF metadata resolves, then stores the prompt before navigation", async () => {
@@ -120,3 +127,56 @@ test("local file URLs are not substituted for PDF attachments", async () => {
assert.ok(!data.prompt.includes("file:///Users/"));
assert.equal(fixture.calls.find(([action]) => action === "connect")[1].name, "pdf-summary-source:request");
});
+
+test("reuses a previously uploaded PDF by reopening its existing conversation", async () => {
+ const fixture = setup();
+ fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = {
+ status: "complete",
+ url: "https://chatgpt.com/c/12345678-1234-1234-1234-123456789abc",
+ };
+ const click = fixture.click();
+ fixture.resolveUrl("https://example.com/original.pdf");
+ await click;
+ const data = fixture.calls.find(([action]) => action === "store")[1]["pdf-viewer-chatgpt-summary:request"];
+ assert.equal(data.attachmentRequired, false);
+ assert.equal(fixture.calls.some(([action]) => action === "connect"), false);
+ assert.match(fixture.calls.find(([action]) => action === "navigate")[1].url,
+ /^https:\/\/chatgpt.com\/c\/12345678-1234-1234-1234-123456789abc#/);
+});
+
+test("refocuses an active upload rather than reattaching the PDF", async () => {
+ const fixture = setup();
+ fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = {
+ status: "pending", tabId: 19, createdAt: Date.now(),
+ };
+ const click = fixture.click();
+ fixture.resolveUrl("https://example.com/original.pdf");
+ await click;
+ assert.deepEqual(fixture.calls.map(([action]) => action), ["popup", "focus", "navigate", "close"]);
+});
+
+test("Shift-click deliberately starts a fresh chat even when the PDF has a saved summary", async () => {
+ const fixture = setup();
+ fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = {
+ status: "complete", url: "https://chatgpt.com/c/12345678-1234-1234-1234-123456789abc",
+ };
+ const click = fixture.click({ shiftKey: true });
+ fixture.resolveUrl("https://example.com/original.pdf");
+ await click;
+ assert.deepEqual(fixture.calls.map(([action]) => action), ["popup", "store", "connect", "navigate"]);
+ const data = fixture.calls[1][1]["pdf-viewer-chatgpt-summary:request"];
+ assert.equal(data.attachmentRequired, true);
+ assert.equal(data.conversationKey, null);
+ assert.match(fixture.calls[3][1].url, /^https:\/\/chatgpt.com\/#/);
+});
+
+test("keeps an in-progress PDF transfer reachable after two minutes in a background tab", async () => {
+ const fixture = setup();
+ fixture.saved["pdf-viewer-chatgpt-conversation:123456"] = {
+ status: "pending", tabId: 19, createdAt: Date.now() - 3 * 60 * 1000,
+ };
+ const click = fixture.click();
+ fixture.resolveUrl("https://example.com/original.pdf");
+ await click;
+ assert.deepEqual(fixture.calls.map(([action]) => action), ["popup", "focus", "navigate", "close"]);
+});