diff --git a/.github/docs/REGRESSION_NOTES.md b/.github/docs/REGRESSION_NOTES.md index 2b75fdef3..1c617b7c9 100644 --- a/.github/docs/REGRESSION_NOTES.md +++ b/.github/docs/REGRESSION_NOTES.md @@ -1128,3 +1128,35 @@ width — overlay left/right must equal `input-area-v2` left/right (was fixed at Commit: `fix(chatwidth): widen file-drop overlay to match adjusted input width` + +## ChatGPT KaTeX may omit MathML annotations + +Symptom: + +Formula Copy showed its hover treatment on ChatGPT, but clicking a formula did +not copy anything or show a toast. The same feature continued to work on +Gemini. + +Root cause: + +ChatGPT's client-side KaTeX layout stopped rendering the hidden MathML +`annotation[encoding="application/x-tex"]` used by the original extractor. The +raw TeX moved to `data-math-source` on the semantic wrapper outside +`.katex-display`. Without the MathML node, display-mode detection also lost its +old `math[display="block"]` signal. + +Fix: + +Read `data-math-source` from the nearest semantic wrapper before falling back +to legacy annotations, and recognize `.katex-display` directly for block +delimiters. Keep the annotation path for Claude and older ChatGPT markup. + +Regression test: + +`src/features/formulaCopy/FormulaCopyService.test.ts` +(`copies current ChatGPT block KaTeX from data-math-source without MathML` and +`copies current ChatGPT inline KaTeX from data-math-source as inline LaTeX`). + +Commit: + +`fix(plugins): restore ChatGPT formula copy in Edge` diff --git a/src/features/formulaCopy/FormulaCopyService.test.ts b/src/features/formulaCopy/FormulaCopyService.test.ts index e38496768..4b861b434 100644 --- a/src/features/formulaCopy/FormulaCopyService.test.ts +++ b/src/features/formulaCopy/FormulaCopyService.test.ts @@ -475,10 +475,8 @@ describe('FormulaCopyService', () => { document.body.removeChild(displayMath); }); - // ChatGPT and Claude render math with standard KaTeX: a `.katex` span whose - // `.katex-mathml` holds , with block - // formulas wrapped in `.katex-display` and `math[display="block"]`. There is no - // `data-math` attribute and no `` wrapper (unlike Gemini / AI Studio). + // Claude and older ChatGPT markup render standard KaTeX with a MathML + // annotation. Keep this fixture to preserve compatibility with that shape. function makeKatex(latex: string, opts: { display: boolean }): HTMLElement { const katex = document.createElement('span'); katex.className = 'katex'; @@ -560,4 +558,64 @@ describe('FormulaCopyService', () => { document.body.removeChild(block); }); + + function makeCurrentChatGptKatex(latex: string, display: boolean): HTMLElement { + const semanticWrapper = document.createElement('span'); + semanticWrapper.setAttribute('role', 'math'); + semanticWrapper.setAttribute('aria-label', latex); + semanticWrapper.setAttribute('data-math-source', latex); + + const katex = document.createElement('span'); + katex.className = 'katex'; + const html = document.createElement('span'); + html.className = 'katex-html'; + html.setAttribute('aria-hidden', 'true'); + html.textContent = 'rendered'; + katex.appendChild(html); + + if (display) { + const displayWrapper = document.createElement('span'); + displayWrapper.className = 'katex-display'; + displayWrapper.appendChild(katex); + semanticWrapper.appendChild(displayWrapper); + } else { + semanticWrapper.appendChild(katex); + } + + return semanticWrapper; + } + + it('copies current ChatGPT block KaTeX from data-math-source without MathML', async () => { + const clipboard = navigator.clipboard as unknown as { write?: unknown }; + clipboard.write = undefined; + + resetSingleton(); + service = FormulaCopyService.getInstance({ format: 'latex' }); + + const block = makeCurrentChatGptKatex('C = B\\log_2\\left(1+\\frac{S}{N}\\right)', true); + document.body.appendChild(block); + + service.initialize(); + block.querySelector('.katex-html')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + + expect(writeTextMock).toHaveBeenCalledWith('$$C = B\\log_2\\left(1+\\frac{S}{N}\\right)$$'); + }); + + it('copies current ChatGPT inline KaTeX from data-math-source as inline LaTeX', async () => { + const clipboard = navigator.clipboard as unknown as { write?: unknown }; + clipboard.write = undefined; + + resetSingleton(); + service = FormulaCopyService.getInstance({ format: 'latex' }); + + const inline = makeCurrentChatGptKatex('E = mc^2', false); + document.body.appendChild(inline); + + service.initialize(); + inline.querySelector('.katex-html')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + + expect(writeTextMock).toHaveBeenCalledWith('$E = mc^2$'); + }); }); diff --git a/src/features/formulaCopy/FormulaCopyService.ts b/src/features/formulaCopy/FormulaCopyService.ts index c61fc4409..1cde60022 100644 --- a/src/features/formulaCopy/FormulaCopyService.ts +++ b/src/features/formulaCopy/FormulaCopyService.ts @@ -190,13 +190,20 @@ export class FormulaCopyService { return dataMath; } - // 2. Try AI Studio's annotation element with encoding="application/x-tex" + // 2. ChatGPT's client-side KaTeX layout omits the MathML annotation and + // keeps the original TeX on the semantic wrapper around .katex-display. + const dataMathSource = element.closest('[data-math-source]')?.getAttribute('data-math-source'); + if (dataMathSource?.trim()) { + return dataMathSource.trim(); + } + + // 3. Try AI Studio's annotation element with encoding="application/x-tex" const annotation = element.querySelector('annotation[encoding="application/x-tex"]'); if (annotation?.textContent) { return annotation.textContent.trim(); } - // 3. Fallback: try any annotation element + // 4. Fallback: try any annotation element const anyAnnotation = element.querySelector('annotation'); if (anyAnnotation?.textContent) { return anyAnnotation.textContent.trim(); @@ -399,13 +406,19 @@ export class FormulaCopyService { return true; } - // 2. AI Studio: check for math element with display="block" attribute + // 2. ChatGPT / Claude: block KaTeX uses a .katex-display wrapper. Current + // ChatGPT markup no longer includes the MathML node checked below. + if (element.closest('.katex-display') !== null) { + return true; + } + + // 3. AI Studio: check for math element with display="block" attribute const mathElement = element.querySelector('math[display="block"]'); if (mathElement) { return true; } - // 3. AI Studio: check if ms-katex container has block-like styling + // 4. AI Studio: check if ms-katex container has block-like styling // (display formulas are typically block-level in AI Studio) if (element.tagName.toLowerCase() === 'ms-katex') { const style = window.getComputedStyle(element); diff --git a/src/features/plugins/runtime/messages.ts b/src/features/plugins/runtime/messages.ts new file mode 100644 index 000000000..63e2e5965 --- /dev/null +++ b/src/features/plugins/runtime/messages.ts @@ -0,0 +1 @@ +export const PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE = 'gv.plugins.syncContentScripts'; diff --git a/src/pages/background/__tests__/runtimeMessageRouting.test.ts b/src/pages/background/__tests__/runtimeMessageRouting.test.ts index 580079f5a..caaa49435 100644 --- a/src/pages/background/__tests__/runtimeMessageRouting.test.ts +++ b/src/pages/background/__tests__/runtimeMessageRouting.test.ts @@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE } from '@/features/plugins/runtime/messages'; + import { isHandledBackgroundRuntimeMessage } from '../runtimeMessageRouting'; describe('background runtime message routing', () => { @@ -9,6 +11,9 @@ describe('background runtime message routing', () => { expect(isHandledBackgroundRuntimeMessage({ type: 'gv.account.resolve' })).toBe(true); expect(isHandledBackgroundRuntimeMessage({ type: 'gv.highlight.list' })).toBe(true); expect(isHandledBackgroundRuntimeMessage({ type: 'gv.sync.upload' })).toBe(true); + expect(isHandledBackgroundRuntimeMessage({ type: PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE })).toBe( + true, + ); expect(isHandledBackgroundRuntimeMessage({ type: 'gv.highlight.unknown' })).toBe(false); expect(isHandledBackgroundRuntimeMessage({ type: 'gv.storageQuota.ready' })).toBe(false); @@ -16,6 +21,17 @@ describe('background runtime message routing', () => { expect(isHandledBackgroundRuntimeMessage(null)).toBe(false); }); + it('routes explicit plugin registration repair through the serialized background sync', () => { + const source = readFileSync(resolve(process.cwd(), 'src/pages/background/index.ts'), 'utf8'); + const repairBranch = + source.match( + /if \(message\?\.type === PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE\) \{[\s\S]*?\n\s*\}/, + )?.[0] ?? ''; + + expect(repairBranch).toContain('await syncPluginContentScripts()'); + expect(repairBranch).toContain('sendResponse({ ok: true })'); + }); + it('uploads the complete prompt union even when duplicate names remain', () => { const source = readFileSync(resolve(process.cwd(), 'src/pages/background/index.ts'), 'utf8'); const pushBranch = diff --git a/src/pages/background/index.ts b/src/pages/background/index.ts index 9c7d8204c..07636e6ab 100644 --- a/src/pages/background/index.ts +++ b/src/pages/background/index.ts @@ -55,6 +55,7 @@ import { } from '@/features/backup/services/HighlightImportExportService'; import { PromptImportExportService } from '@/features/backup/services/PromptImportExportService'; import { computeNudgeDomains, normalizeIconResourcePath } from '@/features/plugins/promptNudge'; +import { PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE } from '@/features/plugins/runtime/messages'; import { partitionPluginOriginPatterns, pluginsToOriginPatterns, @@ -1760,6 +1761,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return; } + if (message?.type === PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE) { + await syncPluginContentScripts(); + sendResponse({ ok: true }); + return; + } + if (message?.type === 'gv.generatedUi.ensureCapturePermission') { sendResponse({ ok: await ensureGeneratedUiCapturePermission() }); return; diff --git a/src/pages/background/runtimeMessageRouting.ts b/src/pages/background/runtimeMessageRouting.ts index c2432af11..31f3fc321 100644 --- a/src/pages/background/runtimeMessageRouting.ts +++ b/src/pages/background/runtimeMessageRouting.ts @@ -1,8 +1,11 @@ +import { PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE } from '@/features/plugins/runtime/messages'; + const HANDLED_BACKGROUND_MESSAGE_TYPES = new Set([ 'gv.fetchImage', 'gv.fetchImageViaPage', 'gv.generatedUi.ensureCapturePermission', 'gv.generatedUi.captureVisibleTab', + PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE, 'gv.account.resolve', 'gv.responseComplete.notify', 'gv.responseComplete.requestNativePermission', diff --git a/src/pages/popup/components/PluginManager.tsx b/src/pages/popup/components/PluginManager.tsx index e0f161d29..6d9b669de 100644 --- a/src/pages/popup/components/PluginManager.tsx +++ b/src/pages/popup/components/PluginManager.tsx @@ -7,6 +7,7 @@ import { supportsDynamicContentScriptRegistration, supportsOptionalHostPermissions, } from '@/core/utils/browser'; +import { PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE } from '@/features/plugins/runtime/messages'; import { pluginToOriginPatternsForActiveUrl } from '@/features/plugins/runtime/siteRegistration'; import { SiteRegistry } from '@/features/plugins/sites/registry'; import { @@ -27,6 +28,21 @@ import { IconChatGPT, IconClaude } from './WebsiteLogos'; type EnabledMap = Record; type SettingsMap = Record>; +/** + * Ask the background service to reconcile dynamic plugin content scripts after + * an optional host permission grant. This is best-effort because Chrome may + * close the popup while displaying its permission prompt; the background + * permissions listener remains the fallback in that case. + */ +async function requestPluginContentScriptSync(): Promise { + try { + await browser.runtime.sendMessage({ type: PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE }); + } catch { + // Chrome may close the popup while showing the optional-host prompt. The + // background permissions.onAdded listener remains the fallback in that case. + } +} + /** Logo + default accent per known site id. */ const SITE_BADGES: Record = { claude: { Icon: IconClaude, color: '#d97757' }, @@ -167,6 +183,10 @@ export interface PluginManagerProps { readonly activeUrl?: string; } +/** + * Render the popup's plugin catalog and manage each plugin's enabled state, + * optional host access, platform-specific settings, and refresh lifecycle. + */ export function PluginManager({ manifests, loading = false, @@ -277,14 +297,21 @@ export function PluginManager({ if (!alreadyGranted) { // Chrome closes extension popups while showing an optional-host // prompt. Persist the user's intent BEFORE opening it so a - // successful grant can be completed by the background - // permissions.onAdded handler without another popup visit. + // successful grant can be completed by the background even if + // the popup is closed before permissions.request resolves. setEnabledMap((prev) => ({ ...prev, [plugin.id]: true })); await setPluginEnabled(plugin.id, true); - if (!(await browser.permissions.request({ origins }))) { + const granted = await browser.permissions.request({ origins }); + if (!granted) { setEnabledMap((prev) => ({ ...prev, [plugin.id]: false })); await setPluginEnabled(plugin.id, false); setDeniedId(plugin.id); + } else { + // Edge can resolve the request without reliably delivering the + // permissions.onAdded event that normally performs registration. + // Reconcile explicitly while retaining onAdded as Chrome's + // popup-close fallback. + await requestPluginContentScriptSync(); } return; } @@ -365,6 +392,7 @@ export function PluginManager({ setDeniedId(plugin.id); return; } + await requestPluginContentScriptSync(); setMissingPermissionIds((previous) => { const next = new Set(previous); next.delete(plugin.id); diff --git a/src/pages/popup/components/__tests__/PluginManager.test.tsx b/src/pages/popup/components/__tests__/PluginManager.test.tsx index b037fbf7a..498e6fe97 100644 --- a/src/pages/popup/components/__tests__/PluginManager.test.tsx +++ b/src/pages/popup/components/__tests__/PluginManager.test.tsx @@ -16,6 +16,7 @@ const { setPluginSetting, permissionContains, permissionRequest, + runtimeSendMessage, permissionOrigins, pluginState, PLUGIN_ID, @@ -26,6 +27,7 @@ const { setPluginSetting: vi.fn().mockResolvedValue(undefined), permissionContains: vi.fn().mockResolvedValue(false), permissionRequest: vi.fn().mockResolvedValue(true), + runtimeSendMessage: vi.fn().mockResolvedValue({ ok: true }), permissionOrigins: vi.fn().mockReturnValue([]), pluginState: { current: {} as Record }, PLUGIN_ID: 'voyager.test-width', @@ -39,6 +41,9 @@ vi.mock('webextension-polyfill', () => ({ contains: permissionContains, request: permissionRequest, }, + runtime: { + sendMessage: runtimeSendMessage, + }, }, })); @@ -158,6 +163,7 @@ beforeEach(() => { setPluginSetting.mockClear(); permissionContains.mockReset().mockResolvedValue(false); permissionRequest.mockReset().mockResolvedValue(true); + runtimeSendMessage.mockReset().mockResolvedValue({ ok: true }); permissionOrigins.mockReset().mockReturnValue([]); supportsDynamicRegistration.mockReset().mockReturnValue(true); container = document.createElement('div'); @@ -268,7 +274,7 @@ describe('PluginManager host permission flow', () => { expect(setPluginEnabled).toHaveBeenCalledWith(PLUGIN_ID, true); }); - it('persists enable intent before opening the Chrome permission prompt', async () => { + it('persists enable intent and explicitly reconciles after the host grant resolves', async () => { let resolvePermission: (granted: boolean) => void = () => {}; permissionRequest.mockReturnValue( new Promise((resolve) => { @@ -302,7 +308,13 @@ describe('PluginManager host permission flow', () => { await act(async () => { resolvePermission(true); await Promise.resolve(); + await Promise.resolve(); }); + + expect(runtimeSendMessage).toHaveBeenCalledWith({ type: 'gv.plugins.syncContentScripts' }); + expect(permissionRequest.mock.invocationCallOrder[0]).toBeLessThan( + runtimeSendMessage.mock.invocationCallOrder[0], + ); }); it('refuses the host grant when dynamic registration is unavailable', async () => { @@ -361,6 +373,7 @@ describe('PluginManager host permission flow', () => { expect(permissionRequest).toHaveBeenCalledWith({ origins: ['https://claude.ai/*', 'https://*.frame.claudeusercontent.com/*'], }); + expect(runtimeSendMessage).toHaveBeenCalledWith({ type: 'gv.plugins.syncContentScripts' }); expect(container.textContent).not.toContain('pluginGrantRequiredAccess'); expect(container.querySelector('input[type="range"]')).not.toBeNull(); });