Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/docs/REGRESSION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
66 changes: 62 additions & 4 deletions src/features/formulaCopy/FormulaCopyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <annotation encoding="application/x-tex">, with block
// formulas wrapped in `.katex-display` and `math[display="block"]`. There is no
// `data-math` attribute and no `<ms-katex>` 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';
Expand Down Expand Up @@ -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$');
});
});
21 changes: 17 additions & 4 deletions src/features/formulaCopy/FormulaCopyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/features/plugins/runtime/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE = 'gv.plugins.syncContentScripts';
16 changes: 16 additions & 0 deletions src/pages/background/__tests__/runtimeMessageRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,36 @@ 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', () => {
it('keeps the async channel open only for exact handled message types', () => {
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);
expect(isHandledBackgroundRuntimeMessage({ type: 'gv.unhandled' })).toBe(false);
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 =
Expand Down
7 changes: 7 additions & 0 deletions src/pages/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/pages/background/runtimeMessageRouting.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
34 changes: 31 additions & 3 deletions src/pages/popup/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +28,21 @@ import { IconChatGPT, IconClaude } from './WebsiteLogos';
type EnabledMap = Record<string, boolean>;
type SettingsMap = Record<string, Record<string, PluginSettingValue>>;

/**
* 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<void> {
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<string, { Icon: typeof IconClaude; color: string }> = {
claude: { Icon: IconClaude, color: '#d97757' },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -365,6 +392,7 @@ export function PluginManager({
setDeniedId(plugin.id);
return;
}
await requestPluginContentScriptSync();
setMissingPermissionIds((previous) => {
const next = new Set(previous);
next.delete(plugin.id);
Expand Down
15 changes: 14 additions & 1 deletion src/pages/popup/components/__tests__/PluginManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
setPluginSetting,
permissionContains,
permissionRequest,
runtimeSendMessage,
permissionOrigins,
pluginState,
PLUGIN_ID,
Expand All @@ -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<string, { enabled: boolean; installedAt: number }> },
PLUGIN_ID: 'voyager.test-width',
Expand All @@ -39,6 +41,9 @@ vi.mock('webextension-polyfill', () => ({
contains: permissionContains,
request: permissionRequest,
},
runtime: {
sendMessage: runtimeSendMessage,
},
},
}));

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<boolean>((resolve) => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});
Expand Down
Loading