fix(ui): prevent text overlap in Claude Code model selectors

- Let selector height adapt to model names and aliases
- Add layout regression tests across languages, themes, and viewport sizes
- Verify model selection, keyboard search, 1M toggles, and config updates
This commit is contained in:
lzt404 committed 2026-10-08 18:36:58 +08:00
1 parent 97ba1051a2
commit f7e9bd9884
13 files changed
+208 -65

No files matched your search

+32 -3
View File
@@ -58,9 +58,9 @@ const fs = require('node:fs/promises');
assert.equal(await card(1).getByRole('switch').isVisible(), true);
assert.equal(await card(1).locator('.auth-list-details-button').isVisible(), true);
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false, `${label}: file metadata and the expanded request summary start collapsed`);
if (width >= 1280) {
if (width >= 1800) {
const heights = await page.locator('.auth-credential-row').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
assert.ok(heights.every(height => height >= 88 && height <= 120), `${label}: dense desktop rows stay near 104px, including quota footnotes: ${JSON.stringify(heights)}`);
assert.ok(heights.every(height => height >= 88 && height <= 190), `${label}: desktop rows stay compact, allowing labeled actions to wrap: ${JSON.stringify(heights)}`);
const cells = await card(1).locator('.auth-credential-row > .auth-list-cell').evaluateAll(nodes => nodes.map(node => {
const bounds = node.getBoundingClientRect(); return { left: bounds.left, right: bounds.right };
}));
@@ -74,7 +74,7 @@ const fs = require('node:fs/promises');
assert.equal(await previous().isDisabled(), true);
assert.ok((await pagination().innerText()).includes('1 / 2'));
assert.equal(await page.locator('.auth-file-list-head > span').count(), 7);
await assertMainInformation('initial desktop', 1800);
await assertMainInformation('initial desktop', 1500);
assert.equal(await card(2).locator('.auth-file-health-compact').isVisible(), true, 'Cooldown summary is directly visible below the main row');
assert.equal(await card(2).locator('.auth-health-body').isVisible(), false, 'Individual cooldown records start collapsed');
assert.equal(await card(2).getByRole('button', { name: '清除冷却', exact: true }).isVisible(), true, 'Clear cooldown is available without opening file details');
@@ -244,6 +244,35 @@ const fs = require('node:fs/promises');
await page.locator('.auth-file-table-scroll').screenshot({ path: path.join(screenshotDir, 'reference-small-idle-preview.png') });
assert.deepEqual(errors, []);
assert.deepEqual(externalRequests, []);
await page.goto(`${base}/tests/fixtures/auth-file-list.html?displayCases=1&locale=zh-CN&theme=light`);
await cards().nth(5).waitFor();
assert.equal(await cards().count(), 6, 'Display fixture renders every representative credential');
const disabledBadges = page.locator('.auth-status-badge.neutral');
assert.equal(await disabledBadges.count(), 5, 'Every disabled credential uses the neutral status treatment');
for (const badge of await disabledBadges.all()) assert.equal(await badge.innerText(), '已停用');
assert.equal(await page.locator('.auth-status-badge.success').innerText(), '可用');
const actionLabels = ['刷新', '设置', '模型', '复制', '删除'];
for (const label of actionLabels) {
const action = cards().first().getByRole('button', { name: label, exact: true });
assert.equal(await action.innerText(), label, `${label} is a visible text button`);
assert.equal(await action.locator('svg title').count(), 0, `${label} does not rely on an icon-only label`);
}
assert.equal(await cards().last().locator('.credential-quota-row-detail').innerText(), '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。');
for (const width of [1680, 1366, 1180, 980, 390]) {
await page.setViewportSize({ width, height: 1000 });
const collisions = await page.locator('.auth-file-card').evaluateAll(nodes => nodes.flatMap(card => {
const buttons = [...card.querySelectorAll('.auth-card-actions button')].filter(button => button.getClientRects().length);
const boxes = buttons.map(button => button.getBoundingClientRect());
return boxes.flatMap((box, index) => boxes.slice(index + 1)
.filter(other => box.left < other.right - 1 && other.left < box.right - 1 && box.top < other.bottom - 1 && other.top < box.bottom - 1)
.map(other => ({ width: innerWidth, texts: [buttons[index].textContent, buttons[boxes.indexOf(other)].textContent] })));
}));
assert.deepEqual(collisions, [], `${width}: credential actions overlap ${JSON.stringify(collisions)}`);
const quotaOverflow = await page.locator('.auth-list-quota').evaluateAll(nodes => nodes.filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.textContent));
assert.deepEqual(quotaOverflow, [], `${width}: quota content overflows its column`);
await page.screenshot({ path: path.join(screenshotDir, `display-cases-${width}.png`), fullPage: true });
}
console.log('PASS: offline seven-column dense credential list with visible request/runtime/quota/action data, compact row height, exact quota percentages/reset timestamps, unknown quota, ten/two pagination, search/provider/status reset, page size twenty, additional quota disclosure, main-row and detail-summary request keyboard tooltips, directly visible cooldown controls, exact status PATCH, direct delete confirmation/cancel, small idle accounts without pagination, light/dark Chinese/English 1800/1280/1024/390 without page or card overflow, contained table scrolling only at intermediate widths.');
console.log(`Screenshots: ${screenshotDir}`);
} finally { await browser?.close(); await server.close(); }
+1 -1
View File
@@ -15,7 +15,7 @@ const os = require('node:os');
assert.equal(await startup.inputValue(), 'sonnet');
await page.locator('.claude-role-row .agent-model-trigger').first().click();
await page.getByRole('option', { name: /gpt-two/ }).click();
await page.getByText('sonnet → gpt-two', { exact: true }).waitFor();
assert.equal(await page.locator('.claude-code-model-summary').count(), 0);
assert.equal(await startup.inputValue(), 'sonnet');
await startup.click();
await page.getByRole('option', { name: /^gpt-two / }).click();
+71
View File
@@ -0,0 +1,71 @@
// Run Vite on port 1421, or set AGENT_MODEL_LAYOUT_TEST_URL to another local server.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const base = process.env.AGENT_MODEL_LAYOUT_TEST_URL || 'http://127.0.0.1:1421';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
for (const locale of ['zh-CN', 'en', 'ja']) {
for (const theme of ['light', 'dark']) {
for (const width of [1392, 1100, 760, 480]) {
await page.setViewportSize({ width, height: 962 });
await page.goto(`${base}/tests/fixtures/agent-backups.html?client=claude-code&reset-selections&shell&claude-alias-layout&locale=${locale}&theme=${theme}`);
await page.locator('.claude-role-row[data-role=sonnet] .agent-model-trigger small').waitFor();
await page.evaluate(() => document.fonts.ready);
const issues = await page.locator('.claude-role-table').evaluate(table => {
const issues = [];
for (const row of table.querySelectorAll('.claude-role-row')) {
const trigger = row.querySelector('.agent-model-trigger');
const bounds = trigger.getBoundingClientRect();
for (const text of trigger.querySelectorAll('strong, small')) {
const rect = text.getBoundingClientRect();
if (rect.top < bounds.top - 1 || rect.bottom > bounds.bottom + 1
|| rect.left < bounds.left - 1 || rect.right > bounds.right + 1) {
issues.push(`${row.dataset.role}: model text exceeds the selector`);
}
}
const next = row.nextElementSibling?.getBoundingClientRect();
if (next && bounds.bottom > next.top + 1) issues.push(`${row.dataset.role}: selector overlaps the next row`);
if (row.scrollWidth > row.clientWidth + 1) issues.push(`${row.dataset.role}: horizontal overflow`);
const toggle = row.querySelector('.claude-inline-context').getBoundingClientRect();
if (bounds.right > toggle.left + 1) issues.push(`${row.dataset.role}: selector overlaps the context switch`);
}
return issues;
});
assert.deepEqual(issues, [], `${locale}/${theme}/${width}`);
if (locale === 'zh-CN' && theme === 'light' && [1392, 480].includes(width)) {
await page.screenshot({ path: path.join(os.tmpdir(), `claude-code-model-layout-${width}.png`), fullPage: true });
}
}
}
}
await page.setViewportSize({ width: 1392, height: 962 });
await page.goto(`${base}/tests/fixtures/agent-backups.html?client=claude-code&reset-selections&shell&claude-alias-layout`);
const sonnet = page.locator('.claude-role-row[data-role=sonnet]');
await sonnet.locator('.agent-model-trigger small').waitFor();
await page.locator('.agent-claude-desktop-mapping-filter input').check();
const trigger = sonnet.locator('.agent-model-trigger');
await trigger.click();
await page.getByRole('option', { name: /^gpt-6\.1-sol / }).click();
assert.equal(await trigger.locator('small').count(), 0);
await trigger.focus();
await trigger.press('Enter');
await sonnet.locator('.agent-model-search input').fill('high-fast');
await page.getByRole('option', { name: /^gpt-6.1-sol-high-fast / }).click();
assert.equal(await trigger.locator('small').innerText(), 'gpt-6.1-sol');
await page.getByRole('switch', { name: 'sonnet 1M', exact: true }).check();
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'update_agent_config'
&& call.args.claudeCodeModelMappings.sonnet === 'gpt-6.1-sol-high-fast'
&& call.args.claudeCodeModelMappings.sonnet1m === true));
assert.deepEqual(errors, []);
console.log('PASS: 24 viewport/language/theme layouts, alias selection, keyboard search, context toggle and configuration update');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+7
View File
@@ -13,6 +13,12 @@ const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','herme
let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one';
let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0;
let currentOauth=false; const currentMappings:Record<string,any>={};
const claudeAliasLayoutModels = [
{name:'gpt-6.1-sol-high-fast',alias:'gpt-6.1-sol',isAlias:true},
{name:'gpt-6.1-sol',isAlias:true},
{name:'provider/very-long-upstream-model-name-with-reasoning-high',alias:'provider/very-long-original-model-name-with-context-window',isAlias:true},
];
if(params.has('claude-alias-layout'))currentMappings['claude-code']={opus:claudeAliasLayoutModels[0].name,sonnet:claudeAliasLayoutModels[0].name,fable:claudeAliasLayoutModels[1].name,haiku:claudeAliasLayoutModels[2].name,startupModel:'opus',subagentModel:''};
if(params.has('legacy-desktop'))currentMappings['claude-desktop']={opus:'gpt-one',sonnet:'gpt-two',haiku:'gpt-one',opus1m:true};
if(params.has('legacy-desktop-direct'))currentMappings['claude-desktop']={opus:'',sonnet:'claude-sonnet-custom-7',haiku:'',desktopModels:[{model:'',alias:'claude-sonnet-custom-7',context1m:false}]};
if(params.has('saved-desktop-alias'))currentMappings['claude-desktop']={opus:'',sonnet:'gpt-one',haiku:'',desktopModels:[{model:'gpt-one',alias:'claude-opus-5',context1m:false}]};
@@ -65,6 +71,7 @@ mockIPC(async (cmd,args:any) => {
nativeOauth=true; return {outcome:'updated'};
}
if(cmd==='get_agent_models') {
if(params.has('claude-alias-layout'))return claudeAliasLayoutModels;
if(params.has('no-models'))return [];
if(params.has('no-core'))throw new Error('CPA core is offline');
if(params.has('defer-models'))await new Promise<void>(resolve=>{(window as any).fixtureFinishModels=resolve;});
+14 -9
View File
@@ -14,15 +14,20 @@ document.documentElement.dataset.theme = params.get('theme') || 'light';
const startedAt = Date.now();
const smallList = params.has('small');
type Request = { path: string; method: string; body?: Record<string, unknown>; query?: Record<string, string> };
const files: Record<string, unknown>[] = Array.from({ length: smallList ? 3 : 12 }, (_, index) => ({
name: `${String(index + 1).padStart(2, '0')}-account.json`,
email: index === 0 ? 'long-fictional-account-for-layout@example.test' : `account-${index + 1}@example.test`,
auth_index: `fixture-account-${index + 1}`, provider: smallList ? ['codex', 'devin', 'xai'][index] : index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
const displayCases = params.has('displayCases');
const files: Record<string, unknown>[] = Array.from({ length: displayCases ? 6 : smallList ? 3 : 12 }, (_, index) => ({
name: displayCases
? ['antigravity-very-long-account-name-for-layout.json', 'codex-team-subscription.json', 'codex-free-disabled.json', 'devin-user-layout-check.json', 'kimi-layout-check.json', 'xai-paid-account.json'][index]
: `${String(index + 1).padStart(2, '0')}-account.json`,
email: displayCases
? ['sasaki245668-layout-check@example.test', '1154606263-layout-check@example.test', 'lzt404rum-codex-layout@example.test', 'lzt404rum-devin-layout@example.test', 'kimi-layout-check@example.test', 'lori-doyle-layout-check@example.test'][index]
: index === 0 ? 'long-fictional-account-for-layout@example.test' : `account-${index + 1}@example.test`,
auth_index: `fixture-account-${index + 1}`, provider: displayCases ? ['antigravity', 'codex', 'codex', 'devin', 'kimi', 'xai'][index] : smallList ? ['codex', 'devin', 'xai'][index] : index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
...(params.has('longQuota') ? { provider: 'antigravity' } : {}),
source: 'file', size: 1800 + index * 350, updated_at: '2026-10-01T00:00:00Z',
disabled: smallList ? index !== 1 : index === 2 || index === 6,
status: (smallList ? index !== 1 : index === 2 || index === 6) ? 'disabled' : 'active',
priority: smallList ? 0 : index % 3, plan_type: smallList ? index === 0 ? 'Team' : '' : index % 3 === 0 ? 'Pro 20x' : 'Team',
disabled: displayCases ? index < 5 : smallList ? index !== 1 : index === 2 || index === 6,
status: (displayCases ? index < 5 : smallList ? index !== 1 : index === 2 || index === 6) ? 'disabled' : 'active',
priority: displayCases || smallList ? 0 : index % 3, plan_type: displayCases ? ['', 'Team', 'Free', '', '', 'Paid'][index] : smallList ? index === 0 ? 'Team' : '' : index % 3 === 0 ? 'Pro 20x' : 'Team',
note: index === 0 ? 'Fictional account note, visible after opening credential details.' : '',
success: smallList ? 0 : 1200 + index * 2450, failed: smallList ? 0 : 3 + index * 97,
recent_requests: Array.from({ length: smallList ? 0 : 20 }, (_, bucket) => ({
@@ -35,13 +40,13 @@ const files: Record<string, unknown>[] = Array.from({ length: smallList ? 3 : 12
}));
updateQuotaCache(Object.fromEntries(files.map((file, index) => {
if (smallList) return [quotaKey(file), { status: 'idle', rows: [] } satisfies QuotaState];
if (smallList || (displayCases && index < 5)) return [quotaKey(file), { status: 'idle', rows: [] } satisfies QuotaState];
const quota: QuotaState = {
status: 'success', plan: String(file.plan_type),
subscriptionActiveUntil: index === 0 ? '2026-11-01T00:00:00Z' : undefined,
resetCredits: index === 0 ? 2 : undefined,
resetCreditsApplicable: index === 0 ? 0 : undefined,
rows: index === 4 ? [{ label: 'Paid API account', remainingPercent: null, detail: 'Upstream does not report remaining quota.' }]
rows: displayCases || index === 4 ? [{ label: '付费 API 账号', remainingPercent: null, detail: '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。' }]
: [
{ label: '5h', remainingPercent: index === 1 ? 0 : index === 2 ? 8 : 72 - index * 3, resetAtMs: startedAt + 3 * 3600000 },
{ label: 'Weekly', remainingPercent: 83 - index * 5, resetAtMs: startedAt + 5 * 86400000 },