-
${getLocalizedText('embeddedModelLimitNotice', 'Claude may show limited model options when embedded. Open Claude in a normal tab to use the regular model selector.')}
+
+ ${getClaudeModelNoticeText()}
+
+
`;
}
+function getClaudeModelOverrideElements(panelEl) {
+ return {
+ notice: panelEl?.querySelector('.embedded-model-limit-notice-text') || null,
+ status: panelEl?.querySelector('.claude-model-override-status') || null
+ };
+}
+
+function setClaudeModelOverrideStatus(panelEl, text, state = '') {
+ const { status } = getClaudeModelOverrideElements(panelEl);
+ if (!status) {
+ return;
+ }
+
+ status.textContent = text;
+ status.dataset.state = state;
+}
+
+function postClaudeModelOverride(panel, mode = currentClaudeModelMode) {
+ if (!panel?.iframe?.contentWindow || panel.providerId !== 'claude') {
+ return;
+ }
+
+ panel.iframe.contentWindow.postMessage(
+ createClaudeModelOverrideMessage(mode),
+ 'https://claude.ai'
+ );
+}
+
+function resetClaudeModelModeAfterFailure(panelEl, message) {
+ updateClaudeModelMode(CLAUDE_MODEL_MODE_DEFAULT, {
+ persist: true,
+ broadcast: true
+ }).then(() => {
+ setClaudeModelOverrideStatus(panelEl, message, 'error');
+ }).catch(error => {
+ console.error('Failed to reset Claude model mode:', error);
+ setClaudeModelOverrideStatus(panelEl, message, 'error');
+ });
+}
+
+function handleClaudeModelProbeMessage(event, data) {
+ if (data.type !== PANELIZE_CLAUDE_MODEL_PROBE || event.origin !== 'https://claude.ai') {
+ return false;
+ }
+
+ const panel = panels.find(candidate => (
+ candidate.providerId === 'claude' &&
+ candidate.iframe?.contentWindow === event.source
+ ));
+ const panelEl = panel ? document.getElementById(panel.id) : null;
+ if (!panel || !panelEl) {
+ return true;
+ }
+
+ switch (data.event) {
+ case 'probe-ready':
+ postClaudeModelOverride(panel);
+ break;
+ case 'override-configured':
+ setClaudeModelOverrideStatus(panelEl, '');
+ break;
+ case 'request-overridden': {
+ const option = getClaudeModelOption(data.mode);
+ setClaudeModelOverrideStatus(
+ panelEl,
+ getLocalizedText('claudeModelRequestedStatus', 'Requested $1', option.label),
+ 'active'
+ );
+ break;
+ }
+ case 'override-request-accepted': {
+ const option = getClaudeModelOption(data.mode);
+ setClaudeModelOverrideStatus(
+ panelEl,
+ getLocalizedText('claudeModelRequestedStatus', 'Requested $1', option.label),
+ 'success'
+ );
+ break;
+ }
+ case 'override-config-rejected':
+ case 'override-schema-unsupported':
+ resetClaudeModelModeAfterFailure(
+ panelEl,
+ getLocalizedText(
+ 'claudeModelOverrideUnavailable',
+ 'Model override is unavailable. Switched to Claude default.'
+ )
+ );
+ break;
+ case 'override-request-rejected':
+ resetClaudeModelModeAfterFailure(
+ panelEl,
+ getLocalizedText(
+ 'claudeModelRequestRejected',
+ 'Model request was rejected (HTTP $1). Switched to Claude default.',
+ String(data.status || '')
+ )
+ );
+ break;
+ default:
+ break;
+ }
+
+ return true;
+}
+
// Keep the embedded model limit notice in sync when a panel's provider changes.
function syncEmbeddedModelLimitNotice(panelEl, providerId) {
const container = panelEl.querySelector('.panel-iframe-container');
@@ -378,6 +545,11 @@ function syncEmbeddedModelLimitNotice(panelEl, providerId) {
if (!existing) {
const iframe = container.querySelector('iframe');
iframe.insertAdjacentHTML('beforebegin', getEmbeddedModelLimitNoticeHtml());
+ } else {
+ const { notice } = getClaudeModelOverrideElements(panelEl);
+ if (notice) {
+ notice.textContent = getClaudeModelNoticeText();
+ }
}
} else if (existing) {
existing.remove();
@@ -393,6 +565,22 @@ function syncGoogleModeControls() {
});
}
+function syncClaudeModelControls() {
+ document.querySelectorAll('.panel-claude-model-select').forEach(select => {
+ if (select.value !== currentClaudeModelMode) {
+ select.value = currentClaudeModelMode;
+ }
+ fitPanelSelectWidth(select);
+ });
+
+ document.querySelectorAll('.panel-item').forEach(panelEl => {
+ const { notice } = getClaudeModelOverrideElements(panelEl);
+ if (notice) {
+ notice.textContent = getClaudeModelNoticeText();
+ }
+ });
+}
+
function showPanelLoadingState(panelEl, provider) {
const loadingEl = panelEl.querySelector('.panel-loading');
if (!loadingEl || !provider) {
@@ -491,6 +679,24 @@ function bindPanelHeaderActions(panelId) {
await updateGoogleProviderMode(event.target.value, { persist: true, reloadPanels: true });
});
}
+
+ const claudeModelSelect = panelEl.querySelector('.panel-claude-model-select');
+ if (claudeModelSelect) {
+ fitPanelSelectWidth(claudeModelSelect);
+ claudeModelSelect.addEventListener('click', event => {
+ event.stopPropagation();
+ });
+ claudeModelSelect.addEventListener('mousedown', event => {
+ event.stopPropagation();
+ });
+ claudeModelSelect.addEventListener('change', async event => {
+ fitPanelSelectWidth(event.target);
+ await updateClaudeModelMode(event.target.value, {
+ persist: true,
+ broadcast: true
+ });
+ });
+ }
}
async function updateGoogleProviderMode(mode, { persist = false, reloadPanels = false } = {}) {
@@ -510,6 +716,21 @@ async function updateGoogleProviderMode(mode, { persist = false, reloadPanels =
}
}
+async function updateClaudeModelMode(mode, { persist = false, broadcast = false } = {}) {
+ currentClaudeModelMode = normalizeClaudeModelMode(mode);
+ syncClaudeModelControls();
+
+ if (broadcast) {
+ panels
+ .filter(panel => panel.providerId === 'claude')
+ .forEach(panel => postClaudeModelOverride(panel));
+ }
+
+ if (persist) {
+ await saveSetting('claudeModelMode', currentClaudeModelMode);
+ }
+}
+
function registerStorageChangeListener() {
if (!chrome?.storage?.onChanged?.addListener) {
return;
@@ -520,19 +741,27 @@ function registerStorageChangeListener() {
return;
}
- if (!changes.googleProviderMode || !changes.googleProviderMode.newValue) {
- return;
+ if (changes.googleProviderMode?.newValue) {
+ const nextMode = normalizeGoogleProviderMode(changes.googleProviderMode.newValue);
+ if (nextMode === currentGoogleProviderMode) {
+ syncGoogleModeControls();
+ } else {
+ updateGoogleProviderMode(nextMode, { reloadPanels: true }).catch((error) => {
+ console.error('Error syncing Google provider mode:', error);
+ });
+ }
}
- const nextMode = normalizeGoogleProviderMode(changes.googleProviderMode.newValue);
- if (nextMode === currentGoogleProviderMode) {
- syncGoogleModeControls();
- return;
+ if (changes.claudeModelMode) {
+ const nextMode = normalizeClaudeModelMode(changes.claudeModelMode.newValue);
+ if (nextMode === currentClaudeModelMode) {
+ syncClaudeModelControls();
+ } else {
+ updateClaudeModelMode(nextMode, { broadcast: true }).catch(error => {
+ console.error('Error syncing Claude model mode:', error);
+ });
+ }
}
-
- updateGoogleProviderMode(nextMode, { reloadPanels: true }).catch((error) => {
- console.error('Error syncing Google provider mode:', error);
- });
});
}
@@ -825,6 +1054,10 @@ function handleProviderStatusMessage(event) {
return;
}
+ if (handleClaudeModelProbeMessage(event, data)) {
+ return;
+ }
+
const isTempChatMessage = data.type === PANELIZE_TEMP_CHAT_ENABLED;
const isLocationMessage = data.type === PANELIZE_PROVIDER_LOCATION;
if (data.context !== MULTI_PANEL_PROVIDER_STATUS_CONTEXT || (!data.requestId && !isTempChatMessage && !isLocationMessage)) {
@@ -967,12 +1200,14 @@ async function loadSettings() {
multiPanelLayout: '1x3',
multiPanelProviders: DEFAULT_PROVIDERS,
openMode: 'tab',
- googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE
+ googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE,
+ claudeModelMode: DEFAULT_CLAUDE_MODEL_MODE
});
currentLayout = normalizeLayout(settings.multiPanelLayout);
currentOpenMode = settings.openMode || 'tab';
currentGoogleProviderMode = normalizeGoogleProviderMode(settings.googleProviderMode);
+ currentClaudeModelMode = normalizeClaudeModelMode(settings.claudeModelMode);
// Apply layout
const panelGrid = document.getElementById('panel-grid');
@@ -1028,6 +1263,7 @@ function collectCurrentState() {
providerId: p.providerId
})),
googleProviderMode: currentGoogleProviderMode,
+ claudeModelMode: currentClaudeModelMode,
timestamp: Date.now()
};
return state;
@@ -1123,6 +1359,12 @@ async function restoreStateIfNeeded() {
});
}
+ if (state.claudeModelMode) {
+ await chrome.storage.sync.set({
+ claudeModelMode: normalizeClaudeModelMode(state.claudeModelMode)
+ });
+ }
+
// 清除已恢复的状态
await chrome.storage.session.remove('preservedState');
await chrome.storage.local.remove('preservedState');
@@ -1324,6 +1566,9 @@ async function addPanel(providerId) {
iframe.addEventListener('load', () => {
loadingEl.classList.add('hidden');
const panel = panels.find(p => p.id === panelId);
+ if (panel?.providerId === 'claude') {
+ postClaudeModelOverride(panel);
+ }
if (isTemporaryChatModeEnabled && panel && requiresTemporaryChatActivationRetry(panel.providerId)) {
startTemporaryChatActivationForPanel(panel);
}
@@ -1405,6 +1650,9 @@ async function switchPanelProvider(panelId, newProviderId) {
if (isGoogleProvider(newProviderId)) {
syncGoogleModeControls();
}
+ if (newProviderId === 'claude') {
+ syncClaudeModelControls();
+ }
// Update panel header
const headerIcon = panelEl.querySelector('.panel-header-left img');
diff --git a/tests/claude-model-request-interceptor.test.js b/tests/claude-model-request-interceptor.test.js
new file mode 100644
index 0000000..3e6ce85
--- /dev/null
+++ b/tests/claude-model-request-interceptor.test.js
@@ -0,0 +1,398 @@
+import '../content-scripts/claude-model-request-utils.js';
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+import {
+ CLAUDE_MODEL_MODE_DEFAULT,
+ CLAUDE_MODEL_MODE_HAIKU_4_5,
+ CLAUDE_MODEL_MODE_OPUS_4_8,
+ CLAUDE_MODEL_MODE_SONNET_5,
+ CLAUDE_MODEL_OPTIONS,
+ createClaudeModelOverrideMessage,
+ getClaudeModelOption,
+ normalizeClaudeModelMode
+} from '../modules/claude-model-mode.js';
+
+const utils = globalThis.PanelizeClaudeModelRequestUtils;
+
+function createInterceptorHarness({ fetchError = null, fetchStatus = 200, xhrStatus = 200 } = {}) {
+ const messages = [];
+ const targetOrigins = [];
+ const fetchCalls = [];
+ const messageListeners = [];
+ const xhrInstances = [];
+
+ class FakeXMLHttpRequest {
+ constructor() {
+ this.listeners = new Map();
+ this.sentBodies = [];
+ this.status = xhrStatus;
+ xhrInstances.push(this);
+ }
+
+ addEventListener(type, listener) {
+ this.listeners.set(type, listener);
+ }
+
+ open() {}
+
+ send(body) {
+ this.sentBodies.push(body);
+ this.listeners.get('loadend')?.();
+ }
+ }
+
+ const parent = {
+ postMessage(message, targetOrigin) {
+ messages.push(message);
+ targetOrigins.push(targetOrigin);
+ }
+ };
+ const context = {
+ Request,
+ URL,
+ XMLHttpRequest: FakeXMLHttpRequest,
+ fetch: async (...args) => {
+ fetchCalls.push(args);
+ if (fetchError) {
+ throw fetchError;
+ }
+ return { status: fetchStatus };
+ },
+ location: { href: 'https://claude.ai/new' },
+ parent,
+ top: {},
+ addEventListener(type, listener) {
+ if (type === 'message') {
+ messageListeners.push(listener);
+ }
+ }
+ };
+ context.window = context;
+ context.self = context;
+
+ vm.runInNewContext(
+ fs.readFileSync(path.resolve('content-scripts/claude-model-request-utils.js'), 'utf8'),
+ context
+ );
+ vm.runInNewContext(
+ fs.readFileSync(path.resolve('content-scripts/claude-model-request-interceptor.js'), 'utf8'),
+ context
+ );
+
+ return {
+ configure(mode) {
+ messageListeners[0]({
+ source: parent,
+ origin: 'chrome-extension://panelize-test',
+ data: createClaudeModelOverrideMessage(mode)
+ });
+ },
+ context,
+ fetchCalls,
+ messages,
+ targetOrigins,
+ xhrInstances
+ };
+}
+
+describe('Claude model request override', () => {
+ it('defines the supported UI modes and exact request model IDs', () => {
+ expect(CLAUDE_MODEL_OPTIONS).toEqual([
+ { mode: 'default', label: 'Claude default', modelId: '' },
+ { mode: 'opus-4-8', label: 'Opus 4.8', modelId: 'claude-opus-4-8' },
+ { mode: 'sonnet-5', label: 'Sonnet 5', modelId: 'claude-sonnet-5' },
+ { mode: 'haiku-4-5', label: 'Haiku 4.5', modelId: 'claude-haiku-4-5-20251001' }
+ ]);
+ });
+
+ it('normalizes unknown stored values to Claude default', () => {
+ expect(normalizeClaudeModelMode(CLAUDE_MODEL_MODE_SONNET_5)).toBe('sonnet-5');
+ expect(normalizeClaudeModelMode('future-model')).toBe(CLAUDE_MODEL_MODE_DEFAULT);
+ expect(normalizeClaudeModelMode(null)).toBe(CLAUDE_MODEL_MODE_DEFAULT);
+ expect(getClaudeModelOption('future-model')).toEqual(CLAUDE_MODEL_OPTIONS[0]);
+ });
+
+ it.each([
+ [CLAUDE_MODEL_MODE_DEFAULT, ''],
+ [CLAUDE_MODEL_MODE_SONNET_5, 'claude-sonnet-5'],
+ [CLAUDE_MODEL_MODE_OPUS_4_8, 'claude-opus-4-8'],
+ [CLAUDE_MODEL_MODE_HAIKU_4_5, 'claude-haiku-4-5-20251001']
+ ])('builds a constrained parent message for %s', (mode, model) => {
+ expect(createClaudeModelOverrideMessage(mode)).toEqual({
+ type: 'PANELIZE_CLAUDE_MODEL_OVERRIDE',
+ mode,
+ model
+ });
+ });
+
+ it('uses exact origins after the initial iframe handshake', () => {
+ const harness = createInterceptorHarness();
+
+ expect(harness.targetOrigins).toEqual(['*']);
+ harness.configure(CLAUDE_MODEL_MODE_OPUS_4_8);
+ expect(harness.targetOrigins.at(-1)).toBe('chrome-extension://panelize-test');
+
+ const multiPanelSource = fs.readFileSync(
+ path.resolve('multi-panel/multi-panel.js'),
+ 'utf8'
+ );
+ expect(multiPanelSource).toContain(
+ "createClaudeModelOverrideMessage(mode),\n 'https://claude.ai'"
+ );
+ });
+
+ it('accepts only the three catalog model IDs', () => {
+ expect(utils.isAllowedModelId('claude-sonnet-5')).toBe(true);
+ expect(utils.isAllowedModelId('claude-opus-4-8')).toBe(true);
+ expect(utils.isAllowedModelId('claude-haiku-4-5-20251001')).toBe(true);
+ expect(utils.isAllowedModelId('claude-future-model')).toBe(false);
+ });
+
+ it('matches only Claude completion endpoint URLs', () => {
+ expect(utils.isClaudeCompletionUrl(
+ 'https://claude.ai/api/organizations/org/chat_conversations/chat/completion'
+ )).toBe(true);
+ expect(utils.isClaudeCompletionUrl('/api/chat/completion', 'https://claude.ai/new')).toBe(true);
+ expect(utils.isClaudeCompletionUrl('https://claude.ai/api/chat/completion/extra')).toBe(false);
+ expect(utils.isClaudeCompletionUrl('https://evil.example/api/chat/completion')).toBe(false);
+ expect(utils.isClaudeCompletionUrl('not a url', 'not a base')).toBe(false);
+ });
+
+ it('replaces only the root model field and leaves nested fields untouched', () => {
+ const result = utils.rewriteCompletionBody(JSON.stringify({
+ model: 'claude-sonnet-5',
+ metadata: { model: 'leave-this-value' },
+ messages: [{ content: 'private prompt', modelId: 'leave-this-too' }]
+ }), 'claude-opus-4-8');
+
+ expect(result).toMatchObject({
+ overridden: true,
+ changed: true,
+ inserted: false,
+ reason: 'model-replaced'
+ });
+ expect(JSON.parse(result.body)).toEqual({
+ model: 'claude-opus-4-8',
+ metadata: { model: 'leave-this-value' },
+ messages: [{ content: 'private prompt', modelId: 'leave-this-too' }]
+ });
+ });
+
+ it('injects a root model field when the embedded request omits it', () => {
+ const result = utils.rewriteCompletionBody(
+ '{"prompt":"hello","timezone":"Asia/Shanghai"}',
+ 'claude-haiku-4-5-20251001'
+ );
+
+ expect(result).toMatchObject({
+ overridden: true,
+ changed: true,
+ inserted: true,
+ reason: 'model-injected'
+ });
+ expect(JSON.parse(result.body)).toEqual({
+ prompt: 'hello',
+ timezone: 'Asia/Shanghai',
+ model: 'claude-haiku-4-5-20251001'
+ });
+ });
+
+ it('passes unsupported bodies through without rewriting them', () => {
+ expect(utils.rewriteCompletionBody('not-json', 'claude-sonnet-5')).toEqual({
+ body: 'not-json',
+ overridden: false,
+ reason: 'invalid-json'
+ });
+ expect(utils.rewriteCompletionBody('["prompt"]', 'claude-sonnet-5')).toEqual({
+ body: '["prompt"]',
+ overridden: false,
+ reason: 'unsupported-shape'
+ });
+ expect(utils.rewriteCompletionBody('{"model":null}', 'claude-sonnet-5')).toEqual({
+ body: '{"model":null}',
+ overridden: false,
+ reason: 'unsupported-model-field'
+ });
+ expect(utils.rewriteCompletionBody('{"prompt":"hello"}', 'claude-unknown')).toEqual({
+ body: '{"prompt":"hello"}',
+ overridden: false,
+ reason: 'unsupported-model'
+ });
+ });
+
+ it('classifies only 4xx responses as model request rejections', () => {
+ expect(utils.classifyResponseStatus(200)).toBe('accepted');
+ expect(utils.classifyResponseStatus(204)).toBe('accepted');
+ expect(utils.classifyResponseStatus(400)).toBe('rejected');
+ expect(utils.classifyResponseStatus(403)).toBe('rejected');
+ expect(utils.classifyResponseStatus(500)).toBe('server-error');
+ expect(utils.classifyResponseStatus(0)).toBe('unknown');
+ });
+
+ it('rewrites a Fetch completion request once without resending it', async () => {
+ const harness = createInterceptorHarness();
+ harness.configure(CLAUDE_MODEL_MODE_OPUS_4_8);
+
+ await harness.context.fetch(
+ 'https://claude.ai/api/organizations/org/chat_conversations/chat/completion',
+ { method: 'POST', body: '{"prompt":"hello"}' }
+ );
+
+ expect(harness.fetchCalls).toHaveLength(1);
+ expect(JSON.parse(harness.fetchCalls[0][1].body)).toEqual({
+ prompt: 'hello',
+ model: 'claude-opus-4-8'
+ });
+ expect(harness.messages.map(message => message.event)).toContain('override-request-accepted');
+ });
+
+ it('leaves completion requests unchanged in Claude default mode', async () => {
+ const harness = createInterceptorHarness();
+ const body = '{"prompt":"default"}';
+
+ await harness.context.fetch('https://claude.ai/api/chat/completion', {
+ method: 'POST',
+ body
+ });
+
+ expect(harness.fetchCalls).toHaveLength(1);
+ expect(harness.fetchCalls[0][1].body).toBe(body);
+ expect(harness.messages.map(message => message.event)).not.toContain('request-overridden');
+ });
+
+ it('passes an unsupported Fetch body through once and disables later overrides', async () => {
+ const harness = createInterceptorHarness();
+ harness.configure(CLAUDE_MODEL_MODE_SONNET_5);
+ const endpoint = 'https://claude.ai/api/chat/completion';
+
+ await harness.context.fetch(endpoint, { method: 'POST', body: 'not-json' });
+ await harness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"second"}' });
+
+ expect(harness.fetchCalls).toHaveLength(2);
+ expect(harness.fetchCalls[0][1].body).toBe('not-json');
+ expect(harness.fetchCalls[1][1].body).toBe('{"prompt":"second"}');
+ expect(harness.messages.map(message => message.event)).toContain('override-schema-unsupported');
+ });
+
+ it('disables the override after a 4xx without automatically retrying', async () => {
+ const harness = createInterceptorHarness({ fetchStatus: 403 });
+ const endpoint = 'https://claude.ai/api/chat/completion';
+ harness.configure(CLAUDE_MODEL_MODE_HAIKU_4_5);
+
+ await harness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"first"}' });
+ await harness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"second"}' });
+
+ expect(harness.fetchCalls).toHaveLength(2);
+ expect(JSON.parse(harness.fetchCalls[0][1].body).model).toBe('claude-haiku-4-5-20251001');
+ expect(harness.fetchCalls[1][1].body).toBe('{"prompt":"second"}');
+ expect(harness.messages.map(message => message.event)).toContain('override-request-rejected');
+ });
+
+ it('leaves the override enabled after a 5xx and sends each XHR only once', async () => {
+ const fetchHarness = createInterceptorHarness({ fetchStatus: 500 });
+ const endpoint = 'https://claude.ai/api/chat/completion';
+ fetchHarness.configure(CLAUDE_MODEL_MODE_SONNET_5);
+
+ await fetchHarness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"first"}' });
+ await fetchHarness.context.fetch(endpoint, { method: 'POST', body: '{"prompt":"second"}' });
+
+ expect(fetchHarness.fetchCalls).toHaveLength(2);
+ expect(JSON.parse(fetchHarness.fetchCalls[1][1].body).model).toBe('claude-sonnet-5');
+
+ const xhrHarness = createInterceptorHarness({ xhrStatus: 200 });
+ xhrHarness.configure(CLAUDE_MODEL_MODE_OPUS_4_8);
+ const xhr = new xhrHarness.context.XMLHttpRequest();
+ xhr.open('POST', endpoint);
+ xhr.send('{"prompt":"xhr"}');
+
+ expect(xhrHarness.xhrInstances).toHaveLength(1);
+ expect(xhr.sentBodies).toHaveLength(1);
+ expect(JSON.parse(xhr.sentBodies[0]).model).toBe('claude-opus-4-8');
+ });
+
+ it('does not treat a Fetch network error as a model rejection', async () => {
+ const networkError = new TypeError('network unavailable');
+ const harness = createInterceptorHarness({ fetchError: networkError });
+ harness.configure(CLAUDE_MODEL_MODE_SONNET_5);
+
+ await expect(harness.context.fetch(
+ 'https://claude.ai/api/chat/completion',
+ { method: 'POST', body: '{"prompt":"network"}' }
+ )).rejects.toThrow('network unavailable');
+
+ expect(harness.fetchCalls).toHaveLength(1);
+ expect(harness.messages.map(message => message.event)).not.toContain('override-request-rejected');
+ });
+
+ it('disables an XHR override after one 4xx without resending', () => {
+ const harness = createInterceptorHarness({ xhrStatus: 403 });
+ const endpoint = 'https://claude.ai/api/chat/completion';
+ harness.configure(CLAUDE_MODEL_MODE_OPUS_4_8);
+
+ const firstXhr = new harness.context.XMLHttpRequest();
+ firstXhr.open('POST', endpoint);
+ firstXhr.send('{"prompt":"first"}');
+
+ const secondXhr = new harness.context.XMLHttpRequest();
+ secondXhr.open('POST', endpoint);
+ secondXhr.send('{"prompt":"second"}');
+
+ expect(firstXhr.sentBodies).toHaveLength(1);
+ expect(JSON.parse(firstXhr.sentBodies[0]).model).toBe('claude-opus-4-8');
+ expect(secondXhr.sentBodies).toEqual(['{"prompt":"second"}']);
+ expect(harness.messages.map(message => message.event)).toContain('override-request-rejected');
+ });
+
+ it('loads the passive interceptor in the main world for Claude frames', () => {
+ const manifest = JSON.parse(fs.readFileSync(path.resolve('manifest.json'), 'utf8'));
+ const registration = manifest.content_scripts.find(entry =>
+ entry.js.includes('content-scripts/claude-model-request-interceptor.js')
+ );
+
+ expect(registration).toMatchObject({
+ matches: ['https://claude.ai/*'],
+ run_at: 'document_start',
+ all_frames: true,
+ world: 'MAIN'
+ });
+ expect(registration.js).toEqual([
+ 'content-scripts/claude-model-request-utils.js',
+ 'content-scripts/claude-model-request-interceptor.js'
+ ]);
+
+ const source = fs.readFileSync(
+ path.resolve('content-scripts/claude-model-request-interceptor.js'),
+ 'utf8'
+ );
+ expect(source).toContain('if (window.self === window.top)');
+ expect(source).not.toContain('console.info');
+ expect(source).not.toContain('endpointPattern');
+ });
+
+ it('provides the Claude model UI copy in every supported locale', () => {
+ const requiredKeys = [
+ 'openProviderTopLevel',
+ 'embeddedModelLimitNotice',
+ 'embeddedModelOverrideNotice',
+ 'claudeModelDefault',
+ 'claudeModelExperimentalBadge',
+ 'claudeModelExperimentalTitle',
+ 'claudeModelSelectTitle',
+ 'claudeModelRequestedStatus',
+ 'claudeModelOverrideUnavailable',
+ 'claudeModelRequestRejected'
+ ];
+ const locales = fs.readdirSync(path.resolve('_locales'));
+
+ locales.forEach(locale => {
+ const messages = JSON.parse(fs.readFileSync(
+ path.resolve('_locales', locale, 'messages.json'),
+ 'utf8'
+ ));
+ requiredKeys.forEach(key => {
+ expect(messages[key]?.message, `${locale} is missing ${key}`).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/tests/e2e/claude-model-selection.e2e.test.js b/tests/e2e/claude-model-selection.e2e.test.js
new file mode 100644
index 0000000..00ff0ce
--- /dev/null
+++ b/tests/e2e/claude-model-selection.e2e.test.js
@@ -0,0 +1,142 @@
+import { test, expect, chromium } from '@playwright/test';
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const EXTENSION_PATH = path.resolve(__dirname, '../..');
+
+test.describe('Claude model selection E2E', () => {
+ test.setTimeout(60000);
+
+ let context;
+ let extensionId;
+ let userDataDir;
+
+ test.beforeAll(async () => {
+ userDataDir = await mkdtemp(path.join(os.tmpdir(), 'panelize-claude-model-'));
+ context = await chromium.launchPersistentContext(userDataDir, {
+ headless: false,
+ viewport: { width: 1200, height: 900 },
+ args: [
+ `--disable-extensions-except=${EXTENSION_PATH}`,
+ `--load-extension=${EXTENSION_PATH}`,
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ ],
+ });
+
+ let [serviceWorker] = context.serviceWorkers();
+ if (!serviceWorker) {
+ serviceWorker = await context.waitForEvent('serviceworker');
+ }
+ extensionId = new URL(serviceWorker.url()).host;
+
+ await context.route('https://claude.ai/**', async route => {
+ await route.fulfill({
+ contentType: 'text/html',
+ body: 'Claude test frame',
+ });
+ });
+ });
+
+ test.afterAll(async () => {
+ await context?.close();
+ await rm(userDataDir, { recursive: true, force: true });
+ });
+
+ test('persists and synchronizes the global selector with responsive themes', async ({}, testInfo) => {
+ const pageA = await context.newPage();
+ const pageB = await context.newPage();
+ const panelUrl = `chrome-extension://${extensionId}/multi-panel/multi-panel.html`;
+
+ await pageA.goto(panelUrl);
+ await pageA.evaluate(async () => {
+ await chrome.storage.sync.set({
+ claudeModelMode: 'default',
+ enabledProviders: ['claude'],
+ multiPanelLayout: '1x1',
+ multiPanelProviders: ['claude'],
+ providerOrder: ['claude'],
+ theme: 'light',
+ });
+ });
+ await pageA.reload();
+ await pageB.goto(panelUrl);
+
+ const selectorA = pageA.locator('.panel-claude-model-select');
+ const selectorB = pageB.locator('.panel-claude-model-select');
+ await expect(selectorA).toHaveValue('default');
+ await expect(selectorB).toHaveValue('default');
+ await expect(selectorA.locator('option')).toHaveText([
+ 'Claude default',
+ 'Opus 4.8',
+ 'Sonnet 5',
+ 'Haiku 4.5',
+ ]);
+ await expect(pageA.locator('.embedded-model-limit-notice')).toBeVisible();
+
+ await selectorA.selectOption('opus-4-8');
+ await expect(selectorA).toHaveValue('opus-4-8');
+ await expect(selectorB).toHaveValue('opus-4-8');
+ await expect.poll(async () => pageA.evaluate(async () => (
+ await chrome.storage.sync.get('claudeModelMode')
+ ).claudeModelMode)).toBe('opus-4-8');
+
+ await pageB.reload();
+ await expect(pageB.locator('.panel-claude-model-select')).toHaveValue('opus-4-8');
+ await pageA.screenshot({
+ path: testInfo.outputPath('claude-model-selector-1x1-light.png'),
+ fullPage: true,
+ });
+
+ await pageA.evaluate(async () => {
+ await chrome.storage.sync.set({
+ theme: 'dark',
+ });
+ });
+ await pageA.reload();
+ await expect(pageA.locator('html')).toHaveAttribute('data-theme', 'dark');
+ await expect(pageA.locator('.panel-claude-model-select')).toHaveCSS('background-repeat', 'no-repeat');
+ await pageA.screenshot({
+ path: testInfo.outputPath('claude-model-selector-1x1-dark.png'),
+ fullPage: true,
+ });
+
+ await pageA.evaluate(async () => {
+ await chrome.storage.sync.set({
+ multiPanelLayout: '1x10',
+ theme: 'light',
+ });
+ });
+ await pageA.reload();
+ await expect(pageA.locator('#panel-grid')).toHaveClass(/layout-1x10/);
+ await expect(pageA.locator('html')).toHaveAttribute('data-theme', 'light');
+ await expect(pageA.locator('.panel-claude-model-select')).toBeVisible();
+ await pageA.screenshot({
+ path: testInfo.outputPath('claude-model-selector-1x10-light.png'),
+ fullPage: true,
+ });
+
+ await pageA.evaluate(async () => {
+ await chrome.storage.sync.set({ theme: 'dark' });
+ });
+ await pageA.reload();
+ await expect(pageA.locator('#panel-grid')).toHaveClass(/layout-1x10/);
+ await expect(pageA.locator('html')).toHaveAttribute('data-theme', 'dark');
+ await expect(pageA.locator('.panel-claude-model-select')).toBeVisible();
+ await expect(pageA.locator('.panel-claude-model-select')).toHaveCSS('background-repeat', 'no-repeat');
+ await pageA.screenshot({
+ path: testInfo.outputPath('claude-model-selector-1x10-dark.png'),
+ fullPage: true,
+ });
+
+ await pageA.locator('.panel-claude-model-select').selectOption('default');
+ await expect(pageB.locator('.panel-claude-model-select')).toHaveValue('default');
+
+ await pageA.close();
+ await pageB.close();
+ });
+});
diff --git a/tests/settings.test.js b/tests/settings.test.js
index c7ea2fe..328ea6e 100644
--- a/tests/settings.test.js
+++ b/tests/settings.test.js
@@ -9,6 +9,7 @@ import {
importSettings,
} from '../modules/settings.js';
import { DEFAULT_GOOGLE_PROVIDER_MODE } from '../modules/google-mode.js';
+import { DEFAULT_CLAUDE_MODEL_MODE } from '../modules/claude-model-mode.js';
import { DEFAULT_PROVIDER_IDS } from '../modules/provider-defaults.js';
describe('settings module', () => {
@@ -47,12 +48,13 @@ describe('settings module', () => {
expect(chrome.storage.local.get).toHaveBeenCalled();
});
- it('should expose the default Google provider mode', async () => {
+ it('should expose the default provider modes', async () => {
chrome.storage.sync.get.mockImplementation(async (defaults) => defaults);
const result = await getSettings();
expect(result.googleProviderMode).toBe(DEFAULT_GOOGLE_PROVIDER_MODE);
+ expect(result.claudeModelMode).toBe(DEFAULT_CLAUDE_MODEL_MODE);
expect(result.enabledProviders).toEqual(DEFAULT_PROVIDER_IDS);
});
});
@@ -90,6 +92,12 @@ describe('settings module', () => {
expect(chrome.storage.sync.set).toHaveBeenCalledWith({ googleProviderMode: 'search' });
});
+
+ it('should save the Claude model mode', async () => {
+ await saveSetting('claudeModelMode', 'opus-4-8');
+
+ expect(chrome.storage.sync.set).toHaveBeenCalledWith({ claudeModelMode: 'opus-4-8' });
+ });
});
describe('saveSettings', () => {
@@ -114,6 +122,7 @@ describe('settings module', () => {
expect.objectContaining({
enabledProviders: expect.any(Array),
googleProviderMode: DEFAULT_GOOGLE_PROVIDER_MODE,
+ claudeModelMode: DEFAULT_CLAUDE_MODEL_MODE,
defaultProvider: 'chatgpt',
theme: 'auto',
})