From 090895cf13261f9cd98e841d115d52db44fa6d28 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:31:30 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9E=20Reuse=20PDF=20summary=20conv?= =?UTF-8?q?ersations=20to=20prevent=20duplicate=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hash PDF contents before launching a summary, reopen an existing ChatGPT conversation for the same document, and focus in-progress handoffs instead of uploading again. Preserve Shift-click to start a separate new chat and add regression coverage for reuse, pending uploads, and fresh-chat overrides. Co-authored-by: Codex Web --- src/content/chatgpt-summary.js | 46 ++++++++++ src/viewer.html | 2 +- src/viewer/sharing/summarize-with-chatgpt.js | 90 +++++++++++++++++--- tests/chatgpt-summary-popup.test.mjs | 59 +++++++++++-- 4 files changed, 178 insertions(+), 19 deletions(-) diff --git a/src/content/chatgpt-summary.js b/src/content/chatgpt-summary.js index c33f010..e9c31c2 100644 --- a/src/content/chatgpt-summary.js +++ b/src/content/chatgpt-summary.js @@ -89,6 +89,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 >= 60000) { + clearInterval(interval); + resolve(found); + } + }; + const interval = setInterval(() => { + elapsed += 500; + check(); + }, 500); + 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(); @@ -200,9 +237,18 @@ async function submitPendingSummary() { }, 30000); 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; 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..d09cc20 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 = 120000; const CHATGPT_URL = "https://chatgpt.com/"; const defaultTitle = summarizeButton.title; // Resolve PDF metadata while the viewer loads, before the user asks for a summary. @@ -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-popup.test.mjs b/tests/chatgpt-summary-popup.test.mjs index 49731e1..14c3657 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,45 @@ 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\/#/); +}); From 6fc8259b6a9f054c2e7516f17d9a0fae9f58ea57 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:42:43 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9D=20Submit=20PDF=20summary=20pro?= =?UTF-8?q?mpts=20before=20uploads=20finish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populate the ChatGPT composer before transferring PDF bytes, permit sending as soon as the file is registered while its upload remains active, and recover composer contents after hydration. Keep background tabs eligible for submission and pause readiness timeouts while the page is inactive. Add end-to-end content-script regression coverage for background submission, unfinished uploads, composer replacement, and conversation reuse. Co-authored-by: Codex Web --- package.json | 2 +- src/content/chatgpt-summary.js | 98 ++++++++----- tests/chatgpt-summary-handoff.test.mjs | 188 +++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 38 deletions(-) create mode 100644 tests/chatgpt-summary-handoff.test.mjs 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 e9c31c2..dc5f892 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(); }); } @@ -188,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() { @@ -210,31 +232,32 @@ 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) { @@ -252,7 +275,8 @@ async function submitPendingSummary() { // 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/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); +}); From f0f41f7136c2149a1d1d0418a8ba309cffd904b6 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:45:36 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9D=20Preserve=20PDF=20summary=20h?= =?UTF-8?q?andoffs=20while=20ChatGPT=20is=20backgrounded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the in-progress PDF source relay and duplicate-upload guard to ten minutes so a slowly loading background ChatGPT tab does not lose its transfer. Keep conversation URL tracking alive for that window and cover refocusing an older pending upload in regression tests. Co-authored-by: Codex Web --- src/content/chatgpt-summary.js | 6 +++--- src/viewer/sharing/summarize-with-chatgpt.js | 4 ++-- tests/chatgpt-summary-popup.test.mjs | 11 +++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/content/chatgpt-summary.js b/src/content/chatgpt-summary.js index dc5f892..6e393e2 100644 --- a/src/content/chatgpt-summary.js +++ b/src/content/chatgpt-summary.js @@ -126,15 +126,15 @@ async function rememberConversation(conversationKey) { let elapsed = 0; const check = () => { const found = currentConversationUrl(); - if (found || elapsed >= 60000) { + if (found || elapsed >= 10 * 60 * 1000) { clearInterval(interval); resolve(found); } }; const interval = setInterval(() => { - elapsed += 500; + elapsed += 1000; check(); - }, 500); + }, 1000); check(); }); if (url) { diff --git a/src/viewer/sharing/summarize-with-chatgpt.js b/src/viewer/sharing/summarize-with-chatgpt.js index d09cc20..757a8da 100644 --- a/src/viewer/sharing/summarize-with-chatgpt.js +++ b/src/viewer/sharing/summarize-with-chatgpt.js @@ -4,7 +4,7 @@ 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 = 120000; +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. @@ -71,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")); diff --git a/tests/chatgpt-summary-popup.test.mjs b/tests/chatgpt-summary-popup.test.mjs index 14c3657..90dab5a 100644 --- a/tests/chatgpt-summary-popup.test.mjs +++ b/tests/chatgpt-summary-popup.test.mjs @@ -169,3 +169,14 @@ test("Shift-click deliberately starts a fresh chat even when the PDF has a saved 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"]); +});