Skip to content
Merged
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 @@ -1196,3 +1196,35 @@ Regression test:

Commit:
`fix(timeline): keep compact preview open across hover gap`

## 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report plugin sync registration failures

When chrome.scripting.registerContentScripts rejects during doSyncPluginContentScripts, that function catches/logs and resolves, so this new branch still sends { ok: true }. In the Edge permission-repair flow the popup removes the pluginGrantRequiredAccess retry as soon as it sees ok, leaving a granted-but-unregistered plugin (Formula Copy still inert) with no retry path; have the sync return a real success/failure before acknowledging.

AGENTS.md reference: AGENTS.md:L84-L84

Useful? React with 👍 / 👎.

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
39 changes: 36 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,26 @@ 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. Returns whether the
* background confirmed that reconciliation completed.
*/
async function requestPluginContentScriptSync(): Promise<boolean> {
try {
const response = (await browser.runtime.sendMessage({
type: PLUGIN_CONTENT_SCRIPT_SYNC_MESSAGE,
})) as { ok?: unknown } | null;
return response?.ok === true;
} catch {
// Chrome may close the popup while showing the optional-host prompt. The
// background permissions.onAdded listener remains the fallback in that case.
return false;
}
}

/** 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 +188,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 +302,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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle failed explicit sync after enabling

In the new-host enable path, requestPluginContentScriptSync() is explicitly the Edge fallback, but this result is ignored. If sendMessage fails or the background returns { ok: false } after the user grants the host, the plugin remains enabled from the pre-prompt write, and because the permission is now present the missing-access repair button will not appear; surface or roll back the failed sync instead of returning.

AGENTS.md reference: AGENTS.md:L84-L84

Useful? React with 👍 / 👎.

}
return;
}
Expand Down Expand Up @@ -365,6 +397,7 @@ export function PluginManager({
setDeniedId(plugin.id);
return;
}
if (!(await requestPluginContentScriptSync())) return;
setMissingPermissionIds((previous) => {
const next = new Set(previous);
next.delete(plugin.id);
Expand Down
Loading
Loading