feat: refine management UI and enhance credential and plugin OAuth support

- Adopt a compact seven-column credential layout with visible quotas, request stats, and actions
- Display cooldown scope, countdowns, and model details with accessible reset controls
- Fix cooldown misassignment when merging same-named credentials from different accounts
- Add client visibility management with search, auto-detection, and persisted preferences
- Integrate plugin OAuth providers with deduplication and change synchronization
- Refine dashboard typography and spacing, and streamline usage history controls
- Add translations, unit tests, and responsive interaction coverage
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent cc24f8419f
commit 3b11361f1b
46 files changed
+2301 -418

No files matched your search

+59
View File
@@ -100,3 +100,62 @@
.auth-health-countdown { margin-left: 26px; }
.auth-health-disclosure { margin-left: auto; }
}
/* A visible cooldown strip below the credential row, independent of file details. */
.auth-file-health.auth-file-health-compact {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0 12px;
grid-column: auto;
border: 0;
border-top: 1px solid var(--auth-border, var(--border-color));
border-radius: 0;
background: transparent;
font-size: 12px;
line-height: 1.5;
}
.auth-file-health-compact > details { min-width: 0; }
.auth-file-health-compact > details[open] { grid-column: 1 / -1; }
.auth-file-health-compact .auth-health-summary {
min-height: 38px;
padding: 7px 0;
gap: 4px 8px;
}
.auth-file-health-compact .auth-health-summary-content { flex: 1; flex-basis: auto; }
.auth-file-health-compact .auth-health-icon { width: 14px; height: 14px; }
.auth-file-health-compact .auth-health-heading { gap: 4px 8px; }
.auth-file-health-compact .auth-health-heading strong { font-size: 12px; font-weight: 650; }
.auth-file-health-compact .auth-health-countdown { margin: 0 0 0 auto; color: var(--text-tertiary); font-size: 11px; }
.auth-file-health-compact .auth-health-disclosure { margin: 0; }
.auth-file-health-compact .auth-health-body { padding: 0 0 12px; }
.auth-file-health-compact .auth-health-records { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 20px; }
.auth-file-health-compact .auth-health-records li { min-width: 0; padding: 10px 0; }
.auth-file-health-compact .auth-health-record-heading { gap: 4px 10px; }
.auth-file-health-compact .auth-health-record-heading strong { font-size: 12px; font-weight: 600; }
.auth-file-health-compact .auth-health-record-heading > span { font-size: 11px; color: var(--text-tertiary); }
.auth-file-health-compact .auth-health-record-meta { gap: 3px 10px; color: var(--text-secondary); font-size: 11px; }
.auth-file-health-compact .auth-health-hint { font-size: 11px; line-height: 1.5; }
.auth-file-health-compact .auth-health-core-state { margin: 6px 0; color: var(--text-tertiary); font-size: 11px; }
.auth-file-health-compact .auth-health-core-message p { margin-bottom: 8px; font-size: 12px; }
.auth-file-health-compact .auth-health-actions { padding: 4px 0; border: 0; }
.auth-file-health-compact:has(> details[open]) .auth-health-actions {
grid-column: 1 / -1;
justify-content: flex-end;
padding: 8px 0;
border-top: 1px solid var(--auth-border, var(--border-color));
}
.auth-file-health-compact .auth-health-actions .auth-health-hint { display: none; }
.auth-file-health-compact .auth-health-reset { width: auto; min-height: 28px; margin: 0; padding: 4px 8px; border-radius: 6px; box-shadow: none; font-size: 11px; }
@container credential-files (max-width: 700px) {
.auth-file-health.auth-file-health-compact { gap: 0 8px; }
.auth-file-health-compact .auth-health-records { grid-template-columns: minmax(0, 1fr); }
.auth-file-health-compact .auth-health-summary { position: relative; padding-right: 18px; }
.auth-file-health-compact .auth-health-summary-content { flex-basis: calc(100% - 24px); }
.auth-file-health-compact .auth-health-countdown { margin: 0 0 0 22px; }
.auth-file-health-compact .auth-health-disclosure { position: absolute; top: 11px; right: 0; }
.auth-file-health-compact > details[open] .auth-health-summary-content { flex-basis: auto; }
.auth-file-health-compact > details[open] .auth-health-countdown { margin-left: auto; }
}
+13 -11
View File
@@ -38,6 +38,7 @@ type HealthProps = {
resetting?: boolean;
resetDisabled?: boolean;
onReset?: () => void;
compact?: boolean;
};
export function AuthFileHealthStatus({ file, receivedAtMs, observedAt, ...resetProps }: Omit<HealthProps, 'snapshot'> & {
@@ -58,7 +59,7 @@ function TimedHealthStatus(props: HealthProps) {
return <HealthStatus {...props} nowMs={nowMs} />;
}
function HealthStatus({ file, snapshot, nowMs, resetting = false, resetDisabled = false, onReset }: HealthProps & { nowMs: number }) {
function HealthStatus({ file, snapshot, nowMs, resetting = false, resetDisabled = false, onReset, compact = false }: HealthProps & { nowMs: number }) {
const { t, formatNumber, formatDate } = useI18n();
const health = authFileHealth(file);
const cooldown = summarizeAuthFileCooldowns(snapshot, nowMs);
@@ -87,37 +88,38 @@ function HealthStatus({ file, snapshot, nowMs, resetting = false, resetDisabled
? cooldown.modelCount ? t('authFiles.health.credentialModels', { count: cooldown.modelCount })
: t('authFiles.health.credential')
: cooldown.modelCount ? t('authFiles.health.models', { count: cooldown.modelCount }) : '';
const title = health.disabled ? t(health.label)
const title = compact && snapshot?.records === null ? t('authFiles.health.unknownCooldown')
: health.disabled && !compact ? t(health.label)
: cooldown.elapsed ? t('authFiles.health.elapsed')
: scope || (healthy ? t('authFiles.health.unknownCooldown') : t(health.label));
const subtitle = health.disabled ? scope : reasons.map((key) => t(key)).join(' · ');
const formatTimestamp = (value: string) => formatDate(value, {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
const summary = <>
<Icon size={16} className="auth-health-icon" aria-hidden="true" />
<span className="auth-health-summary-content">
<span className="auth-health-heading"><strong>{title}</strong>{subtitle ? <span>{subtitle}</span> : null}</span>
{separateState ? <span className="auth-health-hint">{t('authFiles.health.coreStatus', { status: t(health.label) })}</span> : null}
{health.message && !hasTimers && !health.label.startsWith('authFiles.health.reason.')
<span className="auth-health-heading"><strong title={compact && health.message ? health.message : undefined}>{title}</strong>{!compact && subtitle ? <span>{subtitle}</span> : null}</span>
{!compact && separateState ? <span className="auth-health-hint">{t('authFiles.health.coreStatus', { status: t(health.label) })}</span> : null}
{!compact && health.message && !hasTimers && !health.label.startsWith('authFiles.health.reason.')
? <span className="auth-health-message-preview">{health.message}</span> : null}
{cooldown.elapsed ? <span className="auth-health-hint">{t('authFiles.health.expiredHint')}</span> : null}
{snapshot?.records === null && !healthy ? <span className="auth-health-hint">{t('authFiles.health.unknownCooldown')}</span> : null}
{!compact && snapshot?.records === null && !healthy ? <span className="auth-health-hint">{t('authFiles.health.unknownCooldown')}</span> : null}
{snapshot === undefined && health.tone === 'error' ? <span className="auth-health-hint">{t('authFiles.health.missingCooldown')}</span> : null}
</span>
{cooldown.active.length > 0 ? <span className="auth-health-countdown">
{t('authFiles.health.earliest', { time: duration(cooldown.earliestSeconds) })}
</span> : null}
{hasDetails ? <span className="auth-health-disclosure"><span>{t('authFiles.health.details')}</span><ChevronDown size={14} aria-hidden="true" /></span> : null}
{hasDetails ? <span className="auth-health-disclosure">{!compact ? <span>{t('authFiles.health.details')}</span> : null}<ChevronDown size={14} aria-hidden="true" /></span> : null}
</>;
return (
<section className={`auth-file-health ${tone}${hasDetails ? ' detailed' : ''}`} aria-label={t('authFiles.health.title')}>
<section className={`auth-file-health ${tone}${hasDetails ? ' detailed' : ''}${compact ? ' auth-file-health-compact' : ''}`} aria-label={t('authFiles.health.title')}>
{hasDetails ? <details>
<summary className="auth-health-summary">{summary}</summary>
<div className="auth-health-body">
{hasTimers && !healthy ? <p className="auth-health-core-state">{t('authFiles.health.coreStatus', { status: t(health.label) })}</p> : null}
{health.message ? <div className="auth-health-core-message">
{hasTimers && !healthy && (!compact || separateState || health.disabled) ? <p className="auth-health-core-state">{t('authFiles.health.coreStatus', { status: t(health.label) })}</p> : null}
{health.message && (!compact || !health.label.startsWith('authFiles.health.reason.')) ? <div className="auth-health-core-message">
<span>{t('authFiles.health.coreMessage')}</span><p>{health.message}</p>
</div> : null}
<ul className="auth-health-records">
+119 -1
View File
@@ -61,7 +61,125 @@
}
@container credential-quota (max-width: 280px) {
.credential-quota-compact .credential-quota-rows {
.credential-quota-compact:not(.credential-quota-dense) .credential-quota-rows {
grid-template-columns: minmax(0, 1fr);
}
}
.credential-quota.credential-quota-dense {
gap: 6px;
line-height: 1.35;
}
.credential-quota-dense .credential-quota-rows {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px 12px;
}
.credential-quota-dense .credential-quota-row:only-child {
grid-column: 1 / -1;
}
.credential-quota-dense .credential-quota-row {
align-content: start;
gap: 6px;
}
.credential-quota-dense .credential-quota-label {
align-items: center;
gap: 5px;
min-height: 19px;
line-height: 1.2;
}
.credential-quota-dense .credential-quota-label span {
min-width: 0;
padding: 2px 5px;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
font-size: 10px;
font-weight: 550;
text-overflow: ellipsis;
white-space: nowrap;
}
.credential-quota-dense .credential-quota-label strong {
font-size: 12px;
line-height: 1;
}
.credential-quota-dense .credential-quota-track {
height: 5px;
}
.credential-quota-dense .credential-quota-row small {
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.4;
}
.credential-quota-dense .credential-quota-reset-time {
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.credential-quota-dense .credential-quota-heading {
gap: 4px 8px;
}
.credential-quota-dense .credential-quota-heading .credential-quota-reset {
min-height: 21px;
padding: 2px 6px;
margin: 0;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
}
.credential-quota-dense .credential-quota-reset-credits,
.credential-quota-dense .credential-quota-footnotes small {
font-size: 10px;
line-height: 1.4;
}
.credential-quota-dense .credential-quota-footnotes {
gap: 2px 8px;
}
.credential-quota-dense .credential-quota-empty,
.credential-quota-dense .credential-quota-loading,
.credential-quota-dense .credential-quota-error,
.credential-quota-dense .credential-quota-more > summary {
font-size: 11px;
line-height: 1.4;
}
.credential-quota-dense .credential-quota-loading {
gap: 5px;
}
.credential-quota-dense .credential-quota-error-details > summary {
width: fit-content;
cursor: pointer;
}
.credential-quota-dense .credential-quota-error-details > small {
display: block;
padding-top: 5px;
overflow-wrap: anywhere;
}
.credential-quota-dense .credential-quota-more[open] > summary {
margin-bottom: 6px;
}
@container credential-quota (max-width: 180px) {
.credential-quota-dense .credential-quota-rows {
grid-template-columns: minmax(0, 1fr);
}
}
+34 -16
View File
@@ -1,27 +1,40 @@
import { LoaderCircle, RefreshCw } from 'lucide-react';
import { useI18n } from '../i18n';
import { MessageNotice } from '../appNotice';
import { readBoolean } from '../services/managementApi';
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
import { canResetCodexQuota } from '../services/quotaActions';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
import './AuthFileQuotaPanel.css';
export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset, compact = false }: {
export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset, compact = false, dense = false }: {
quota: QuotaState;
file: AuthFile;
disabled: boolean;
onRefresh: () => void;
onReset?: () => void;
compact?: boolean;
dense?: boolean;
}) {
const { locale, t } = useI18n();
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
const loading = quota.status === 'loading';
const name = fileName(file);
const compactLayout = compact || dense;
const fileDisabled = readBoolean(file, 'disabled');
const showReset = Boolean(onReset && (quota.resetCredits ?? 0) > 0);
const resetCreditsLabel = quota.resetCredits !== undefined ? t('authFiles.quota.resets', { count: quota.resetCredits }) : '';
const denseMetadata = dense ? [
quota.resetCreditsApplicable !== undefined ? t('quota.resetApplicable', { count: quota.resetCreditsApplicable }) : '',
quota.subscriptionActiveUntil ? t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) }) : '',
quota.resetCreditsEarliestExpiry ? t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }) : '',
].filter(Boolean).join(' · ') : '';
const renderRow = (row: QuotaState['rows'][number], index: number) => {
const percent = row.remainingPercent !== null && Number.isFinite(row.remainingPercent) ? Math.max(0, Math.min(100, row.remainingPercent)) : null;
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
const resetText = dense && row.resetAtMs !== undefined && Number.isFinite(row.resetAtMs)
? new Intl.DateTimeFormat(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(row.resetAtMs)
: reset;
const tone = percent === null ? 'unknown' : percent <= 10 ? 'low' : percent <= 30 ? 'medium' : 'high';
return <div className={`credential-quota-row ${tone}`} key={`${row.label}-${index}`}>
<div className="credential-quota-label"><span title={row.label}>{row.label}</span><strong>{percent === null ? '—' : `${Math.round(percent)}%`}</strong></div>
@@ -29,39 +42,44 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset,
aria-valuemin={percent === null ? undefined : 0} aria-valuemax={percent === null ? undefined : 100} aria-valuenow={percent ?? undefined}>
{percent === null ? <span className="sr-only">{t('authFiles.settings.quotaUnknown')}</span> : <i style={{ width: `${percent}%` }} />}
</div>
{reset ? <small>{reset}</small> : null}
{row.detail ? <small>{row.detail}</small> : null}
{reset ? <small className={dense ? 'credential-quota-reset-time' : undefined} title={dense ? reset : undefined}>{resetText}</small> : null}
{row.detail ? <small className={dense ? 'credential-quota-row-detail' : undefined} title={dense ? row.detail : undefined}>{row.detail}</small> : null}
</div>;
};
return (
<section className={`credential-quota${compact ? ' credential-quota-compact' : ''}`} aria-label={t('authFiles.quota.aria')} aria-busy={loading}>
{!compact || showReset ? <div className="credential-quota-heading">
{!compact ? <strong>{t('authFiles.settings.quotaRemaining')}</strong> : null}
{!compact && quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
{showReset ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
{!compact ? <button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
<section className={`credential-quota${compactLayout ? ' credential-quota-compact' : ''}${dense ? ' credential-quota-dense' : ''}`} aria-label={t('authFiles.quota.aria')} aria-busy={loading} title={denseMetadata || undefined}>
{!compactLayout || showReset || (dense && resetCreditsLabel) ? <div className="credential-quota-heading">
{!compactLayout ? <strong>{t('authFiles.settings.quotaRemaining')}</strong> : null}
{!compactLayout && quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
{showReset ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)}
title={dense ? [t('quota.reset'), resetCreditsLabel, denseMetadata].filter(Boolean).join(' · ') : t('quota.reset')}>{dense ? resetCreditsLabel : t('quota.reset')}</button> : null}
{dense && !showReset && resetCreditsLabel ? <small className="credential-quota-reset-credits">{resetCreditsLabel}</small> : null}
{!compactLayout ? <button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
title={disabled ? t('quota.fileDisabled') : t('authFiles.quota.refresh')}>
{loading ? <LoaderCircle size={13} className="spin" /> : <RefreshCw size={13} />}
{t(loading ? 'authFiles.quota.querying' : quota.status === 'idle' ? 'authFiles.quota.fetch' : 'common.refresh')}
</button> : null}
</div> : null}
{loading ? <div className="credential-quota-loading" role="status"><span>{t(quota.pendingAction === 'reset' ? 'quota.resetting' : 'authFiles.quota.loading')}</span><div className="credential-quota-track indeterminate"><i /></div></div> : null}
{quota.status === 'idle' ? <p className="credential-quota-empty">{t(disabled ? 'quota.fileDisabled' : 'authFiles.settings.quotaIdle')}</p> : null}
{quota.status === 'error' ? <div className="credential-quota-error" role="status"><span>{t('authFiles.quota.failed')}</span>{quota.error ? <small>{quota.error}</small> : null}</div> : null}
{quota.status === 'idle' ? <p className="credential-quota-empty">{t(dense ? (fileDisabled ? 'quota.disabled' : 'quota.notFetched') : disabled ? 'quota.fileDisabled' : 'authFiles.settings.quotaIdle')}</p> : null}
{quota.status === 'error' ? <div className="credential-quota-error" role="status">
{dense && quota.error ? <details className="credential-quota-error-details"><summary title={quota.error}>{t('authFiles.quota.failed')}</summary><small>{quota.error}</small></details>
: <><span>{t('authFiles.quota.failed')}</span>{quota.error ? <small>{quota.error}</small> : null}</>}
</div> : null}
{quota.status === 'success' ? <>
{quota.rows.length ? <>
<div className="credential-quota-rows">{(compact ? quota.rows.slice(0, 2) : quota.rows).map(renderRow)}</div>
{compact && quota.rows.length > 2 ? <details className="credential-quota-more">
<div className="credential-quota-rows">{(compactLayout ? quota.rows.slice(0, 2) : quota.rows).map(renderRow)}</div>
{compactLayout && quota.rows.length > 2 ? <details className="credential-quota-more">
<summary>{t('authFiles.health.details')} (+{quota.rows.length - 2})</summary>
<div className="credential-quota-rows">{quota.rows.slice(2).map((row, index) => renderRow(row, index + 2))}</div>
</details> : null}
</> : <p className="credential-quota-empty">{t('authFiles.quota.empty')}</p>}
<div className="credential-quota-footnotes">
{quota.resetCredits !== undefined ? <small>{t('authFiles.quota.resets', { count: quota.resetCredits })}</small> : null}
{!dense ? <div className="credential-quota-footnotes">
{!dense && quota.resetCredits !== undefined ? <small>{resetCreditsLabel}</small> : null}
{quota.resetCreditsApplicable !== undefined ? <small>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</small> : null}
{quota.subscriptionActiveUntil ? <small>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</small> : null}
{quota.resetCreditsEarliestExpiry ? <small>{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</small> : null}
</div>
</div> : null}
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
</> : null}
</section>
+73
View File
@@ -136,3 +136,76 @@
.auth-file-requests-compact .auth-file-requests-tooltip {
max-width: min(280px, 80vw);
}
.auth-file-requests.auth-file-requests-summary {
grid-column: auto;
align-content: start;
gap: 5px;
width: 100%;
font-size: 11px;
line-height: 1.4;
}
.auth-file-requests-summary .auth-file-requests-heading {
justify-content: space-between;
align-items: baseline;
flex-wrap: nowrap;
gap: 8px;
}
.auth-file-requests-summary .auth-file-requests-heading > span:first-child {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
}
.auth-file-requests-summary .auth-file-requests-heading > .auth-file-requests-counts {
min-width: 0;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
font-weight: 550;
}
.auth-file-requests-summary .auth-file-requests-heading > .auth-file-requests-counts > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-file-requests-summary .auth-file-requests-timeline {
flex-wrap: nowrap;
gap: 8px;
}
.auth-file-requests-summary .auth-file-requests-chart {
flex: 1 1 0;
max-width: none;
}
.auth-file-requests-summary .auth-file-requests-blocks {
gap: 2px;
}
.auth-file-requests-summary .auth-file-request-block {
height: 14px;
}
.auth-file-requests-summary .auth-file-requests-rate {
flex: 0 0 42px;
font-size: 11px;
line-height: 1;
}
.auth-file-requests-summary .auth-file-requests-unavailable {
flex: 1 1 0;
min-width: 0;
}
.auth-file-requests-summary .auth-file-requests-tooltip {
max-width: min(300px, 80vw);
padding: 8px 10px;
font-size: 11px;
line-height: 1.5;
}
+22 -10
View File
@@ -3,7 +3,11 @@ import { useI18n } from '../i18n';
import { authFileRequestStats, requestRateColor } from '../services/authFileRequests';
import './AuthFileRequestStatus.css';
export function AuthFileRequestStatus({ file, compact = false }: { file: Record<string, unknown>; compact?: boolean }) {
export function AuthFileRequestStatus({ file, compact = false, summary = false }: {
file: Record<string, unknown>;
compact?: boolean;
summary?: boolean;
}) {
const { t, formatNumber } = useI18n();
const stats = useMemo(() => authFileRequestStats(file), [file]);
const [active, setActive] = useState<number | null>(null);
@@ -12,6 +16,9 @@ export function AuthFileRequestStatus({ file, compact = false }: { file: Record<
style: 'percent', maximumFractionDigits: 1,
});
const countText = (count: number | null) => count === null ? '—' : formatNumber(count);
const compactLayout = compact && !summary;
const successLabel = t('authFiles.requests.success', { count: countText(stats.success) });
const failureLabel = t('authFiles.requests.failure', { count: countText(stats.failure) });
const rateClass = stats.recentRate === null ? ''
: stats.recentRate >= 0.9 ? 'success' : stats.recentRate >= 0.5 ? 'mixed' : 'failure';
const detail = active === null ? null : stats.buckets[active];
@@ -25,17 +32,17 @@ export function AuthFileRequestStatus({ file, compact = false }: { file: Record<
};
return (
<div className={`auth-file-requests${compact ? ' auth-file-requests-compact' : ''}`} role="group" aria-label={t('authFiles.requests.title')}>
{compact ? <div className="auth-file-requests-latest" title={t('authFiles.requests.window')}>{latestRequest?.time || '—'}</div> : <div className="auth-file-requests-heading">
<div className={`auth-file-requests${compactLayout ? ' auth-file-requests-compact' : ''}${summary ? ' auth-file-requests-summary' : ''}`} role="group" aria-label={t('authFiles.requests.title')}>
{compactLayout ? <div className="auth-file-requests-latest" title={t('authFiles.requests.window')}>{latestRequest?.time || '—'}</div> : <div className="auth-file-requests-heading">
<span>{t('authFiles.requests.title')}</span>
<span className="auth-file-requests-counts" title={t('authFiles.requests.totalsHint')}>
<span className={stats.success ? 'success' : ''}>{t('authFiles.requests.success', { count: countText(stats.success) })}</span>
<span className={stats.failure ? 'failure' : ''}>{t('authFiles.requests.failure', { count: countText(stats.failure) })}</span>
<span className={stats.success ? 'success' : ''} title={summary ? successLabel : undefined}>{successLabel}</span>
<span className={stats.failure ? 'failure' : ''} title={summary ? failureLabel : undefined}>{failureLabel}</span>
</span>
{stats.recentAvailable ? <span className="auth-file-requests-window">{t('authFiles.requests.window')}</span> : null}
{stats.recentAvailable && !summary ? <span className="auth-file-requests-window">{t('authFiles.requests.window')}</span> : null}
</div>}
{stats.recentAvailable ? (
<div className="auth-file-requests-timeline">
<div className="auth-file-requests-timeline" title={summary ? t('authFiles.requests.window') : undefined}>
<div className="auth-file-requests-chart">
<div className="auth-file-requests-blocks" onMouseLeave={() => setActive(null)}>
{stats.buckets.map((bucket, index) => (
@@ -75,12 +82,17 @@ export function AuthFileRequestStatus({ file, compact = false }: { file: Record<
</div>
) : null}
</div>
{!compact ? <span className={`auth-file-requests-rate ${rateClass}`} title={t('authFiles.requests.recentRate')}>
{!compactLayout ? <span className={`auth-file-requests-rate ${rateClass}`} title={t('authFiles.requests.recentRate')}
aria-label={summary ? t('authFiles.requests.rate', { rate: rateText(stats.recentRate) }) : undefined}>
{rateText(stats.recentRate)}
</span> : null}
{stats.recentRate === null ? <span>{t('authFiles.requests.empty')}</span> : null}
{stats.recentRate === null && !summary ? <span>{t('authFiles.requests.empty')}</span> : null}
</div>
) : <span className="auth-file-requests-unavailable">{t('authFiles.requests.unavailable')}</span>}
) : summary ? <div className="auth-file-requests-timeline">
<span className="auth-file-requests-unavailable">{t('authFiles.requests.unavailable')}</span>
<span className="auth-file-requests-rate" title={t('authFiles.requests.recentRate')}
aria-label={t('authFiles.requests.rate', { rate: '—' })}>—</span>
</div> : <span className="auth-file-requests-unavailable">{t('authFiles.requests.unavailable')}</span>}
</div>
);
}
+3
View File
@@ -2,6 +2,7 @@ export const authFileListZhCN = {
'authFiles.list.credential': '凭证',
'authFiles.list.plan': '套餐',
'authFiles.list.status': '可用状态',
'authFiles.list.available': '可用',
'authFiles.list.recent': '最近请求',
'authFiles.list.quota': '额度',
'authFiles.list.actions': '操作',
@@ -27,6 +28,7 @@ export const authFileListEn: Record<keyof typeof authFileListZhCN, string> = {
'authFiles.list.credential': 'Credential',
'authFiles.list.plan': 'Plan',
'authFiles.list.status': 'Availability',
'authFiles.list.available': 'Available',
'authFiles.list.recent': 'Recent requests',
'authFiles.list.quota': 'Quota',
'authFiles.list.actions': 'Actions',
@@ -52,6 +54,7 @@ export const authFileListJa: Record<keyof typeof authFileListZhCN, string> = {
'authFiles.list.credential': '認証情報',
'authFiles.list.plan': 'プラン',
'authFiles.list.status': '利用状態',
'authFiles.list.available': '利用可能',
'authFiles.list.recent': '最近のリクエスト',
'authFiles.list.quota': 'クォータ',
'authFiles.list.actions': '操作',
+9
View File
@@ -1083,6 +1083,15 @@ export const jaOverrides = {
'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用',
'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用',
'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます',
'agents.clients.manage': 'クライアントを管理',
'agents.clients.description': '一覧に表示するクライアントを選択します。表示のみを変更し、クライアントの設定は変更しません。',
'agents.clients.search': 'クライアントを検索',
'agents.clients.shown': '{count} / {total} 件を表示',
'agents.clients.selected': '{count} 件のクライアントを選択中',
'agents.clients.automatic': '自動表示に戻す',
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを表示します。',
'agents.clients.minimum': '少なくとも 1 件のクライアントを表示してください。',
'agents.clients.noResults': '一致するクライアントが見つかりません',
'agents.list.detecting': '検出中',
'agents.list.unsupported': '現在のプラットフォームでは非対応',
'agents.list.notInstalled': 'インストールを検出できません',
+9
View File
@@ -1083,6 +1083,15 @@ export const en: Record<MessageKey, string> = {
'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog',
'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog',
'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider',
'agents.clients.manage': 'Manage Clients',
'agents.clients.description': 'Choose which clients appear in the list. This only changes visibility, not client settings.',
'agents.clients.search': 'Search clients',
'agents.clients.shown': 'Showing {count} / {total}',
'agents.clients.selected': '{count} clients selected',
'agents.clients.automatic': 'Restore automatic display',
'agents.clients.automaticHint': 'Installed or configured clients are shown by default.',
'agents.clients.minimum': 'Keep at least one client visible.',
'agents.clients.noResults': 'No matching clients found',
'agents.list.detecting': 'Detecting',
'agents.list.unsupported': 'Unsupported on this platform',
'agents.list.notInstalled': 'Installation not detected',
+9
View File
@@ -1082,6 +1082,15 @@ export const zhCN = {
'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录',
'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录',
'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI 提供方动态加载模型',
'agents.clients.manage': '管理客户端',
'agents.clients.description': '选择要显示在列表中的客户端,仅调整显示,不修改客户端配置。',
'agents.clients.search': '搜索客户端',
'agents.clients.shown': '显示 {count} / {total}',
'agents.clients.selected': '已选择 {count} 个客户端',
'agents.clients.automatic': '恢复自动显示',
'agents.clients.automaticHint': '默认显示已安装或已有配置的客户端。',
'agents.clients.minimum': '至少保留一个客户端。',
'agents.clients.noResults': '未找到匹配的客户端',
'agents.list.detecting': '正在检测',
'agents.list.unsupported': '当前平台不支持',
'agents.list.notInstalled': '未检测到安装',
+3
View File
@@ -3,6 +3,9 @@ import { createTraditionalMessages } from './traditional';
const messages = {
title: ['插件登录', 'Plugin sign-in', 'プラグインのログイン'],
providerTitle: ['{name} OAuth', '{name} OAuth', '{name} OAuth'],
providerHint: ['通过插件登录并保存账号凭据。', 'Sign in through this plugin and save account credentials.', 'プラグインでログインし、アカウントの認証情報を保存します。'],
loadFailed: ['无法加载插件登录入口,请刷新重试。', 'Could not load plugin sign-in options. Refresh to retry.', 'プラグインのログイン項目を読み込めませんでした。更新して再試行してください。'],
starting: ['正在获取登录链接…', 'Requesting a sign-in link…', 'ログインリンクを取得中…'],
waiting: ['等待完成授权…', 'Waiting for authorization…', '認証の完了を待っています…'],
success: ['授权完成,凭据已保存。', 'Authorization completed and credentials saved.', '認証が完了し、認証情報が保存されました。'],
+227
View File
@@ -0,0 +1,227 @@
.agent-client-list {
/* The configuration panel or shell sets the height, not the full catalog. */
contain: size;
}
.agents-page .agent-list-items {
gap: 4px;
}
.agents-page .agent-list-items button {
min-height: 56px;
padding: 8px;
gap: 8px;
}
.agents-page .agent-list-items button > span {
gap: 2px;
}
.agents-page .agent-list-items strong {
font-size: var(--font-size-body);
}
.agents-page .agent-list-items strong,
.agents-page .agent-list-items small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-client-list-footer {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
border-top: 1px solid var(--border-primary);
padding-top: 10px;
}
.agent-client-list-footer small,
.agent-client-manager small,
.agent-client-manager p,
.agent-client-manager footer > span {
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
line-height: 1.5;
}
.agent-client-manager {
box-sizing: border-box;
width: min(600px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 22px;
border: 1px solid var(--border-primary);
border-radius: 14px;
background: var(--theme-fffdf8);
color: var(--theme-2d2a26);
box-shadow: 0 24px 80px #0005;
overflow: auto;
}
.agent-client-manager[open] {
display: flex;
flex-direction: column;
gap: 14px;
}
.agent-client-manager::backdrop {
background: #0007;
}
.agent-client-manager header,
.agent-client-manager footer,
.agent-client-manager footer > div,
.agent-client-manager-defaults {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.agent-client-manager h2,
.agent-client-manager p {
margin: 0;
}
.agent-client-manager h2 {
font-size: var(--font-size-panel-title);
}
.agent-client-manager > p {
flex: none;
}
.agent-client-search {
display: flex;
flex: none;
align-items: center;
gap: 8px;
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 0 10px;
color: var(--theme-8a847c);
}
.agent-client-search:focus-within {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.agent-client-search input {
min-width: 0;
width: 100%;
height: 38px;
padding: 0;
border: 0;
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
}
.agent-client-catalog {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
gap: 8px;
min-height: 80px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 3px;
}
.agent-client-catalog > p {
grid-column: 1 / -1;
padding-block: 20px;
text-align: center;
}
.agent-client-option {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 10px;
border: 1px solid var(--border-primary);
border-radius: 8px;
cursor: pointer;
}
.agent-client-option:has(input:checked) {
border-color: var(--ui-accent);
background: var(--ui-accent-soft);
}
.agent-client-option:focus-within {
outline: 2px solid var(--ui-accent);
outline-offset: 1px;
}
.agent-client-option input {
flex: none;
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--ui-accent);
}
.agent-client-option .agent-client-icon {
flex: none;
}
.agent-client-option .provider-logo {
width: 24px;
height: 24px;
}
.agent-client-option-copy {
display: grid;
min-width: 0;
gap: 3px;
}
.agent-client-option-copy strong {
font-size: var(--font-size-body);
overflow-wrap: anywhere;
}
.agent-client-option-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-client-manager-defaults {
flex-wrap: wrap;
justify-content: flex-start;
}
.agent-client-manager footer {
flex-wrap: wrap;
border-top: 1px solid var(--border-primary);
padding-top: 14px;
}
.agent-client-manager footer > div {
margin-left: auto;
}
@media (max-width: 520px) {
.agent-client-manager {
padding: 16px;
}
.agent-client-catalog {
grid-template-columns: minmax(0, 1fr);
}
}
@container agent-workspace (max-width: 760px) {
.agents-page:not(.agents-page-embedded) .agent-client-list {
contain: none;
}
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
import { MessageNotice } from '../appNotice';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import { useI18n } from '../i18n';
const VISIBLE_CLIENTS_KEY = 'cpa-gui.agent-visible-clients.v1';
type Client<Id extends string> = {
id: Id;
name: string;
icon: ReactNode;
summary: string;
installed: boolean;
detected: boolean;
};
function readVisibleClients<Id extends string>(clients: Client<Id>[]): Id[] | null {
try {
const value: unknown = JSON.parse(window.localStorage.getItem(VISIBLE_CLIENTS_KEY) ?? 'null');
if (!Array.isArray(value)) return null;
const known = clients.filter((client) => value.includes(client.id)).map((client) => client.id);
return known.length ? known : null;
} catch {
return null;
}
}
export function AgentClientList<Id extends string>({
clients, selected, onSelect, onRefresh, loading, busy, error, onDismissError,
}: {
clients: Client<Id>[];
selected: Id;
onSelect: (id: Id) => void;
onRefresh: () => void;
loading: boolean;
busy: boolean;
error: string;
onDismissError: () => void;
}) {
const { t } = useI18n();
const [visibleIds, setVisibleIds] = useState(() => readVisibleClients(clients));
const [managing, setManaging] = useState(false);
const [draftIds, setDraftIds] = useState<Id[] | null>(null);
const [query, setQuery] = useState('');
// Keep the current client reachable even when detection fails or it was uninstalled.
const automaticIds = clients.filter((client) => client.detected || client.id === selected)
.map((client) => client.id);
const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
const draftSelection = draftIds ?? automaticIds;
const searchRef = useRef<HTMLInputElement>(null);
const titleId = useId();
const descriptionId = useId();
const dialogRef = useDialogFocusTrap<HTMLDialogElement>({
active: managing,
onEscape: () => setManaging(false),
initialFocusRef: searchRef,
});
useEffect(() => {
if (!busy && visibleIds && !visibleIds.includes(selected) && visibleIds.length) {
onSelect(visibleIds[0]);
}
}, [busy, onSelect, selected, visibleIds]);
useEffect(() => {
if (managing) dialogRef.current?.showModal();
}, [dialogRef, managing]);
const save = () => {
if (!draftSelection.length) return;
try {
if (draftIds === null) window.localStorage.removeItem(VISIBLE_CLIENTS_KEY);
else window.localStorage.setItem(VISIBLE_CLIENTS_KEY, JSON.stringify(draftIds));
} catch {
// Still allow a session-only preference when browser storage is unavailable.
}
setVisibleIds(draftIds);
if (!draftSelection.includes(selected)) {
const first = clients.find((client) => draftSelection.includes(client.id));
if (first) onSelect(first.id);
}
setManaging(false);
};
const matches = clients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase()));
return <>
<aside className="panel agent-client-list" aria-label={t('agents.localClients')}>
<div className="agent-client-list-heading">
<strong>{t('agents.localClients')}</strong>
<button type="button" className="icon-button quiet" onClick={onRefresh}
disabled={loading || busy} title={t('agents.redetect')} aria-label={t('agents.redetect')}>
<RefreshCw size={15} className={loading ? 'spin' : ''} aria-hidden="true" />
</button>
</div>
{error ? <MessageNotice message={error} onDismiss={onDismissError} /> : null}
<div className="agent-list-items">
{visibleClients.map((client) => <button type="button" key={client.id}
className={selected === client.id ? 'active' : ''} aria-pressed={selected === client.id}
onClick={() => onSelect(client.id)} disabled={busy}>
<span className="agent-client-icon">{client.icon}</span>
<span><strong title={client.name}>{client.name}</strong><small title={client.summary}>{client.summary}</small></span>
{client.installed ? <i className="agent-installed-indicator" title={t('agents.clientDetected')} aria-hidden="true" /> : null}
</button>)}
</div>
<div className="agent-client-list-footer">
<small>{t('agents.clients.shown', { count: visibleClients.length, total: clients.length })}</small>
<button type="button" className="secondary-button compact-button" disabled={busy || loading}
onClick={() => { setDraftIds(visibleIds); setQuery(''); setManaging(true); }}>
<SlidersHorizontal size={15} aria-hidden="true" />{t('agents.clients.manage')}
</button>
</div>
</aside>
{managing ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault(); setManaging(false); }}>
<header>
<h2 id={titleId}>{t('agents.clients.manage')}</h2>
<button type="button" className="icon-button quiet" aria-label={t('common.close')} onClick={() => setManaging(false)}>
<X size={18} aria-hidden="true" />
</button>
</header>
<p id={descriptionId}>{t('agents.clients.description')}</p>
<div className="agent-client-search">
<Search size={16} aria-hidden="true" />
<input ref={searchRef} type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)}
placeholder={t('agents.clients.search')} aria-label={t('agents.clients.search')} />
</div>
<div className="agent-client-catalog">
{matches.map((client) => <label key={client.id} className="agent-client-option">
<input type="checkbox" aria-label={client.name} checked={draftSelection.includes(client.id)}
onChange={(event) => setDraftIds(event.currentTarget.checked
? [...draftSelection, client.id] : draftSelection.filter((id) => id !== client.id))} />
<span className="agent-client-icon">{client.icon}</span>
<span className="agent-client-option-copy"><strong>{client.name}</strong><small title={client.summary}>{client.summary}</small></span>
</label>)}
{!matches.length ? <p role="status">{t('agents.clients.noResults')}</p> : null}
</div>
<div className="agent-client-manager-defaults">
<button type="button" className="secondary-button compact-button" onClick={() => setDraftIds(null)}>
{t('agents.clients.automatic')}
</button>
<small>{t('agents.clients.automaticHint')}</small>
</div>
<footer>
<span role="status">{t(draftSelection.length ? 'agents.clients.selected' : 'agents.clients.minimum', { count: draftSelection.length })}</span>
<div>
<button type="button" className="secondary-button" onClick={() => setManaging(false)}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={save} disabled={!draftSelection.length}>{t('common.save')}</button>
</div>
</footer>
</dialog> : null}
</>;
}
+14 -38
View File
@@ -70,6 +70,7 @@ import { CodexSessionsPanel } from './CodexSessionsPanel';
import { CodexModelCatalogDialog } from './CodexModelCatalogDialog';
import { DeepSeekHarnessCatalogDialog } from './DeepSeekHarnessCatalogDialog';
import { AgentConfigBackupDialog } from './AgentConfigBackupDialog';
import { AgentClientList } from './AgentClientList';
import { AgentConfigManagementPanel, AgentConfigurationFeedback, AgentRunControls } from './AgentControls';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
@@ -2029,44 +2030,19 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
) : null}
<div className="agent-workbench">
<aside className="panel agent-client-list" aria-label={t('agents.localClients')}>
<div className="agent-client-list-heading">
<strong>{t('agents.localClients')}</strong>
<button
type="button"
className="icon-button quiet"
onClick={() => void refresh()}
disabled={loading || busy}
title={t('agents.redetect')}
aria-label={t('agents.redetect')}
>
<RefreshCw size={15} className={loading ? 'spin' : ''} aria-hidden="true" />
</button>
</div>
{detectionError ? (
<MessageNotice message={detectionError} onDismiss={() => setDetectionError('')} />
) : null}
<div className="agent-list-items">
{agentDefinitions.map((agent) => {
const status = statuses.find((item) => item.id === agent.id);
return (
<button
type="button"
className={selected === agent.id ? 'active' : ''}
key={agent.id}
onClick={() => setSelected(agent.id)}
disabled={busy}
>
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
<span><strong title={agent.name}>{agent.name}</strong><small title={listStatusText(status)}>{listStatusText(status)}</small></span>
{status?.installed ? (
<i className="agent-installed-indicator" title={t('agents.clientDetected')} aria-hidden="true" />
) : null}
</button>
);
})}
</div>
</aside>
<AgentClientList clients={agentDefinitions.map((agent) => {
const status = statuses.find((item) => item.id === agent.id);
return {
id: agent.id,
name: agent.name,
icon: <AgentMark definition={agent} />,
summary: listStatusText(status),
installed: Boolean(status?.installed),
detected: Boolean(status && (status.installed || status.configExists || status.configured
|| (agent.id === 'pi' && status.pluginInstalled))),
};
})} selected={selected} onSelect={setSelected} onRefresh={() => void refresh()}
loading={loading} busy={busy} error={detectionError} onDismissError={() => setDetectionError('')} />
<section className="panel agent-config-panel">
{availableSubpages.length > 1 ? (
+157 -67
View File
@@ -1,61 +1,126 @@
.auth-files-panel { --auth-list-columns: minmax(180px, 1.2fr) 78px 100px 124px 148px minmax(230px, 1.8fr) 170px; }
.auth-file-table-scroll { overflow-x: auto; padding: 0 14px 14px; }
.auth-file-list-head, .auth-credential-row { display: grid; grid-template-columns: var(--auth-list-columns); align-items: center; gap: 16px; }
.auth-file-list-head { min-width: 1264px; padding: 14px 17px; color: var(--text-tertiary); font-size: var(--font-size-meta); font-weight: var(--font-weight-semibold); }
.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1264px; gap: 8px; }
.auth-file-card { display: flex; flex-direction: column; min-width: 0; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 9px; background: var(--bg-card); transition: border-color .15s; }
.auth-file-card:hover { border-color: color-mix(in srgb, var(--primary-color) 35%, var(--border-color)); }
.auth-file-card.is-disabled .auth-card-header .provider-logo { opacity: .65; }
.auth-credential-row { min-height: 104px; padding: 14px 0; }
.auth-files-page {
--auth-muted: #64748b;
--auth-text: #334155;
--auth-accent: #3b82f6;
--auth-border: #e9edf2;
--success-color: #16b653;
gap: 12px;
}
[data-theme="dark"] .auth-files-page {
--auth-muted: #94a3b8;
--auth-text: #e2e8f0;
--auth-accent: #70a7ff;
--auth-border: #293241;
--success-color: #34d399;
}
.auth-files-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; }
.auth-files-heading > div:first-child { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; }
.auth-files-heading h2 { margin: 0; color: var(--auth-text); font-size: 16px; font-weight: 700; }
.auth-files-heading p { margin: 0; color: var(--auth-muted); font-size: 12px; }
.auth-files-heading-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.auth-files-heading-actions > button { flex: none; width: auto; margin: 0; min-height: 32px; padding: 5px 10px; gap: 6px; border-radius: 7px; box-shadow: none; font-size: 12px; }
.auth-files-heading-actions > .auth-toolbar-quiet { border-color: transparent; background: transparent; color: var(--auth-muted); }
.auth-files-heading-actions > .auth-toolbar-quiet:hover:not(:disabled) { background: var(--bg-hover); color: var(--auth-text); }
.auth-files-page .auth-files-panel {
--auth-list-columns: minmax(160px, 1.15fr) 66px 88px 110px 152px minmax(220px, 3fr) 166px;
padding: 0;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.auth-files-page .auth-files-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 36px; margin-bottom: 4px; padding: 0; }
.auth-files-search { display: flex; align-items: center; gap: 8px; flex: 1 1 220px; min-width: 0; max-width: 380px; color: var(--auth-muted); background: var(--bg-card); border: 1px solid var(--auth-border); border-radius: 7px; padding: 0 10px; }
.auth-files-search:focus-within { border-color: var(--auth-accent); }
.auth-files-search > svg { flex: none; }
.auth-files-page .auth-files-search input { width: 100%; min-width: 0; height: 32px; padding: 0; border: 0; outline: 0; box-shadow: none; background: transparent; color: var(--auth-text); font: inherit; font-size: 12px; }
.auth-files-search input::placeholder { color: var(--auth-muted); }
.auth-files-filters { display: flex; align-items: center; gap: 8px; }
.auth-files-page .auth-files-toolbar .auth-files-filters select { width: auto; min-width: 0; max-width: 100%; height: 32px; padding: 0 8px; border: 1px solid var(--auth-border); border-radius: 7px; background: var(--bg-card); color: var(--auth-muted); font: inherit; font-size: 12px; }
.auth-files-page .auth-files-toolbar select:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 1px; }
.auth-file-table-scroll { min-width: 0; overflow-x: auto; padding: 0 0 2px; }
.auth-file-list-head, .auth-credential-row { display: grid; grid-template-columns: var(--auth-list-columns); align-items: center; column-gap: 10px; }
.auth-file-list-head { min-width: 1148px; min-height: 36px; padding: 2px 13px 0; color: var(--auth-muted); font-size: 12px; font-weight: 700; }
.auth-file-list-head > span { min-width: 0; line-height: 1.25; text-align: center; }
.auth-file-list-head > :first-child, .auth-file-list-head > :nth-child(6) { text-align: left; }
.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1148px; gap: 8px; }
.auth-file-card { min-width: 0; padding: 0 12px; border: 1px solid var(--auth-border); border-radius: 8px; background: var(--bg-card); }
.auth-file-card:hover { border-color: color-mix(in srgb, var(--auth-accent) 24%, var(--auth-border)); }
.auth-credential-row { min-height: 102px; padding: 10px 0; }
.auth-list-cell { min-width: 0; }
.auth-card-header { display: flex; gap: 10px; align-items: center; min-width: 0; }
.auth-card-header .provider-logo { width: 32px; height: 32px; object-fit: contain; flex-shrink: 0; }
.auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 6px; }
.auth-card-identity strong { font-size: var(--font-size-label); font-weight: var(--font-weight-semibold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-provider { font-size: var(--font-size-meta); color: var(--text-secondary); font-weight: var(--font-weight-medium); }
.auth-card-filename { color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-meta); }
.auth-list-plan, .auth-list-status { display: flex; flex-direction: column; align-items: flex-start; gap: 9px; font-size: var(--font-size-label); overflow-wrap: anywhere; }
.auth-list-plan > span { text-transform: capitalize; }
.auth-list-plan small, .auth-list-status small { color: var(--text-tertiary); font-size: var(--font-size-caption); }
.auth-status-badge { display: inline-flex; padding: 3px 7px; border-radius: 5px; background: var(--bg-secondary); color: var(--text-secondary); font-size: var(--font-size-meta); line-height: 1.5; font-weight: var(--font-weight-semibold); }
.auth-status-badge.success { color: var(--success-color); background: color-mix(in srgb, var(--success-color) 10%, var(--bg-card)); }
.auth-status-badge.warning { color: var(--amber-text); background: color-mix(in srgb, var(--amber-text) 10%, var(--bg-card)); }
.auth-status-badge.error { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 8%, var(--bg-card)); }
.auth-status-badge.info { color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-card)); }
.auth-list-muted { color: var(--text-tertiary); font-size: var(--font-size-meta); }
.auth-card-note { display: flex; align-items: baseline; gap: 8px; min-width: 0; padding: 8px 10px; border-radius: 7px; background: color-mix(in srgb, var(--text-tertiary) 5%, var(--bg-card)); font-size: var(--font-size-meta); line-height: var(--line-height-body); }
.auth-card-note > span { flex-shrink: 0; color: var(--text-tertiary); font-size: var(--font-size-caption); }
.auth-card-note p { min-width: 0; margin: 0; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
.auth-card-meta { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; }
.auth-card-meta strong { color: var(--text-secondary); font-weight: var(--font-weight-medium); }
.auth-card-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.auth-list-icon-actions { display: grid; grid-template-columns: repeat(3, 28px); gap: 5px; }
.auth-card-actions .auth-list-action { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 9%, var(--bg-card)); }
.auth-card-actions .auth-list-action.muted { color: var(--text-secondary); background: var(--bg-secondary); }
.auth-card-actions .auth-list-action.danger { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 8%, var(--bg-card)); }
.auth-card-header .provider-logo { width: 35px; height: 35px; object-fit: contain; flex-shrink: 0; }
[data-theme="dark"] .auth-card-header .provider-logo[data-provider="xai"] { filter: invert(1); }
.auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 5px; }
.auth-card-identity strong { color: var(--auth-text); font-size: 13px; font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-filename, .auth-card-provider { color: var(--auth-muted); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-list-plan, .auth-list-status { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; }
.auth-list-plan > span { max-width: 100%; color: var(--auth-text); font-size: 12px; font-weight: 600; text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-list-plan small, .auth-list-status small { color: var(--auth-muted); font-size: 11px; line-height: 1.25; }
.auth-list-status small { font-weight: 600; }
.auth-status-badge { display: block; max-width: 100%; padding: 4px 8px; border-radius: 7px; background: var(--bg-secondary); color: var(--auth-muted); font-size: 12px; line-height: 1.4; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-status-badge.success { color: var(--success-color); background: color-mix(in srgb, var(--success-color) 8%, var(--bg-card)); }
.auth-status-badge.warning { color: var(--amber-text); background: color-mix(in srgb, var(--amber-text) 8%, var(--bg-card)); }
.auth-status-badge.error { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 7%, var(--bg-card)); }
.auth-status-badge.info { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 8%, var(--bg-card)); }
.auth-list-muted { color: var(--auth-muted); font-size: 11px; }
.auth-list-recent .auth-file-requests { gap: 9px; }
.auth-list-recent .auth-file-requests-latest { color: var(--auth-muted); font-size: 11px; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.auth-list-recent .auth-file-requests-blocks { gap: 2px; padding: 0; }
.auth-list-recent .auth-file-request-block { height: 16px; border-radius: 2px; }
.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 10px; }
.auth-list-recent .auth-file-requests-unavailable { font-size: 10px; line-height: 1.4; text-align: center; color: var(--auth-muted); }
.auth-list-usage .auth-file-usage { color: var(--auth-text); }
.auth-list-usage .auth-file-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 8px; }
.auth-list-usage .auth-file-usage-metric { gap: 5px; }
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; border-radius: 4px; }
.auth-list-usage .auth-file-usage-metric.total svg { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 9%, var(--bg-card)); }
.auth-list-usage .auth-file-usage-metric strong { font-size: 12.75px; line-height: 1.2; font-weight: 750; }
.auth-list-usage .auth-file-usage-scope { margin-top: 6px; color: var(--auth-muted); text-align: right; font-size: 10px; line-height: 1.2; }
.auth-card-actions { display: grid; grid-template-columns: 98px 54px; align-items: center; justify-content: center; gap: 12px; }
.auth-list-icon-actions { display: grid; grid-template-columns: repeat(3, 30px); gap: 4px; }
.auth-card-actions .auth-list-action { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 30px; min-width: 0; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 8px; color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 10%, var(--bg-card)); cursor: pointer; }
.auth-card-actions .auth-list-action.muted { color: var(--auth-muted); background: var(--bg-secondary); }
.auth-card-actions .auth-list-action.danger { color: #ef4444; background: color-mix(in srgb, #ef4444 9%, var(--bg-card)); }
.auth-card-actions .auth-list-action:disabled { opacity: .4; cursor: default; }
.auth-card-actions .auth-list-action:not(:disabled):hover { filter: brightness(.93); }
.auth-list-main-actions { display: grid; justify-items: center; gap: 8px; }
.auth-list-switch { display: flex; align-items: center; width: 36px; height: 21px; padding: 2px; border: 1px solid var(--border-color); border-radius: 20px; background: var(--text-disabled); cursor: pointer; }
.auth-list-switch > span { display: block; width: 15px; height: 15px; flex-shrink: 0; border-radius: 50%; background: var(--bg-card); box-shadow: 0 1px 3px #0002; transition: transform .15s; }
.auth-list-switch[aria-checked="true"] { background: var(--primary-color); border-color: var(--primary-color); }
.auth-list-switch[aria-checked="true"] > span { transform: translateX(15px); background: #fff; }
.auth-card-actions .auth-list-action:not(:disabled):hover { border-color: currentColor; }
.auth-list-main-actions { display: grid; justify-items: center; align-items: center; grid-template-rows: repeat(2, 30px); gap: 4px; border-left: 1px solid var(--auth-border); }
.auth-list-switch { display: flex; align-items: center; flex: none; width: 38px; height: 22px; padding: 2px; border: 1px solid color-mix(in srgb, var(--auth-muted) 18%, var(--bg-card)); border-radius: 20px; background: color-mix(in srgb, var(--auth-muted) 24%, var(--bg-card)); cursor: pointer; }
.auth-list-switch > span { display: block; width: 16px; height: 16px; flex-shrink: 0; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; transition: transform .15s; }
.auth-list-switch[aria-checked="true"] { background: var(--auth-accent); border-color: var(--auth-accent); }
.auth-list-switch[aria-checked="true"] > span { transform: translateX(16px); }
.auth-list-switch:disabled { opacity: .5; cursor: default; }
.auth-list-details-button { min-height: 28px; padding: 4px 7px; border: 0; border-radius: 5px; background: color-mix(in srgb, var(--primary-color) 7%, var(--bg-card)); color: var(--primary-color); font: inherit; font-size: var(--font-size-caption); cursor: pointer; }
.auth-list-switch:focus-visible, .auth-list-details-button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 3px; }
.auth-list-details { border-top: 1px solid var(--border-color); padding: 12px 0; }
.auth-list-details .auth-card-note { margin-top: 10px; }
.auth-file-card > .auth-file-health { margin-bottom: 12px; }
.auth-file-card > .auth-file-health.detailed { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; }
.auth-file-card .auth-health-actions { padding: 6px 8px; border-top: 0; }
.auth-file-card .auth-health-actions .auth-health-hint { display: none; }
.auth-file-card .auth-file-health, .auth-file-card .auth-file-requests { grid-column: auto; }
.auth-list-details-button { width: 50px; min-height: 30px; padding: 4px; border: 0; border-radius: 8px; background: color-mix(in srgb, var(--auth-accent) 7%, var(--bg-card)); color: var(--auth-accent); font: inherit; font-size: 11px; font-weight: 650; cursor: pointer; }
.auth-list-details-button:hover { background: color-mix(in srgb, var(--auth-accent) 14%, var(--bg-card)); }
.auth-list-action:focus-visible, .auth-list-switch:focus-visible, .auth-list-details-button:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 3px; }
.auth-list-details { display: grid; gap: 12px; border-top: 1px solid var(--auth-border); padding: 12px 0; }
.auth-list-details[hidden] { display: none; }
.auth-list-details > .auth-file-requests-summary { max-width: 620px; }
.auth-card-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--auth-muted); font-size: 11px; overflow-wrap: anywhere; }
.auth-card-meta strong { flex-basis: 100%; color: var(--auth-text); font-weight: 500; }
.auth-card-note { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 12px; line-height: 1.5; }
.auth-card-note > span { flex-shrink: 0; color: var(--auth-muted); }
.auth-card-note p { min-width: 0; margin: 0; color: var(--auth-text); white-space: pre-wrap; overflow-wrap: anywhere; }
.auth-file-card .auth-health-body, .auth-file-card .auth-health-summary { overflow-wrap: anywhere; }
.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--border-color); color: var(--text-tertiary); font-size: var(--font-size-meta); }
.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 0 0; color: var(--auth-muted); font-size: 11px; }
.auth-list-pagination > span { margin-right: auto; }
.auth-list-pagination label { display: flex; align-items: center; gap: 6px; }
.auth-list-pagination select { min-height: 30px; border: 1px solid var(--auth-border); border-radius: 6px; background: var(--bg-card); color: var(--auth-muted); padding: 3px 6px; font: inherit; }
.auth-list-pagination .compact-button { min-height: 30px; padding: 4px 8px; margin: 0; font-size: 11px; }
.credential-quota { padding: 0 0 0 20px; border: 0; border-left: 1px solid var(--border-color); border-radius: 0; background: transparent; }
.auth-detail-panels .credential-quota-rows { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.auth-card-meta { display: flex; flex-wrap: wrap; gap: 8px 18px; padding-top: 14px; border-top: 1px solid var(--border-color); color: var(--auth-muted); font-size: 12px; overflow-wrap: anywhere; }
.auth-card-meta strong { flex-basis: 100%; color: var(--text-secondary); font-weight: 400; }
.auth-card-note { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 12px; line-height: 1.6; }
.auth-card-note > span { flex-shrink: 0; color: var(--auth-muted); }
.auth-card-note p { min-width: 0; margin: 0; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
.auth-file-card .auth-health-body, .auth-file-card .auth-health-summary { overflow-wrap: anywhere; }
.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--border-color); color: var(--auth-muted); font-size: 12px; }
.auth-list-pagination > span { margin-right: auto; }
.auth-list-pagination label { display: flex; align-items: center; gap: 8px; }
.auth-list-pagination select { min-height: 32px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-card); color: var(--text-secondary); padding: 4px 8px; font: inherit; }
.auth-list-pagination .compact-button { min-height: 32px; padding: 5px 9px; margin: 0; font-size: var(--font-size-meta); }
.auth-list-pagination select { min-height: 32px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-card); color: var(--auth-muted); padding: 4px 8px; font: inherit; }
.auth-list-pagination .compact-button { min-height: 32px; padding: 5px 9px; margin: 0; font-size: 12px; }
.credential-quota { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: color-mix(in srgb, var(--text-tertiary) 3%, var(--bg-card)); font-size: var(--font-size-meta); }
.credential-quota-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.credential-quota-heading > strong { color: var(--text-secondary); font-size: var(--font-size-meta); font-weight: var(--font-weight-medium); }
@@ -84,24 +149,49 @@
.credential-quota-track.indeterminate i { width: 35%; animation: credential-quota-pending 1.3s ease-in-out infinite alternate; }
@keyframes credential-quota-pending { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
@media (prefers-reduced-motion: reduce) { .credential-quota-track.indeterminate i { animation: none; width: 100%; opacity: .3; } }
@media (max-width: 760px) {
.auth-file-table-scroll { overflow: visible; padding: 10px; }
@container credential-files (min-width: 701px) and (max-width: 1147px) {
.auth-file-list-head, .auth-credential-row { grid-template-columns: minmax(140px, 1fr) 60px 78px 96px 136px minmax(180px, 2fr) 142px; column-gap: 8px; }
.auth-file-card-grid, .auth-file-list-head { min-width: 906px; }
.auth-card-actions { grid-template-columns: 86px 46px; gap: 8px; }
.auth-list-icon-actions { grid-template-columns: repeat(3, 26px); }
.auth-card-actions .auth-list-action { width: 26px; height: 28px; border-radius: 6px; }
.auth-list-main-actions { grid-template-rows: repeat(2, 28px); }
.auth-list-details-button { width: 42px; min-height: 28px; }
.auth-list-usage .auth-file-usage-metric strong { font-size: 12px; }
}
@container credential-files (max-width: 700px) {
.auth-file-table-scroll { overflow: visible; }
.auth-file-list-head { display: none; }
.auth-file-card-grid { min-width: 0; gap: 10px; }
.auth-file-card { padding: 0 12px; }
.auth-credential-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 14px 0; }
.auth-file-card > .auth-file-health.detailed { grid-template-columns: minmax(0, 1fr); }
.auth-card-header { grid-column: 1 / -1; }
.auth-list-cell[data-label]::before { content: attr(data-label); display: block; margin-bottom: 7px; color: var(--text-tertiary); font-size: var(--font-size-caption); font-weight: var(--font-weight-medium); }
.auth-list-plan, .auth-list-status { gap: 5px; }
.auth-list-plan::before, .auth-list-status::before { margin-bottom: 2px; }
.auth-list-quota { grid-column: 1 / -1; }
.auth-card-actions { grid-column: 1 / -1; padding-top: 10px; border-top: 1px solid var(--border-color); }
.auth-card-actions[data-label]::before { margin: 0 auto 0 0; }
.auth-list-icon-actions { display: flex; flex-wrap: wrap; }
.auth-list-main-actions { display: flex; }
.auth-list-pagination { gap: 8px; padding: 12px; }
.auth-file-card-grid { min-width: 0; margin-top: 10px; }
.auth-credential-row { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 10px; padding: 14px 0; }
.auth-card-header { grid-column: 1 / 3; }
.auth-list-plan { grid-column: 3; }
.auth-list-status { grid-column: 1; }
.auth-list-recent { grid-column: 2; }
.auth-list-usage { grid-column: 3; }
.auth-list-quota { grid-column: 1 / -1; border-top: 1px solid var(--auth-border); padding-top: 12px; }
.auth-card-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--auth-border); padding-top: 12px; }
.auth-list-main-actions { display: flex; gap: 8px; padding-left: 8px; }
.auth-list-icon-actions { display: flex; gap: 4px; }
.auth-list-usage .auth-file-usage-metrics { gap: 5px; }
.auth-list-usage .auth-file-usage-metric { gap: 3px; }
.auth-list-usage .auth-file-usage-metric strong { font-size: 11px; }
.auth-list-usage .auth-file-usage-metric svg { width: 10px; height: 10px; padding: 1px; }
.auth-list-cell[data-label]::before { content: attr(data-label); display: block; margin-bottom: 7px; color: var(--auth-muted); font-size: 10px; font-weight: 500; }
.auth-list-plan[data-label]::before, .auth-list-status[data-label]::before { margin: 0; }
.auth-card-actions[data-label]::before { flex: none; margin: 0 auto 0 0; white-space: nowrap; }
}
@container credential-files (max-width: 400px) {
.auth-files-search { flex-basis: 100%; max-width: none; }
.auth-files-filters { width: 100%; }
.auth-files-page .auth-files-toolbar .auth-files-filters select { flex: 1; width: 0; }
.auth-list-pagination > span { flex-basis: 100%; }
.auth-list-pagination label { margin-right: auto; }
}
@container workspace (max-width: 700px) {
.auth-files-heading { flex-wrap: wrap; }
.auth-files-heading-actions { margin-left: auto; }
}
@media (prefers-reduced-motion: reduce) { .auth-list-switch > span { transition: none; } }
+36 -29
View File
@@ -1,6 +1,5 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useConfirmation } from '../components/ConfirmationDialog';
import { OAuthPageToolbar } from '../components/OAuthPageToolbar';
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel';
import { AuthFileSettingsDialog } from '../components/AuthFileSettingsDialog';
@@ -18,7 +17,6 @@ import {
ChevronRight,
Copy,
FileDown,
Files,
FolderOpen,
Import,
LoaderCircle,
@@ -454,16 +452,14 @@ export function AuthFileManagementPage() {
return (
<section className="page management-page auth-files-page">
{confirmationDialog}
<OAuthPageToolbar icon={<Files size={18} />} summary={t('authFiles.summary', { files: files.length, disabled: disabledCount })}>
<button type="button" className="secondary-button compact-button oauth-toolbar-quiet" title={t('authFiles.models.globalButton')} onClick={() => {
<header className="auth-files-heading">
<div><h2>{t('authFiles.title')}</h2><p>{t('authFiles.summary', { files: files.length, disabled: disabledCount })}</p></div>
<div className="auth-files-heading-actions">
<button type="button" className="secondary-button compact-button auth-toolbar-quiet" disabled={loading || busy || oauthModelSaving || oauthModelProviders.length === 0} onClick={() => {
const provider = oauthModelProviders.find((item) => item.label === providerFilter) ?? oauthModelProviders[0];
if (provider) void openOauthModelSettings({ ...provider, scope: 'provider' });
}} disabled={loading || busy || oauthModelSaving || oauthModelProviders.length === 0}>
<Settings2 size={16} />{t('authFiles.models.toolbarButton')}
</button>
<button type="button" className="secondary-button compact-button oauth-toolbar-quiet" onClick={() => void openAuthFilesDirectory()} disabled={busy}>
<FolderOpen size={16} />{t('authFiles.openDirectory')}
</button>
}}><Settings2 size={15} />{t('authFiles.models.toolbarButton')}</button>
<button type="button" className="secondary-button compact-button auth-toolbar-quiet" disabled={busy} onClick={() => void openAuthFilesDirectory()}><FolderOpen size={15} />{t('authFiles.openDirectory')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
<RefreshCw size={16} className={loading ? 'spin' : ''} />{t('common.refresh')}
</button>
@@ -471,15 +467,18 @@ export function AuthFileManagementPage() {
<Import size={16} />{t('authFiles.import')}
</button>
<input ref={fileInputRef} type="file" accept=".json,application/json" multiple hidden onChange={(event) => void handleUpload(event)} />
</OAuthPageToolbar>
</div>
</header>
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
<section className="panel auth-files-panel real-auth-files-panel">
<div className="management-toolbar auth-files-toolbar">
<Search size={16} />
<div className="auth-files-toolbar">
<div className="auth-files-search"><Search size={16} aria-hidden="true" />
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('authFiles.searchPlaceholder')} aria-label={t('authFiles.searchPlaceholder')} />
</div>
<div className="auth-files-filters">
<select value={providerFilter} onChange={(event) => setProviderFilter(event.currentTarget.value)} aria-label={t('authFiles.filter.allProviders')}>
<option value="all">{t('authFiles.filter.allProviders')}</option>
{providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
@@ -490,6 +489,7 @@ export function AuthFileManagementPage() {
<option value="disabled">{t('authFiles.filter.disabled')}</option>
<option value="runtime">{t('authFiles.filter.runtime')}</option>
</select>
</div>
</div>
{loading ? (
@@ -499,9 +499,10 @@ export function AuthFileManagementPage() {
) : (
<div className="auth-file-table-scroll">
<div className="auth-file-list-head" aria-hidden="true">
<span>{t('authFiles.list.credential')}</span><span>{t('authFiles.list.plan')}</span>
<span>{t('authFiles.list.status')}</span><span>{t('authFiles.list.recent')}</span>
<span>{t('authFiles.usage.title')}</span><span>{t('authFiles.list.quota')}</span>
<span>{t('authFiles.list.credential')}</span>
<span>{t('authFiles.list.plan')}</span><span>{t('authFiles.list.status')}</span>
<span>{t('authFiles.list.recent')}</span><span title={t('authFiles.requests.totalsHint')}>{t('authFiles.usage.title')}</span>
<span>{t('authFiles.list.quota')}</span>
<span>{t('authFiles.list.actions')}</span>
</div>
<div className="auth-file-card-grid" role="list" aria-label={t('authFiles.title')}>
@@ -520,8 +521,10 @@ export function AuthFileManagementPage() {
const expiry = quota.subscriptionActiveUntil || metadata?.subscriptionActiveUntil;
const expiryMs = quotaResetInstant(expiry);
const health = authFileHealth(file);
const hasCooldown = Boolean(normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt)?.records?.length);
const statusLabel = hasCooldown && !health.disabled ? t('authFiles.list.cooling') : t(health.label);
const cooldownSnapshot = normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt);
const hasCooldown = Boolean(cooldownSnapshot?.records?.length);
const showCooldown = hasCooldown || cooldownSnapshot?.records === null;
const statusLabel = hasCooldown && !health.disabled ? t('authFiles.list.cooling') : t(health.label === 'authFiles.health.active' ? 'authFiles.list.available' : health.label);
const expanded = expandedFiles.has(key);
const detailsId = `auth-file-details-${encodeURIComponent(key)}`;
const cooldownResetIndex = authFileCooldownResetIndex(file);
@@ -530,7 +533,7 @@ export function AuthFileManagementPage() {
<article className={`auth-file-card ${disabled ? 'is-disabled' : ''}`} key={key} role="listitem" aria-label={identity || name}>
<div className="auth-credential-row">
<header className={`auth-card-header auth-list-cell ${identity ? '' : 'filename-only'}`}>
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<img src={icon} alt="" data-provider={providerKey(file)} className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div className="auth-card-identity">
<strong title={identity || name}>{identity || name}</strong>
<span className="auth-card-filename" title={name}>{identity ? name : providerName(file)}</span>
@@ -548,16 +551,16 @@ export function AuthFileManagementPage() {
<div className="auth-list-cell auth-list-recent" data-label={t('authFiles.list.recent')}><AuthFileRequestStatus file={file} compact /></div>
<div className="auth-list-cell auth-list-usage" data-label={t('authFiles.usage.title')}><AuthFileUsageSummary file={file} /></div>
<div className="auth-list-cell auth-list-quota" data-label={t('authFiles.list.quota')}>
{quotaProvider ? <AuthFileQuotaPanel compact quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProvider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : <span className="auth-list-muted">{t('authFiles.list.noQuota')}</span>}
{quotaProvider ? <AuthFileQuotaPanel compact dense quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProvider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : <span className="auth-list-muted">{t('authFiles.list.noQuota')}</span>}
<QuotaActionFeedback quota={quota} name={name} />
</div>
<footer className="auth-card-actions auth-list-cell" data-label={t('authFiles.list.actions')}>
<div className="auth-list-icon-actions">
<button type="button" className="icon-button auth-list-action" onClick={() => void refreshQuota(file)} disabled={busy || disabled || !quotaProvider || quota.status === 'loading'} title={t('authFiles.quota.refresh')} aria-label={t('authFiles.quota.refresh')}><RefreshCw size={15} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
<button type="button" className="icon-button auth-list-action" onClick={() => setSettingsName(name)} disabled={busy || resettingCooldown || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')} aria-label={t('authFiles.settings.title')}><Settings2 size={15} aria-hidden="true" /></button>
<button type="button" className="icon-button auth-list-action" onClick={() => setModelViewName(name)} disabled={busy || !providerKey(file)} title={t('authFiles.models.viewTitle')} aria-label={t('authFiles.models.viewTitle')}><Network size={15} aria-hidden="true" /></button>
<button type="button" className="icon-button auth-list-action muted" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')} aria-label={t('authFiles.copyName')}>{copied === name ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}</button>
<button type="button" className="icon-button auth-list-action danger" onClick={() => void deleteFile(file)} disabled={busy || resettingCooldown || isRuntimeOnly(file)} title={t('common.delete')} aria-label={t('common.delete')}><Trash2 size={15} aria-hidden="true" /></button>
<button type="button" className="auth-list-action" onClick={() => void refreshQuota(file)} disabled={busy || disabled || !quotaProvider || quota.status === 'loading'} title={t('authFiles.quota.refresh')} aria-label={t('authFiles.quota.refresh')}><RefreshCw size={15} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
<button type="button" className="auth-list-action" onClick={() => setSettingsName(name)} disabled={busy || resettingCooldown || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')} aria-label={t('authFiles.settings.title')}><Settings2 size={15} aria-hidden="true" /></button>
<button type="button" className="auth-list-action" onClick={() => setModelViewName(name)} disabled={busy || !providerKey(file)} title={t('authFiles.models.viewTitle')} aria-label={t('authFiles.models.viewTitle')}><Network size={15} aria-hidden="true" /></button>
<button type="button" className="auth-list-action muted" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')} aria-label={t('authFiles.copyName')}>{copied === name ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}</button>
<button type="button" className="auth-list-action danger" onClick={() => void deleteFile(file)} disabled={busy || resettingCooldown || isRuntimeOnly(file)} title={t('common.delete')} aria-label={t('common.delete')}><Trash2 size={15} aria-hidden="true" /></button>
</div>
<div className="auth-list-main-actions">
<button type="button" className="auth-list-switch" role="switch" aria-checked={!disabled} aria-label={`${t(disabled ? 'common.enable' : 'common.disable')} ${identity || name}`} onClick={() => void toggleStatus(file)} disabled={busy || resettingCooldown || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? disabled ? 'common.enable' : 'common.disable' : 'authFiles.fileOnly')}><span /></button>
@@ -565,12 +568,16 @@ export function AuthFileManagementPage() {
</div>
</footer>
</div>
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} observedAt={observedAt}
{showCooldown ? <AuthFileHealthStatus compact file={file} receivedAtMs={receivedAtMs} observedAt={observedAt}
resetting={resettingCooldown} resetDisabled={busy || loading}
onReset={cooldownResetIndex ? () => void resetCooldown(file) : undefined} />
onReset={cooldownResetIndex ? () => void resetCooldown(file) : undefined} /> : null}
<div className="auth-list-details" id={detailsId} hidden={!expanded}>
{!showCooldown ? <AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} observedAt={observedAt} /> : null}
<AuthFileRequestStatus file={file} summary />
<div className="auth-card-meta">
<strong>{name}</strong><span>{providerName(file)}</span>
<strong title={name}>{name}</strong>
<span>{t('authFiles.priority.button', { priority })}</span>
{expiryMs !== undefined ? <span>{t('authFiles.list.expiry', { time: formatDate(new Date(expiryMs).toISOString()) })}</span> : null}
<span>{t('authFiles.list.size')} · {readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
<span>{t('authFiles.list.updated')} · {formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
</div>
@@ -582,7 +589,7 @@ export function AuthFileManagementPage() {
</div>
</div>
)}
{!loading && visibleFiles.length > 0 ? <nav className="auth-list-pagination" aria-label={t('authFiles.title')}>
{!loading && visibleFiles.length > 0 && files.length > 10 ? <nav className="auth-list-pagination" aria-label={t('authFiles.title')}>
<span>{t('authFiles.list.pageSummary', { page: currentPage, pages: pageCount, start: (currentPage - 1) * pageSize + 1, end: Math.min(currentPage * pageSize, visibleFiles.length), total: visibleFiles.length })}</span>
<label>{t('authFiles.list.pageSize')}<select value={pageSize} onChange={(event) => setPageSize(Number(event.currentTarget.value))}>
{[10, 20, 50].map((size) => <option key={size} value={size}>{t('authFiles.list.perPage', { count: size })}</option>)}
+48 -36
View File
@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
gap: 22px;
gap: 18px;
min-width: 0;
}
@@ -16,10 +16,10 @@
.home-dashboard .home-top-grid > .panel {
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
min-width: 0;
margin: 0;
padding: 20px;
padding: 16px 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-card);
@@ -43,6 +43,8 @@
line-height: 1.4;
}
.home-dashboard .home-top-grid h2 { font-size: 18px; }
.home-dashboard .control-panel .panel-detail-grid {
display: flex;
flex-direction: column;
@@ -61,8 +63,8 @@
justify-content: space-between;
gap: 16px;
min-width: 0;
min-height: 30px;
padding: 5px 0;
min-height: 32px;
padding: 4px 0;
border: 0;
border-bottom: 1px solid var(--border-secondary);
}
@@ -71,16 +73,16 @@
.home-dashboard .control-panel .panel-detail-row dt {
color: var(--text-tertiary);
font-size: 12px;
line-height: 20px;
font-size: 16px;
line-height: 1.5;
}
.home-dashboard .control-panel .panel-detail-row dd {
margin: 0;
color: var(--text-primary);
font-size: 12px;
font-size: 16px;
font-weight: 500;
line-height: 20px;
line-height: 1.5;
white-space: normal;
overflow-wrap: anywhere;
}
@@ -90,7 +92,7 @@
border: 0;
border-radius: 0;
background: none;
font-size: 12px;
font-size: inherit;
overflow-wrap: anywhere;
}
@@ -107,22 +109,27 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
gap: 8px;
min-width: 0;
min-height: 34px;
padding: 6px 8px;
font-size: 12px;
min-height: 38px;
padding: 6px 10px;
font-size: 16px;
line-height: 1.5;
white-space: normal;
overflow-wrap: anywhere;
}
.home-dashboard .control-action-row > button svg { width: 18px; height: 18px; flex: none; }
.home-runtime-state { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; color: var(--text-tertiary); }
.home-runtime-state.success { color: var(--success-color); }
.home-runtime-state.error { color: var(--error-color); }
.home-runtime-state.pending { color: var(--amber-text); }
.home-runtime-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: currentColor; }
.home-runtime-error { margin: 0; color: var(--error-color); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.home-runtime-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; }
.home-runtime-error { margin: 0; color: var(--error-color); font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
.home-dashboard .control-action-row .home-runtime-stop { color: var(--text-secondary); }
.home-dashboard .control-action-row .home-runtime-stop:hover:not(:disabled) { color: var(--error-color); border-color: color-mix(in srgb, var(--error-color) 35%, var(--border-color)); background: var(--bg-error-light); }
.home-dashboard .home-runtime-refresh { width: 28px; height: 28px; min-width: 28px; border-color: transparent; background: transparent; }
.home-dashboard .home-runtime-refresh { width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; }
.home-dashboard .home-runtime-refresh svg { width: 18px; height: 18px; }
.home-panel-heading,
.home-overview-heading {
@@ -133,10 +140,11 @@
}
.home-panel-heading > div { min-width: 0; }
.home-panel-heading { flex-wrap: wrap; }
.home-panel-heading p {
margin: 5px 0 0;
color: var(--text-tertiary);
font-size: 12px;
font-size: 14px;
line-height: 1.5;
}
@@ -146,9 +154,10 @@
align-items: center;
gap: 6px;
color: var(--text-tertiary);
font-size: 12px;
font-size: 14px;
line-height: 1.5;
}
.home-connection-state > span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.home-connection-state > span { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; }
.home-connection-state.ready { color: var(--success-color, #15986b); }
.home-protocol-tabs {
@@ -165,18 +174,19 @@
flex: 1;
min-width: 0;
margin: 0;
padding: 9px 4px 11px;
padding: 6px 4px 8px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--text-tertiary);
font-size: 13px;
font-size: 16px;
font-weight: 500;
line-height: 1.5;
}
.home-protocol-tabs button:hover { color: var(--text-primary); background: transparent; }
.home-protocol-tabs button.active { color: var(--ui-accent); border-bottom-color: var(--ui-accent); font-weight: 600; }
.home-protocol-tabs img { width: 18px; height: 18px; object-fit: contain; }
.home-protocol-tabs img { width: 20px; height: 20px; flex: none; object-fit: contain; }
:root[data-theme='dark'] #home-protocol-openai img { filter: invert(1); }
.home-dashboard .home-access-panel .home-connection-details.client-api-card,
@@ -201,10 +211,10 @@
gap: 4px 12px;
margin-bottom: 8px;
color: var(--text-secondary);
font-size: 12px;
font-size: 16px;
line-height: 1.5;
}
.home-field-label > span + span { color: var(--text-tertiary); font-size: 11px; }
.home-field-label > span + span { color: var(--text-tertiary); font-size: 14px; }
.home-copy-field {
display: flex;
align-items: center;
@@ -222,13 +232,14 @@
overflow-wrap: anywhere;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
font-size: 16px;
line-height: 1.5;
}
.home-dashboard .home-copy-field > button { flex: none; width: 30px; height: 30px; min-width: 30px; border-color: transparent; background: transparent; }
.home-dashboard .home-copy-field > button { flex: none; width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; }
.home-dashboard .home-copy-field > button svg { width: 18px; height: 18px; }
.home-copy-field .home-key-value { color: var(--text-secondary); }
.home-field-error { color: var(--failure-badge-text); font-size: 12px; }
.home-field-empty { color: var(--text-tertiary); font-size: 12px; }
.home-field-error { color: var(--failure-badge-text); font-size: 14px; line-height: 1.5; }
.home-field-empty { color: var(--text-tertiary); font-size: 14px; line-height: 1.5; }
.home-inline-link {
display: inline-flex;
align-items: center;
@@ -237,7 +248,8 @@
border: 0;
background: transparent;
color: var(--ui-accent);
font-size: 11px;
font-size: 14px;
line-height: 1.5;
}
.home-overview-heading { flex-wrap: wrap; margin-bottom: 14px; }
@@ -276,15 +288,15 @@
.content:has(> .home-dashboard) {
display: flex;
min-height: 100dvh;
padding-block: 32px;
padding-block: 24px;
}
.content > .home-page.home-dashboard { flex: 1; }
.home-dashboard .home-top-grid { flex: 1 0 auto; }
.home-dashboard .home-top-grid { flex: 0 0 auto; }
.home-dashboard .control-panel .panel-detail-grid { flex: 1; }
.home-dashboard .control-panel .panel-detail-row { flex: 1; }
.home-dashboard .home-access-panel { justify-content: space-between; }
.home-dashboard .home-overview { display: flex; flex-direction: column; flex: 1 0 auto; }
.home-dashboard .home-overview { display: flex; flex-direction: column; flex: 0 0 auto; }
.home-dashboard .home-overview-heading { flex: none; }
.home-dashboard .home-stat-grid { flex: 1; }
}
@@ -303,7 +315,7 @@
}
@container workspace (max-width: 800px) {
.home-top-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.home-dashboard .home-top-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.home-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -321,7 +333,7 @@
}
@media (max-width: 380px) {
.home-protocol-tabs button { gap: 5px; padding-inline: 4px; font-size: 12px; }
.home-protocol-tabs { gap: 4px; }
.home-protocol-tabs button { gap: 5px; padding-inline: 0; }
.home-stat-card { padding: 12px; }
.home-panel-heading { flex-wrap: wrap; }
}
+10
View File
@@ -31,6 +31,8 @@ import {
import { AuthFileManagementPage } from './AuthFileManagementPage';
import { validateDevinCallback } from '../services/devinOAuth';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
import { PluginOAuthProviders } from './PluginOAuthProviders';
import { notifyPluginResourcesChanged } from '../services/pluginResources';
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin' | 'meta';
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
@@ -86,6 +88,7 @@ const OAUTH_CALLBACK_SUPPORTED = new Set<OAuthProviderId>([
'xai',
'devin',
]);
const BUILTIN_OAUTH_PROVIDER_IDS = oauthProviders.map(provider => provider.id);
const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
const OAUTH_POLL_INTERVAL_MS = 3000;
const OAUTH_BROWSER_STORAGE_KEY = 'easy-cli-proxy-api.oauth-browser.v3';
@@ -535,6 +538,9 @@ export function OAuthLoginPage() {
<option value={NO_AUTO_OPEN_BROWSER_ID}>{t('oauth.browser.noAutoOpen')}</option>
</select>
</label>
<button type="button" className="secondary-button" onClick={notifyPluginResourcesChanged}>
<RefreshCw size={16} aria-hidden="true" />{t('common.refresh')}
</button>
</header>
<FloatingNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} />
@@ -625,6 +631,10 @@ export function OAuthLoginPage() {
</section>
);
})}
<PluginOAuthProviders
builtInProviderIds={BUILTIN_OAUTH_PROVIDER_IDS}
browser={selectedBrowser === NO_AUTO_OPEN_BROWSER_ID ? 'default' : selectedBrowser || 'default'}
/>
</div>
</section>
);
+3 -2
View File
@@ -17,8 +17,9 @@ const MAX_SESSION_MS = 30 * 60 * 1000;
function message(error: unknown) { return error instanceof Error ? error.message : String(error); }
export function PluginOAuthDialog({ plugin, onClose, onCompleted }: {
export function PluginOAuthDialog({ plugin, browser = 'default', onClose, onCompleted }: {
plugin: PluginListEntry;
browser?: string;
onClose: () => void;
onCompleted: () => void;
}) {
@@ -128,7 +129,7 @@ export function PluginOAuthDialog({ plugin, onClose, onCompleted }: {
if (!session || !activeSession() || openPending.current) return;
const current = generation.current;
openPending.current = true; setOpening(true); setError('');
try { await invoke('open_oauth_url', { url: session.url, browser: 'default' }); }
try { await invoke('open_oauth_url', { url: session.url, browser }); }
catch (reason) { if (generation.current === current && phaseRef.current === 'waiting') setError(message(reason)); }
finally { if (generation.current === current) { openPending.current = false; setOpening(false); } }
};
+12
View File
@@ -0,0 +1,12 @@
.plugin-oauth-provider-status {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
color: var(--text-secondary);
}
.plugin-oauth-provider-status > span {
flex: 1 1 240px;
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { LogIn, Puzzle, RefreshCw } from 'lucide-react';
import { useI18n } from '../i18n';
import { pluginOAuthText } from '../i18n/pluginOAuth';
import { pluginsApi, type PluginListEntry } from '../services/plugins';
import { collectPluginOAuthProviders } from '../services/pluginOAuthProviders';
import { getPluginTitle, PLUGIN_RESOURCES_REFRESH_EVENT } from '../services/pluginResources';
import { PluginOAuthDialog } from './PluginOAuthDialog';
import './PluginOAuthProviders.css';
export function PluginOAuthProviders({ builtInProviderIds, browser = 'default' }: {
builtInProviderIds: readonly string[];
browser?: string;
}) {
const { t, locale } = useI18n();
const [plugins, setPlugins] = useState<PluginListEntry[]>([]);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [completed, setCompleted] = useState<Set<string>>(() => new Set());
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const revision = useRef(0);
const mounted = useRef(false);
const retryTimer = useRef<number | undefined>(undefined);
const load = useCallback(() => {
window.clearTimeout(retryTimer.current);
const current = ++revision.current;
setLoading(true);
setError(false);
const active = () => mounted.current && current === revision.current;
const refresh = async (attempt: number) => {
try {
if (attempt === 0) {
const supported = await invoke<boolean>('get_plugin_support');
if (!active()) return;
if (!supported) {
setPlugins([]);
setSelectedProvider(null);
return;
}
}
const response = await pluginsApi.list();
if (!active()) return;
const next = response.pluginsEnabled
? collectPluginOAuthProviders(response.plugins, builtInProviderIds) : [];
setPlugins(next);
setSelectedProvider(previous => next.some(plugin => plugin.oauthProvider === previous) ? previous : null);
// Installation can finish before the core registers the plugin. Keep following
// that transition even if the user has already left the plugin manager.
if (attempt < 8 && response.pluginsEnabled
&& response.plugins.some(plugin => plugin.path && plugin.enabled && !plugin.effectiveEnabled)) {
retryTimer.current = window.setTimeout(() => {
if (active()) void refresh(attempt + 1);
}, 1500);
}
} catch {
if (active()) {
setPlugins([]);
setSelectedProvider(null);
setError(true);
}
} finally {
if (active()) setLoading(false);
}
};
return refresh(0);
}, [builtInProviderIds]);
useEffect(() => {
mounted.current = true;
const refresh = () => { void load(); };
refresh();
window.addEventListener('focus', refresh);
window.addEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, refresh);
return () => {
mounted.current = false;
++revision.current;
window.clearTimeout(retryTimer.current);
window.removeEventListener('focus', refresh);
window.removeEventListener(PLUGIN_RESOURCES_REFRESH_EVENT, refresh);
};
}, [load]);
const selectedPlugin = plugins.find(plugin => plugin.oauthProvider === selectedProvider);
return <>
{error && <div className="plugin-oauth-provider-status" role="alert">
<span>{pluginOAuthText('loadFailed', locale)}</span>
<button type="button" className="secondary-button" disabled={loading} onClick={() => void load()}>
<RefreshCw size={16} aria-hidden="true" />{t('common.refresh')}
</button>
</div>}
{plugins.map(plugin => {
const provider = plugin.oauthProvider!;
const authorized = completed.has(provider);
return <section className="panel oauth-card" key={provider}>
<div className="provider-title-row">
<Puzzle className="provider-logo" size={40} aria-hidden="true" />
<div>
<h2>{pluginOAuthText('providerTitle', locale).replace('{name}', getPluginTitle(plugin))}</h2>
{authorized && <span className="state-pill success">{t('oauth.status.completed')}</span>}
</div>
</div>
<div className="oauth-card-body"><p className="oauth-hint">{pluginOAuthText('providerHint', locale)}</p></div>
<div className="button-row management-card-actions">
<button type="button" className="primary-button" onClick={() => setSelectedProvider(provider)}>
<LogIn size={16} aria-hidden="true" />{t(authorized ? 'oauth.loginAnother' : 'oauth.startLogin')}
</button>
</div>
</section>;
})}
{selectedPlugin && <PluginOAuthDialog
key={selectedPlugin.oauthProvider}
plugin={selectedPlugin}
browser={browser}
onClose={() => setSelectedProvider(null)}
onCompleted={() => setCompleted(previous => new Set(previous).add(selectedPlugin.oauthProvider!))}
/>}
</>;
}
+3 -1
View File
@@ -6,7 +6,7 @@ import { pluginText } from '../i18n/plugins';
import { FloatingNotice, useAppNotice } from '../appNotice';
import { useConfirmation } from '../components/ConfirmationDialog';
import { pluginsApi, pluginStoreApi, type PluginListEntry, type PluginListResponse, type PluginStoreEntry, type PluginStoreResponse } from '../services/plugins';
import { collectPluginResourceEntries, getPluginTitle, isOfficialPlugin, type PluginResourceEntry } from '../services/pluginResources';
import { collectPluginResourceEntries, getPluginTitle, isOfficialPlugin, notifyPluginResourcesChanged, type PluginResourceEntry } from '../services/pluginResources';
import { getPluginStatus, isPluginInstalled } from '../services/pluginStatus';
import { PluginConfigDialog } from './PluginConfigDialog';
import { PluginInstallDialog } from './PluginInstallDialog';
@@ -54,6 +54,7 @@ export function PluginsPage() {
const next = await pluginsApi.list();
if (!mounted.current || revision !== requests.current) return;
setData(next);
notifyPluginResourcesChanged();
setResource(previous => previous && collectPluginResourceEntries(next.plugins).some(entry => entry.pluginID === previous.entry.pluginID && entry.menuIndex === previous.entry.menuIndex && entry.menu.path === previous.entry.menu.path) ? previous : null);
if (target === 'store') {
const nextStore = await pluginStoreApi.list();
@@ -80,6 +81,7 @@ export function PluginsPage() {
const next = await pluginsApi.list();
if (cancelled || requests.current !== revision) return;
setData(next);
notifyPluginResourcesChanged();
} catch { /* The manual refresh exposes errors without replacing action feedback. */ }
if (!cancelled && ++attempts < 8) timer = setTimeout(() => void poll(), 1500);
};
+10 -84
View File
@@ -25,88 +25,37 @@
overflow: hidden;
}
.usage-request-log .usage-events-summary {
min-height: 88px;
flex: none;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
padding: 19px 20px 17px;
border-bottom: 1px solid var(--usage-log-line);
background: var(--usage-log-surface);
color: var(--usage-log-text);
}
.usage-request-log .usage-events-heading {
min-width: 0;
flex: 1 1 240px;
}
.usage-request-log .usage-events-title-row {
.usage-request-log .usage-events-actions {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.usage-request-log .usage-events-title-row h2 {
margin: 0;
color: var(--usage-log-text);
font-size: 18px;
font-weight: 650;
line-height: 1.4;
letter-spacing: -.02em;
}
.usage-request-log .usage-events-heading p {
margin: 9px 0 0;
color: var(--usage-log-muted);
font-size: 12px;
line-height: 1.5;
}
.usage-request-log .usage-events-count-badge {
padding: 3px 9px;
border: 1px solid var(--usage-log-line);
border-radius: 999px;
background: var(--usage-log-soft);
color: var(--usage-log-text);
font-size: 11px;
font-weight: 600;
line-height: 1.45;
}
.usage-request-log .usage-events-summary-right {
width: auto;
margin-left: auto;
justify-content: flex-end;
gap: 10px;
gap: 6px;
}
.usage-request-log .usage-col-settings-btn,
.usage-request-log .usage-col-reset-btn,
.usage-request-log .usage-events-export-btn {
display: inline-flex;
height: 40px;
height: 32px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 14px;
gap: 6px;
padding: 0 10px;
border: 1px solid var(--usage-log-line);
border-radius: 999px;
background: var(--usage-log-surface);
color: var(--usage-log-text);
box-shadow: 0 2px 3px rgb(0 0 0 / 3%), 0 7px 16px rgb(0 0 0 / 4%);
font-size: 12px;
font-weight: 600;
color: var(--usage-log-muted);
box-shadow: none;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.usage-request-log .usage-col-reset-btn.icon-only {
width: 40px;
width: 32px;
padding: 0;
}
@@ -426,10 +375,6 @@
}
@container workspace (max-width: 760px) {
.usage-request-log .usage-events-summary {
padding: 17px 16px;
}
.usage-request-log .usage-events-footer {
padding: 12px 14px;
}
@@ -444,25 +389,6 @@
}
@container workspace (max-width: 480px) {
.usage-request-log .usage-events-summary {
gap: 13px;
}
.usage-request-log .usage-events-heading {
flex-basis: 100%;
}
.usage-request-log .usage-events-summary-right {
width: auto;
margin-left: 0;
justify-content: flex-start;
}
.usage-request-log .usage-col-settings-btn,
.usage-request-log .usage-events-export-btn {
height: 34px;
}
.usage-request-log .usage-events-footer {
justify-content: flex-start;
}
+27 -38
View File
@@ -612,44 +612,7 @@ export function EventsView({
};
return (
<section className="panel usage-events-panel usage-request-log" aria-busy={loading}>
<div className="usage-events-summary">
<div className="usage-events-heading">
<div className="usage-events-title-row">
<h2>{t('usage.events.title')}</h2>
<span className="usage-events-count-badge">{t('usage.events.total', { count: loading ? '—' : compactNumber(events.total) })}</span>
</div>
<p>{t('usage.events.description')}</p>
</div>
<div className="usage-events-summary-right">
<button
type="button"
className="usage-col-settings-btn"
onClick={openColumnSettings}
title={t('usage.events.columnSettings')}
aria-label={t('usage.events.columnSettings')}
>
<Columns3Cog size={14} />
<span>{t('usage.events.columns')}</span>
</button>
{isCustomized ? (
<button
type="button"
className="usage-col-reset-btn icon-only"
onClick={resetAllWidths}
title={t('usage.events.resetColumns')}
aria-label={t('usage.events.resetColumns')}
>
<RotateCcw size={13} />
</button>
) : null}
<button type="button" className="usage-events-export-btn" disabled={loading || events.items.length === 0} onClick={exportCurrentPage} title={t('usage.events.exportDescription')}>
<Download size={14} aria-hidden="true" /><span>{t('usage.events.exportPage')}</span>
</button>
</div>
</div>
<section className="panel usage-events-panel usage-request-log" aria-label={t('usage.events.title')} aria-busy={loading}>
{loading ? <div className="usage-empty" role="status"><Database size={20} aria-hidden="true" /><span>{t('usage.loading')}</span></div> : events.items.length ? (
<div ref={tableWrapRef} className="usage-table-wrap" tabIndex={0} role="region" aria-label={t('usage.events.title')}>
<table
@@ -717,6 +680,32 @@ export function EventsView({
<div className="usage-events-footer">
<span className="usage-pagination-summary">{t('usage.events.rangeSummary', { start: startRecordNum, end: endRecordNum, total: compactNumber(events.total) })}</span>
<div className="usage-events-actions">
<button
type="button"
className="usage-col-settings-btn"
onClick={openColumnSettings}
title={t('usage.events.columnSettings')}
aria-label={t('usage.events.columnSettings')}
>
<Columns3Cog size={14} aria-hidden="true" />
<span>{t('usage.events.columns')}</span>
</button>
{isCustomized ? (
<button
type="button"
className="usage-col-reset-btn icon-only"
onClick={resetAllWidths}
title={t('usage.events.resetColumns')}
aria-label={t('usage.events.resetColumns')}
>
<RotateCcw size={13} aria-hidden="true" />
</button>
) : null}
<button type="button" className="usage-events-export-btn" disabled={loading || events.items.length === 0} onClick={exportCurrentPage} title={t('usage.events.exportDescription')}>
<Download size={14} aria-hidden="true" /><span>{t('usage.events.exportPage')}</span>
</button>
</div>
<div className="usage-pagination-controls">
<select className="usage-page-size-select" value={pageSize} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.currentTarget.value))} aria-label={t('usage.events.pageSize', { size: pageSize })}>
{[20, 50, 100, 200].map((size) => <option key={size} value={size}>{t('usage.events.pageSize', { size })}</option>)}
+20 -4
View File
@@ -187,8 +187,24 @@ export const dedupeAuthFiles = (files: AuthFileRecord[]) => {
grouped.set(key, entries);
});
return Array.from(grouped.values())
.map(mergeDuplicateAuthFiles)
.sort((left, right) =>
authFileName(left).localeCompare(authFileName(right), undefined, { sensitivity: 'base' }),
);
.flatMap((entries) => {
const byAuthIndex = new Map<string | undefined, AuthFileRecord[]>();
entries.forEach((entry) => {
const authIndex = authFileCooldownResetIndex(entry);
const matches = byAuthIndex.get(authIndex) ?? [];
matches.push(entry);
byAuthIndex.set(authIndex, matches);
});
const knownIndexCount = byAuthIndex.size - (byAuthIndex.has(undefined) ? 1 : 0);
// Legacy disk entries can enrich one indexed runtime record. Conflicting
// indexes identify different credentials; unindexed entries stay separate.
return knownIndexCount <= 1
? [mergeDuplicateAuthFiles(entries)]
: Array.from(byAuthIndex.values()).map(mergeDuplicateAuthFiles);
})
.sort((left, right) => {
const nameOrder = authFileName(left).localeCompare(authFileName(right), undefined, { sensitivity: 'base' });
return nameOrder || (authFileCooldownResetIndex(left) ?? '')
.localeCompare(authFileCooldownResetIndex(right) ?? '');
});
};
+16
View File
@@ -0,0 +1,16 @@
import type { PluginListEntry } from './plugins';
/** OAuth providers are declared by the running plugins, independently of their IDs or menus. */
export function collectPluginOAuthProviders(
plugins: PluginListEntry[],
builtInProviderIds: readonly string[] = [],
): PluginListEntry[] {
const seen = new Set(builtInProviderIds);
return plugins.filter(plugin => {
const provider = plugin.oauthProvider;
if (!plugin.effectiveEnabled || !plugin.supportsOAuth || !provider
|| !/^[a-z0-9-]+$/.test(provider) || seen.has(provider)) return false;
seen.add(provider);
return true;
});
}
+1
View File
@@ -7,3 +7,4 @@
@import './responsive-settings.css';
@import './config-settings.css';
@import '../pages/UsageEventsView.css';
@import '../pages/AgentClientList.css';
+203
View File
@@ -0,0 +1,203 @@
// Run Vite on port 1421, then node tests/agent-client-list-ui.cjs.
// PLAYWRIGHT_MODULE and AGENT_CLIENT_TEST_URL can target an existing local setup.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const base = process.env.AGENT_CLIENT_TEST_URL || 'http://127.0.0.1:1421';
const storageKey = 'cpa-gui.agent-visible-clients.v1';
const automaticClients = ['Claude Code', 'Claude Desktop', 'Codex', 'OpenCode', 'Pi'];
const screenshots = path.join(os.tmpdir(), 'easycliproxy-agent-clients');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1460, height: 1060 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
fs.mkdirSync(screenshots, { recursive: true });
const clients = () => page.locator('.agent-list-items button strong');
const visibleClients = () => clients().allTextContents();
const activeClient = () => page.locator('.agent-list-items button.active strong').textContent();
const stored = () => page.evaluate(key => localStorage.getItem(key), storageKey);
const manage = () => page.getByRole('button', { name: '管理客户端', exact: true });
const dialog = () => page.getByRole('dialog', { name: '管理客户端', exact: true });
const checkbox = name => dialog().getByRole('checkbox', { name, exact: true });
const save = () => dialog().getByRole('button', { name: '保存', exact: true });
const cancel = () => dialog().getByRole('button', { name: '取消', exact: true });
const search = () => dialog().getByRole('searchbox', { name: '搜索客户端', exact: true });
const automatic = () => dialog().getByRole('button', { name: '恢复自动显示', exact: true });
const ready = () => page.waitForFunction(() => window.fixtureCalls?.some(call => call.cmd === 'get_agent_config_statuses')
&& document.querySelector('.agent-client-list-heading button')
&& !document.querySelector('.agent-client-list-heading button').disabled);
const waitClients = async names => {
await page.waitForFunction(expected => {
const actual = Array.from(document.querySelectorAll('.agent-list-items button strong'), node => node.textContent);
return JSON.stringify(actual) === JSON.stringify(expected);
}, names);
assert.deepEqual(await visibleClients(), names);
};
const open = async (extra = 'mixed-clients', saved = null) => {
if (page.url() !== 'about:blank') {
await page.evaluate(({ key, saved }) => {
if (saved === null) localStorage.removeItem(key);
else localStorage.setItem(key, saved);
}, { key: storageKey, saved });
}
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=codex&${extra}`, { waitUntil: 'domcontentloaded' });
await ready();
await page.evaluate(() => document.fonts.ready);
};
const refresh = async override => {
await page.evaluate(value => { window.fixtureClientStatusesOverride = value; }, override);
await page.locator('.agent-client-list-heading button').click();
await ready();
};
const closeAndFocus = async close => {
await close();
await dialog().waitFor({ state: 'hidden' });
assert.equal(await manage().evaluate(node => node === document.activeElement), true,
'closing the client manager returns keyboard focus to its trigger');
};
const assertNoHorizontalOverflow = async label => {
const metrics = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
assert.ok(metrics.scrollWidth <= metrics.width + 1, `${label}: the page must fit the viewport`);
const shown = page.getByRole('dialog');
if (await shown.count()) {
const bounds = await shown.evaluate(node => {
const rect = node.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: innerWidth, scrollWidth: node.scrollWidth, clientWidth: node.clientWidth };
});
assert.ok(bounds.left >= -1 && bounds.right <= bounds.width + 1, `${label}: the dialog must fit the viewport`);
assert.ok(bounds.scrollWidth <= bounds.clientWidth + 1, `${label}: the dialog must not scroll horizontally`);
}
};
// Installation, config-only and Pi plugin-only detection all keep clients accessible.
await open();
await waitClients(automaticClients);
assert.equal(await activeClient(), 'Codex');
assert.equal(await page.locator('.agent-list-items button.active').getAttribute('aria-pressed'), 'true');
assert.equal(await stored(), null, 'automatic defaults do not become a custom preference');
// Search reaches uninstalled clients; a cancelled draft never affects navigation or storage.
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 13);
await search().fill('herMES');
assert.equal(await dialog().getByRole('checkbox').count(), 1);
await checkbox('Hermes Agent').check();
await search().fill('no-such-client');
assert.equal(await dialog().getByRole('checkbox').count(), 0);
await search().fill('');
assert.equal(await checkbox('Hermes Agent').isChecked(), true, 'search preserves pending choices');
await closeAndFocus(() => cancel().click());
await waitClients(automaticClients);
assert.equal(await stored(), null);
assert.equal(await activeClient(), 'Codex');
await manage().click();
assert.equal(await checkbox('Hermes Agent').isChecked(), false, 'cancel discards the draft');
await checkbox('Hermes Agent').check();
await closeAndFocus(() => page.keyboard.press('Escape'));
assert.equal(await stored(), null, 'Escape also discards changes');
// A keyboard user cannot escape the dialog, and cannot save an empty client list.
await manage().click();
for (let index = 0; index < 20; index++) {
await page.keyboard.press('Tab');
assert.equal(await dialog().evaluate(node => node.contains(document.activeElement)), true, 'Tab stays in the modal');
}
for (const control of await dialog().getByRole('checkbox').all()) {
if (await control.isChecked() && await control.isEnabled()) await control.uncheck();
}
const remaining = await dialog().getByRole('checkbox', { checked: true }).count();
assert.ok(remaining <= 1);
if (remaining === 0) assert.equal(await save().isDisabled(), true, 'empty selection must disable Save');
else assert.equal(await dialog().getByRole('checkbox', { checked: true }).isDisabled(), true,
'if the last client cannot be removed, its checkbox must communicate that state');
await checkbox('Hermes Agent').check();
for (const name of automaticClients) if (await checkbox(name).isChecked()) await checkbox(name).uncheck();
assert.equal(await checkbox('Hermes Agent').isChecked(), true);
await closeAndFocus(() => save().click());
await waitClients(['Hermes Agent']);
assert.equal(await activeClient(), 'Hermes Agent', 'removing the active client selects the first remaining client');
assert.deepEqual(JSON.parse(await stored()), ['hermes']);
await page.reload();
await ready();
await waitClients(['Hermes Agent']);
assert.equal(await activeClient(), 'Hermes Agent', 'saved visibility is restored on reload');
// Detection updates automatic mode but never overrides a manually curated list.
const override = { 'kimi-code': { installed: true, configExists: true, version: '2.0' } };
await refresh(override);
await waitClients(['Hermes Agent']);
await manage().click();
await automatic().click();
assert.equal(await checkbox('Kimi Code').isChecked(), true);
assert.equal(await checkbox('Claude Desktop').isChecked(), true);
await closeAndFocus(() => cancel().click());
assert.deepEqual(JSON.parse(await stored()), ['hermes'], 'restoring automatic visibility is still a cancellable draft');
await manage().click();
await automatic().click();
await closeAndFocus(() => save().click());
assert.equal(await stored(), null, 'saving automatic mode clears the custom preference');
await waitClients([...automaticClients, 'Kimi Code', 'Hermes Agent']);
await refresh({ ...override,
'claude-code': { installed: false, configured: false, configExists: false },
openclaw: { installed: true },
});
await waitClients(['Claude Desktop', 'Codex', 'OpenCode', 'Pi', 'Kimi Code', 'OpenClaw', 'Hermes Agent']);
// Detection failure and a clean machine still offer a usable selected client and the full catalog.
await open('not-installed&no-plugin&fresh');
await waitClients(['Codex']);
await manage().click();
assert.equal(await dialog().getByRole('checkbox').count(), 13);
await closeAndFocus(() => cancel().click());
await open('mixed-clients&fail-detection');
await page.getByText(/模拟客户端检测失败/).waitFor();
await waitClients(['Codex']);
await refresh({});
await waitClients(automaticClients);
await open('mixed-clients&shell');
await waitClients(automaticClients);
await page.screenshot({ path: path.join(screenshots, 'automatic-desktop.png'), fullPage: true });
await manage().click();
await dialog().waitFor();
await page.screenshot({ path: path.join(screenshots, 'manager-desktop.png'), fullPage: true });
await closeAndFocus(() => cancel().click());
// Both entry points remain usable with narrow windows and English text in dark mode.
for (const embedded of [false, true]) {
for (const english of [false, true]) {
await page.setViewportSize({ width: 360, height: 800 });
await open(`mixed-clients&shell&${embedded ? 'embedded&' : ''}${english ? 'locale=en&theme=dark' : ''}`);
await waitClients(automaticClients);
const label = `${embedded ? 'embedded' : 'full'}-${english ? 'dark-en' : 'light-zh'}`;
await assertNoHorizontalOverflow(label);
const trigger = page.getByRole('button', { name: english ? 'Manage Clients' : '管理客户端', exact: true });
await trigger.click();
const modal = page.getByRole('dialog', { name: english ? 'Manage Clients' : '管理客户端', exact: true });
await modal.waitFor();
await assertNoHorizontalOverflow(`${label} manager`);
const hermes = modal.getByRole('checkbox', { name: 'Hermes Agent', exact: true });
await hermes.check();
assert.equal(await hermes.isChecked(), true, `${label}: the last catalog entry is reachable`);
await page.screenshot({ path: path.join(screenshots, `${label}-manager.png`), fullPage: true });
await page.keyboard.press('Escape');
await modal.waitFor({ state: 'hidden' });
assert.equal(await trigger.evaluate(node => node === document.activeElement), true);
}
}
assert.deepEqual(errors, []);
console.log('PASS: automatic detection, custom visibility, persistence, active selection, search, cancellation, empty selection, modal focus, retry, narrow layouts and dark English');
console.log(`Screenshots: ${screenshots}`);
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exit(1); });
+52 -28
View File
@@ -30,7 +30,7 @@ const desktopViewports = [
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_config_statuses')
await page.waitForFunction(() => window.fixtureCalls?.some(call => call.cmd === 'get_agent_config_statuses')
&& document.querySelector('.agent-config-panel')?.getBoundingClientRect().height > 0
&& !document.querySelector('.agent-client-list-heading button')?.disabled);
await page.evaluate(() => document.fonts.ready);
@@ -119,13 +119,18 @@ const desktopViewports = [
assert.deepEqual(metrics.controlsOutside, [], `${label}: every visible control must remain inside the panel`);
const tabs = await page.locator('.agent-subpage-tabs').evaluateAll(nodes => nodes
.filter(node => getComputedStyle(node).display !== 'none')
.map(node => node.getBoundingClientRect().height));
assert.ok(tabs.every(height => height >= 40), `${label}: the tab bar must retain its full height`);
.map(node => ({
height: node.getBoundingClientRect().height,
buttonHeights: Array.from(node.querySelectorAll('[role="tab"]'))
.map(button => button.getBoundingClientRect().height),
})));
assert.ok(tabs.every(({ height, buttonHeights }) => height >= 39 && buttonHeights.every(value => value >= 38)),
`${label}: the tab bar must retain its 38px buttons and 1px bottom border`);
}
return metrics;
};
const assertClientListScroll = async (label, { requireOverflow = true } = {}) => {
const assertClientListScroll = async (label, { requireOverflow = false } = {}) => {
const metrics = await page.locator('.agent-list-items').evaluate(list => {
const style = getComputedStyle(list);
const overflowX = list.scrollWidth > list.clientWidth + 1;
@@ -166,7 +171,7 @@ const desktopViewports = [
assert.deepEqual(metrics.unreachable, [], `${label}: scrolling must reveal every client option`);
};
const assertDesktopLayout = async (label, { requireClientOverflow = true } = {}) => {
const assertDesktopLayout = async (label, { requireClientOverflow = false } = {}) => {
const [clientList, configuration] = await page.locator('.agent-client-list, .agent-config-panel')
.evaluateAll(nodes => nodes.map(node => {
const rect = node.getBoundingClientRect();
@@ -287,7 +292,7 @@ const desktopViewports = [
if (metrics.tabRect) {
assert.equal(metrics.tabInsideRegion, false,
`${label}: tabs must stay fixed above, not inside, the scrolling content region`);
assert.ok(metrics.tabRect.height >= 40 && metrics.tabHeights.every(height => height >= 34),
assert.ok(metrics.tabRect.height >= 39 && metrics.tabHeights.every(height => height >= 38),
`${label}: the tab bar and tab buttons must retain their full height`);
assert.ok(metrics.tabRect.top >= metrics.panelRect.top - 1
&& metrics.tabRect.bottom <= metrics.panelRect.bottom + 1,
@@ -330,7 +335,7 @@ const desktopViewports = [
};
});
const assertFixedShellDesktop = async (label, { requireRightOverflow = false } = {}) => {
const assertFixedShellDesktop = async (label, { requireRightOverflow = false, requireClientOverflow = false } = {}) => {
const metrics = await shellLayoutMetrics();
assert.ok(Math.abs(metrics.client.height - metrics.configuration.height) <= 1,
`${label}: fixed-height desktop panels must remain equal in height`);
@@ -344,7 +349,7 @@ const desktopViewports = [
`${label}: fixed-height desktop layout must not create a far-right page scrollbar`);
assert.ok(metrics.documentScrollWidth <= metrics.documentClientWidth + 1,
`${label}: fixed-height desktop layout must not create horizontal page scrolling`);
await assertClientListScroll(label, { requireOverflow: requireRightOverflow });
await assertClientListScroll(label, { requireOverflow: requireClientOverflow });
await assertShellScrollRegion(label, { requireOverflow: requireRightOverflow });
return metrics;
};
@@ -355,7 +360,6 @@ const desktopViewports = [
};
const textFlow = { full: {}, embedded: {} };
const naturalHeight = { full: {}, embedded: {} };
for (const viewport of desktopViewports) {
await page.setViewportSize(viewport);
for (const embedded of [false, true]) {
@@ -363,25 +367,39 @@ const desktopViewports = [
await open({ client, embedded });
await openSubpage('基础配置');
const label = `${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`;
const core = await assertDesktopLayout(label);
await assertDesktopLayout(label);
if (client === 'zcode' && viewport.height === 941) {
naturalHeight[embedded ? 'embedded' : 'full'][viewport.width] = core.configuration.height;
const status = page.locator('.agent-list-items button').filter({ hasText: 'Antigravity CLI' }).locator('small');
await status.evaluate(node => {
node.textContent = '当前设备已检测到本地智能体客户端,可以直接从此页面启动';
});
const longModel = 'gemini-3.8-flash-high-with-a-long-client-status-for-layout-verification';
await page.evaluate(model => {
window.fixtureClientStatusesOverride = { 'antigravity-cli': { appliedModel: model } };
}, longModel);
await page.locator('.agent-client-list-heading button').click();
await page.waitForFunction(model => Array.from(document.querySelectorAll('.agent-list-items small'))
.some(node => node.textContent.includes(model)), longModel);
const flow = await status.evaluate(node => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
fits: node.scrollWidth <= node.clientWidth + 1 && node.scrollHeight <= node.clientHeight + 1,
rowHeight: node.closest('button').getBoundingClientRect().height,
overflows: node.scrollWidth > node.clientWidth + 1,
fitsVertically: node.scrollHeight <= node.clientHeight + 1,
whiteSpace: getComputedStyle(node).whiteSpace,
overflow: getComputedStyle(node).overflow,
textOverflow: getComputedStyle(node).textOverflow,
title: node.title,
text: node.textContent,
}));
assert.equal(flow.fits, true, `${label}: client status text must wrap without clipping`);
assert.equal(flow.whiteSpace, 'normal', `${label}: client status text must use responsive wrapping`);
const afterWrap = await page.locator('.agent-client-list, .agent-config-panel')
assert.equal(flow.overflows, true, `${label}: the long status fixture must exercise text truncation`);
assert.equal(flow.fitsVertically, true, `${label}: client status text must fit on one line`);
assert.equal(flow.whiteSpace, 'nowrap', `${label}: client status text must remain on one line`);
assert.equal(flow.overflow, 'hidden', `${label}: long client status text must stay within its row`);
assert.equal(flow.textOverflow, 'ellipsis', `${label}: truncated client status text must show an ellipsis`);
assert.equal(flow.title, flow.text, `${label}: the title must retain the full client status text`);
assert.ok(flow.title.includes(longModel), `${label}: the title must include the full long model name`);
const afterTruncation = await page.locator('.agent-client-list, .agent-config-panel')
.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
assert.ok(Math.abs(afterWrap[0] - afterWrap[1]) <= 1,
`${label}: wrapped client text must not break equal panel heights`);
assert.ok(Math.abs(afterTruncation[0] - afterTruncation[1]) <= 1,
`${label}: truncated client text must not break equal panel heights`);
textFlow[embedded ? 'embedded' : 'full'][viewport.width] = flow;
}
@@ -394,10 +412,10 @@ const desktopViewports = [
for (const mode of ['full', 'embedded']) {
assert.ok(textFlow[mode][900].width < textFlow[mode][1280].width,
`${mode}: client text width must follow the proportional sidebar`);
assert.ok(textFlow[mode][900].height > textFlow[mode][1280].height,
`${mode}: client text must reflow as the proportional sidebar narrows`);
assert.ok(naturalHeight[mode][900] > naturalHeight[mode][1280],
`${mode}: the natural panel height must grow when its content reflows at a narrower width`);
assert.ok(Math.abs(textFlow[mode][900].height - textFlow[mode][1280].height) <= 1,
`${mode}: client status must retain a single line as the proportional sidebar narrows`);
assert.ok(Math.abs(textFlow[mode][900].rowHeight - textFlow[mode][1280].rowHeight) <= 1,
`${mode}: compact client rows must retain their height as the proportional sidebar narrows`);
}
// In the real application shell, the desktop workbench fills the same bounded content area as Usage records.
@@ -409,7 +427,10 @@ const desktopViewports = [
await open({ client: 'claude-desktop', shell: true });
await openSubpage('基础配置');
const label = `${viewport.width}x${viewport.height}/shell/claude-desktop/core`;
const metrics = await assertFixedShellDesktop(label, { requireRightOverflow: height === 700 });
const metrics = await assertFixedShellDesktop(label, {
requireRightOverflow: height === 700,
requireClientOverflow: height === 700,
});
fixedShellByHeight.set(height, metrics);
}
const fixedShellEntries = [...fixedShellByHeight.entries()];
@@ -425,11 +446,14 @@ const desktopViewports = [
`${viewportHeight}px shell: vertical resizing must not move the workbench top edge`);
}
// A 900px window still uses the bounded shell, but its usable workspace crosses the one-column breakpoint.
await page.setViewportSize({ width: 900, height: 941 });
// A 790px window keeps the bounded shell while its padded workspace crosses the 760px container breakpoint.
await page.setViewportSize({ width: 790, height: 941 });
await open({ client: 'claude-desktop', shell: true });
await openSubpage('基础配置');
const shellNarrowLabel = '900x941/shell/claude-desktop/core';
const shellNarrowLabel = '790x941/shell/claude-desktop/core';
const shellWorkspaceWidth = await page.locator('.agents-page').evaluate(node => node.clientWidth);
assert.ok(shellWorkspaceWidth <= 760,
`${shellNarrowLabel}: the usable workspace must cross the 760px container breakpoint`);
const shellNarrow = await shellLayoutMetrics();
assert.ok(Math.abs(shellNarrow.client.x - shellNarrow.configuration.x) <= 1
&& Math.abs(shellNarrow.client.width - shellNarrow.configuration.width) <= 1,
+3 -2
View File
@@ -3,9 +3,10 @@ const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1421/') ? route.continue() : route.abort());
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
@@ -22,7 +23,7 @@ const assert = require('node:assert/strict');
return refresh && !refresh.disabled && window.fixtureCalls.some(call => call.cmd === 'get_agent_models');
});
const open = async query => {
await page.goto('http://localhost:1421/tests/fixtures/agent-backups.html?reset-selections&' + query);
await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?reset-selections&' + query, { waitUntil: 'domcontentloaded' });
await ready();
};
const remount = async embedded => {
+72 -9
View File
@@ -27,6 +27,18 @@ const fs = require('node:fs/promises');
const clear = target => target.getByRole('button', { name: '清除冷却', exact: true });
const confirmation = () => page.getByRole('alertdialog', { name: '清除凭证冷却?', exact: true });
const requests = () => page.evaluate(() => window.cooldownFixture.resetRequests);
const expandMetadata = async target => {
const button = target.locator('.auth-list-details-button');
if (await button.getAttribute('aria-expanded') !== 'true') await button.click();
};
const expandCooldown = async target => {
const details = target.locator('.auth-file-health-compact > details');
if (await details.getAttribute('open') === null) await details.locator('.auth-health-summary').click();
};
const assertMainRowHeight = async label => {
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}: cooldown content does not stretch the seven-column main row: ${JSON.stringify(heights)}`);
};
const open = async query => {
await page.goto(`${base}/tests/fixtures/auth-file-cooldown.html?${query || ''}`);
await primary().waitFor();
@@ -41,12 +53,35 @@ const fs = require('node:fs/promises');
await fs.mkdir(screenshotDir, { recursive: true });
await open();
assert.equal(await primary().locator('details').getAttribute('open'), null);
assert.equal(await clear(primary()).isVisible(), true, 'Reset is discoverable while details are collapsed');
assert.equal(await primary().locator('.auth-file-health details').getAttribute('open'), null);
assert.equal(await primary().locator('.auth-list-details').isVisible(), false, 'File metadata starts independently collapsed');
assert.equal(await primary().locator('.auth-file-health-compact').isVisible(), true, 'Cooldown summary is visible before opening any details');
assert.equal(await primary().locator('.auth-health-body').isVisible(), false, 'Individual cooldown records start collapsed');
assert.ok((await primary().locator('.auth-health-heading').innerText()).includes('凭证及 2 个模型冷却'));
assert.equal(await primary().locator('.auth-credential-row > .auth-list-cell').count(), 7, 'Cooldown rows retain the complete seven-column overview');
assert.equal(await primary().locator('.auth-list-recent').isVisible(), true, 'Recent requests stay visible while diagnostics are collapsed');
assert.equal(await primary().locator('.auth-list-usage').isVisible(), true, 'Runtime counters stay visible while diagnostics are collapsed');
assert.equal(await primary().locator('.auth-list-icon-actions > button:visible').count(), 5, 'Credential actions stay directly available');
assert.equal(await clear(primary()).isVisible(), true, 'Reset is directly available beside the collapsed cooldown summary');
await assertMainRowHeight('Collapsed cooldown');
await primary().screenshot({ path: path.join(screenshotDir, 'cooldown-collapsed-light-desktop.png') });
for (const name of ['05-empty.json', '06-null.json', '07-absent.json', '08-no-index.json']) {
assert.equal(await clear(card(name)).count(), 0, `${name} must not offer an unsafe or inapplicable reset`);
}
await primary().locator('.auth-health-summary').click();
assert.equal(await card('05-empty.json').locator('.auth-file-health-compact').count(), 0, 'An empty snapshot does not display a cooldown strip');
assert.equal(await card('06-null.json').locator('.auth-file-health-compact').isVisible(), true);
assert.ok((await card('06-null.json').locator('.auth-file-health-compact').innerText()).includes('冷却状态未知'), 'Null remains an explicit unknown state');
assert.equal(await card('07-absent.json').locator('.auth-file-health-compact').count(), 0, 'A missing snapshot never claims to know cooldown state');
await expandMetadata(card('07-absent.json'));
assert.ok((await card('07-absent.json').locator('.auth-list-details').innerText()).includes('当前内核未提供冷却详情'));
await primary().locator('.auth-health-summary').focus();
await page.keyboard.press('Enter');
assert.equal(await primary().locator('.auth-health-body').isVisible(), true, 'Enter opens cooldown records without opening metadata');
await page.keyboard.press('Space');
assert.equal(await primary().locator('.auth-health-body').isVisible(), false, 'Space closes native cooldown disclosure');
await expandCooldown(primary());
assert.equal(await primary().locator('.auth-list-details').isVisible(), false, 'Cooldown and metadata disclosures are independent');
await assertMainRowHeight('Expanded cooldown');
const details = primary().locator('.auth-health-body');
for (const text of ['整个凭证', 'gpt-fictional', 'claude-fictional', '额度或速率限制', '上游服务暂时异常', 'HTTP 429', 'HTTP 503', '退避等级 0']) {
assert.ok((await details.innerText()).includes(text), `Cooldown detail contains ${text}`);
@@ -58,6 +93,7 @@ const fs = require('node:fs/promises');
assert.equal(await page.evaluate(() => window.cooldownFixture.reads), 1);
assert.ok((await card('04-expired-soon.json').innerText()).includes('冷却计时已到期'));
assert.equal(await clear(card('04-expired-soon.json')).isEnabled(), true, 'Elapsed records remain explicitly resettable');
await expandCooldown(card('03-disabled-runtime.json'));
assert.ok((await card('03-disabled-runtime.json').innerText()).includes('运行时'));
assert.ok((await card('03-disabled-runtime.json').locator('.auth-file-health').innerText()).includes('已停用'));
assert.equal(await clear(card('03-disabled-runtime.json')).isEnabled(), true, 'Runtime and disabled credentials can clear existing cooldown records');
@@ -71,6 +107,7 @@ const fs = require('node:fs/promises');
assert.deepEqual(await requests(), [], 'Cancel leaves the backend untouched');
assert.equal(await primary().locator('.auth-health-records li').count(), 3);
await expandCooldown(other());
const otherDeadline = await other().locator('.auth-health-records time').getAttribute('datetime');
const otherBefore = await other().locator('.auth-health-countdown').innerText();
await confirmReset(primary());
@@ -93,7 +130,7 @@ const fs = require('node:fs/promises');
assert.ok((await page.locator('.app-notice-stack').innerText()).includes('01-primary.json'), 'Success identifies the cleared credential');
await open('resetError=1');
await primary().locator('.auth-health-summary').click();
await expandCooldown(primary());
await confirmReset(primary());
await releaseReset();
await page.getByText(/Fixture cooldown reset failed/).waitFor();
@@ -108,7 +145,8 @@ const fs = require('node:fs/promises');
await page.waitForFunction(() => window.cooldownFixture.reads === 2);
await clear(card('03-disabled-runtime.json')).waitFor({ state: 'detached' });
assert.ok((await card('03-disabled-runtime.json').innerText()).includes('运行时'));
assert.ok((await card('03-disabled-runtime.json').locator('.auth-file-health').innerText()).includes('已停用'), 'Clearing cooldown preserves disabled state');
assert.ok((await card('03-disabled-runtime.json').locator('.auth-list-status').innerText()).includes('已停用'), 'Clearing cooldown preserves disabled state in the main row');
assert.equal(await card('03-disabled-runtime.json').locator('.auth-file-health-compact').count(), 0, 'Successful reset removes only the cooldown strip');
await open();
await confirmReset(primary());
@@ -128,15 +166,40 @@ const fs = require('node:fs/promises');
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });
await open(`theme=${theme}`);
await primary().locator('.auth-health-summary').click();
await other().locator('.auth-health-summary').click();
const sizes = await page.locator('.auth-file-card, .auth-file-health').evaluateAll(nodes => nodes.map(node => ({
assert.equal(await clear(primary()).isVisible(), true);
assert.equal(await primary().locator('.auth-list-details').isVisible(), false);
if (width >= 1280) await assertMainRowHeight(`${theme} ${width} collapsed`);
await primary().screenshot({ path: path.join(screenshotDir, `cooldown-collapsed-${theme}-${width}.png`) });
await expandCooldown(primary());
await expandCooldown(other());
const sizes = await page.locator('.auth-file-card, .auth-file-health:visible, .auth-file-table-scroll').evaluateAll(nodes => nodes.map(node => ({
className: node.className, width: node.clientWidth, scroll: node.scrollWidth,
})));
assert.ok(sizes.every(size => size.scroll <= size.width + 1), `${theme} ${width}: ${JSON.stringify(sizes)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `No page overflow at ${theme} ${width}`);
await page.screenshot({ path: path.join(screenshotDir, `details-${theme}-${width}.png`), fullPage: true });
await primary().screenshot({ path: path.join(screenshotDir, `card-${theme}-${width}.png`) });
await expandMetadata(primary());
const summary = primary().locator('.auth-file-requests-summary');
assert.equal(await summary.isVisible(), true, 'File details expose the separate request summary');
assert.equal(await summary.locator('.auth-file-request-block:visible').count(), 20);
assert.equal(await primary().locator('.auth-list-recent .auth-file-request-block:visible').count(), 20, 'The main request timeline remains intact');
assert.ok((await summary.locator('.auth-file-requests-heading').innerText()).includes('成功 20'));
assert.ok((await summary.locator('.auth-file-requests-heading').innerText()).includes('失败 2'));
assert.equal(await summary.locator('.auth-file-requests-rate').innerText(), '95.2%', 'Summary rate uses the recent window while counts retain the runtime totals');
const block = summary.locator('.auth-file-request-block').nth(18);
await block.focus();
assert.ok((await summary.getByRole('tooltip').innerText()).includes('失败 1'));
await page.keyboard.press('ArrowRight');
assert.equal(await summary.locator('.auth-file-request-block').nth(19).evaluate(node => node === document.activeElement), true);
await page.keyboard.press('ArrowLeft');
assert.equal(await block.evaluate(node => node === document.activeElement), true);
await page.keyboard.press('Escape');
assert.equal(await summary.getByRole('tooltip').count(), 0);
await block.blur();
assert.equal(await summary.evaluate(node => node.scrollWidth <= node.clientWidth + 1), true, 'Request summary fits desktop and mobile');
if (width >= 1280) await assertMainRowHeight(`${theme} ${width} expanded`);
await primary().screenshot({ path: path.join(screenshotDir, `cooldown-and-requests-${theme}-${width}.png`) });
await clear(primary()).click();
await confirmation().waitFor();
assert.equal(await confirmation().evaluate(node => node.scrollWidth <= node.clientWidth + 1), true, 'Confirmation fits viewport');
@@ -146,7 +209,7 @@ const fs = require('node:fs/promises');
}
}
assert.deepEqual(errors, []);
console.log('PASS: cooldown models/reasons/countdowns, confirmation and cancel, single auth_index POST, pending duplicate protection, isolated success refresh, failure preservation, stale response protection, unknown/empty/missing-index guards, elapsed records, runtime/disabled state, light/dark desktop/mobile layout.');
console.log('PASS: directly visible cooldown summary/reset, independent keyboard disclosure, seven-column main-row height, cooldown models/reasons/countdowns, request summary counts/rate and keyboard tooltips, confirmation and cancel, single auth_index POST, pending duplicate protection, isolated success refresh, failure preservation, stale response protection, unknown/empty/missing-index guards, elapsed records, runtime/disabled state, light/dark desktop/mobile layout.');
console.log(`Screenshots: ${screenshotDir}`);
} finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+98 -19
View File
@@ -15,7 +15,7 @@ const fs = require('node:fs/promises');
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } });
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 }, timezoneId: 'Asia/Shanghai' });
page.setDefaultTimeout(10000);
const errors = [], externalRequests = [];
page.on('pageerror', error => errors.push(String(error)));
@@ -30,27 +30,68 @@ const fs = require('node:fs/promises');
const pagination = () => page.locator('.auth-list-pagination');
const next = () => pagination().getByRole('button', { name: '下一页', exact: true });
const previous = () => pagination().getByRole('button', { name: '上一页', exact: true });
const expand = async target => {
const button = target.locator('.auth-list-details-button');
if (await button.getAttribute('aria-expanded') !== 'true') await button.click();
};
const open = async (query = '') => {
await page.goto(`${base}/tests/fixtures/auth-file-list.html?${query}`);
await card(1).waitFor();
await page.waitForFunction(() => window.authFileListFixture.reads === 1);
};
const assertFits = async label => {
const dimensions = await cards().evaluateAll(nodes => nodes.map(node => ({ width: node.clientWidth, scroll: node.scrollWidth })));
assert.ok(dimensions.every(size => size.scroll <= size.width + 1), `${label}: cards overflow ${JSON.stringify(dimensions)}`);
const dimensions = await page.locator('.auth-file-card, .auth-file-table-scroll').evaluateAll(nodes => nodes.map(node => ({
className: node.className, width: node.clientWidth, scroll: node.scrollWidth, overflowX: getComputedStyle(node).overflowX,
})));
assert.ok(dimensions.every(size => size.scroll <= size.width + 1 || (
size.className === 'auth-file-table-scroll' && size.width > 700 && size.width < 906 && size.overflowX === 'auto'
)), `${label}: only intermediate widths may scroll inside the table; cards must fit ${JSON.stringify(dimensions)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `${label}: page overflow`);
};
const assertMainInformation = async (label, width) => {
for (const selector of ['.auth-card-filename', '.auth-list-plan', '.auth-list-status', '.auth-list-recent', '.auth-list-usage', '.auth-list-quota', '.auth-card-actions']) {
assert.equal(await card(1).locator(selector).isVisible(), true, `${label}: ${selector} is visible without opening details`);
}
assert.equal(await card(1).locator('.auth-list-recent .auth-file-request-block:visible').count(), 20, `${label}: the request timeline remains visible`);
assert.equal(await card(1).locator('.auth-file-usage-metric:visible').count(), 4, `${label}: all four runtime counters remain visible`);
assert.equal(await card(1).locator('.auth-list-icon-actions > button:visible').count(), 5, `${label}: all five actions are directly available`);
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) {
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)}`);
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 };
}));
assert.equal(cells.length, 7, `${label}: desktop has seven information columns`);
assert.ok(cells.slice(1).every((cell, index) => cell.left >= cells[index].right - 1), `${label}: all seven columns remain in one row`);
}
};
await fs.mkdir(screenshotDir, { recursive: true });
await open();
assert.equal(await cards().count(), 10, 'Default page contains ten of twelve credentials');
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);
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 };
}));
assert.equal(cells.length, 7, 'Desktop row includes all seven columns');
assert.ok(cells.slice(1).every((cell, index) => cell.left >= cells[index].right - 1), 'Desktop columns stay in one horizontal row');
await assertMainInformation('initial desktop', 1800);
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');
assert.equal(await card(1).getByRole('button', { name: '删除', exact: true }).isVisible(), true, 'Delete is directly available alongside the other actions');
assert.equal(await card(4).locator('.credential-quota-row:visible').count(), 2, 'The first two quota windows are always shown');
assert.equal(await card(2).locator('.credential-quota-row.low .credential-quota-label strong').first().innerText(), '0%', 'Exhausted quota stays explicitly zero');
assert.equal(await card(2).getByRole('progressbar').first().getAttribute('aria-valuenow'), '0');
assert.equal(await card(1).getByRole('progressbar').first().getAttribute('aria-valuenow'), '72');
assert.equal(await card(1).getByRole('progressbar').nth(1).getAttribute('aria-valuenow'), '83');
assert.equal(await card(5).locator('.credential-quota-row.unknown [role="progressbar"]').count(), 0, 'Unknown quota is not reported as a zero-percent progress bar');
assert.equal(await card(5).locator('.credential-quota-label strong').innerText(), '—', 'Unknown quota retains its separate presentation');
const resetTimes = card(1).locator('.credential-quota-reset-time');
assert.equal(await resetTimes.count(), 2, 'Both quota reset times stay visible');
assert.ok((await resetTimes.first().innerText()).includes('10/03 15:00'), 'Five-hour window preserves its exact reset timestamp');
assert.ok((await resetTimes.nth(1).innerText()).includes('10/08 12:00'), 'Weekly window preserves its exact reset timestamp');
const resetTitle = await resetTimes.first().getAttribute('title');
assert.ok(resetTitle.startsWith(await resetTimes.first().innerText()) && resetTitle.includes('小时后'), 'Compact timestamps retain their exact time and relative countdown on hover');
await next().click();
await card(11).waitFor();
assert.equal(await cards().count(), 2);
@@ -83,23 +124,35 @@ const fs = require('node:fs/promises');
assert.equal(await detailsButton.getAttribute('aria-expanded'), 'true');
assert.equal(await card(1).locator('.auth-list-details').isVisible(), true);
assert.ok((await card(1).locator('.auth-list-details').innerText()).includes('Fictional account note'));
const requestSummary = card(1).locator('.auth-file-requests-summary');
assert.equal(await requestSummary.isVisible(), true, 'File details include the additional compact request summary');
assert.equal(await requestSummary.locator('.auth-file-request-block:visible').count(), 20);
assert.ok((await requestSummary.locator('.auth-file-requests-heading').innerText()).includes('成功 1,200'));
assert.ok((await requestSummary.locator('.auth-file-requests-heading').innerText()).includes('失败 3'));
await requestSummary.locator('.auth-file-request-block').nth(18).focus();
assert.equal(await requestSummary.getByRole('tooltip').isVisible(), true, 'Expanded request summary retains keyboard tooltips');
await page.keyboard.press('Escape');
await requestSummary.locator('.auth-file-request-block').nth(18).blur();
await detailsButton.click();
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false);
const extraQuota = card(4).locator('.credential-quota-more');
assert.equal(await extraQuota.getAttribute('open'), null);
const extraQuota = card(4).locator('.auth-list-quota .credential-quota');
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), false);
await extraQuota.locator('summary').click();
await extraQuota.locator('.credential-quota-more > summary').click();
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), true, 'All additional quotas remain accessible');
assert.ok((await extraQuota.innerText()).includes('No quota limit supplied'));
assert.equal(await card(1).locator('.auth-file-request-block').count(), 20);
await card(1).locator('.auth-file-request-block').nth(18).focus();
assert.equal(await card(1).getByRole('tooltip').isVisible(), true, 'Recent request details are keyboard accessible');
assert.equal(await extraQuota.locator('.credential-quota-row:visible').count(), 4, 'Expanding quota details shows all four rows');
assert.equal(await card(4).locator('.auth-list-details').isVisible(), false, 'Additional quota rows open independently from health and metadata');
await extraQuota.locator('.credential-quota-more > summary').click();
assert.equal(await card(1).locator('.auth-list-recent .auth-file-request-block').count(), 20);
await card(1).locator('.auth-list-recent .auth-file-request-block').nth(18).focus();
assert.equal(await card(1).locator('.auth-list-recent').getByRole('tooltip').isVisible(), true, 'Recent request details are keyboard accessible');
await page.keyboard.press('Escape');
await card(1).locator('.auth-file-request-block').nth(18).blur();
await card(1).locator('.auth-list-recent .auth-file-request-block').nth(18).blur();
assert.equal(await card(2).getByRole('button', { name: '清除冷却', exact: true }).isVisible(), true);
await card(2).locator('.auth-health-summary').click();
assert.ok((await card(2).locator('.auth-health-body').innerText()).includes('gpt-fictional-layout'), 'Model cooldown details remain available');
assert.equal(await card(2).locator('.auth-list-details').isVisible(), false, 'Cooldown expansion is independent of file metadata');
await card(1).getByRole('switch').click();
await page.waitForFunction(() => window.authFileListFixture.reads === 2);
@@ -118,29 +171,55 @@ const fs = require('node:fs/promises');
await card(12).getByRole('button', { name: '删除', exact: true }).click();
const deleteDialog = page.getByRole('alertdialog', { name: '删除', exact: true });
await deleteDialog.waitFor();
assert.equal(await page.evaluate(() => window.authFileListFixture.requests.filter(request => request.method === 'DELETE').length), 0, 'The direct delete action still requires confirmation');
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click();
assert.equal(await card(12).count(), 1, 'Cancel preserves the credential');
await card(12).getByRole('button', { name: '删除', exact: true }).click();
await deleteDialog.waitFor();
await deleteDialog.getByRole('button', { name: '删除', exact: true }).click();
await card(12).waitFor({ state: 'detached' });
assert.equal(await cards().count(), 11);
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.requests.find(request => request.method === 'DELETE')?.query), { name: '12-account.json' });
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
const cases = ['light', 'dark'].flatMap(theme => [1800, 1280, 390].map(width => ({ theme, width, locale: 'zh-CN' })));
cases.push({ theme: 'light', width: 1800, locale: 'en' }, { theme: 'light', width: 390, locale: 'en' });
const cases = ['light', 'dark'].flatMap(theme => [1800, 1280, 1024, 390].flatMap(width => ['zh-CN', 'en'].map(locale => ({ theme, width, locale }))));
for (const { theme, width, locale } of cases) {
await page.setViewportSize({ width, height: 1000 });
await open(`theme=${theme}&locale=${locale}`);
await assertMainInformation(`${locale} ${theme} ${width}`, width);
await assertFits(`${locale} ${theme} ${width}`);
await page.screenshot({ path: path.join(screenshotDir, `list-${locale}-${theme}-${width}.png`), fullPage: true });
await card(1).locator('.auth-list-details-button').click();
await expand(card(1));
await card(2).locator('.auth-health-summary').click();
await card(4).locator('.credential-quota-more > summary').click();
await assertFits(`${locale} ${theme} ${width} expanded`);
await card(1).screenshot({ path: path.join(screenshotDir, `request-summary-${locale}-${theme}-${width}.png`) });
await card(2).screenshot({ path: path.join(screenshotDir, `cooldown-${locale}-${theme}-${width}.png`) });
await card(4).screenshot({ path: path.join(screenshotDir, `expanded-quota-${locale}-${theme}-${width}.png`) });
await open(`theme=${theme}&locale=${locale}&small=1`);
assert.equal(await cards().count(), 3, 'Screenshot-like idle state shows three accounts');
assert.equal(await pagination().count(), 0, 'Small lists omit unnecessary pagination');
assert.equal(await page.locator('.auth-list-details:visible, .auth-file-health:visible').count(), 0, 'Small idle list starts with diagnostics collapsed');
await assertMainInformation(`${locale} ${theme} ${width} small idle`, width);
assert.equal(await page.locator('.auth-file-usage-metric:visible').count(), 12, 'Idle counters stay visible on all three rows');
assert.equal(await page.locator('.auth-list-quota:visible').count(), 3, 'Disabled and idle quota states remain visible');
await assertFits(`${locale} ${theme} ${width} small idle`);
await page.screenshot({ path: path.join(screenshotDir, `small-idle-${locale}-${theme}-${width}.png`), fullPage: true });
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
}
await page.setViewportSize({ width: 1800, height: 1200 });
await open('theme=light&locale=zh-CN');
const tableBounds = await page.locator('.auth-file-table-scroll').boundingBox();
const seventhBounds = await card(7).boundingBox();
await page.screenshot({ path: path.join(screenshotDir, 'reference-dense-preview.png'), clip: {
x: tableBounds.x, y: tableBounds.y, width: tableBounds.width,
height: seventhBounds.y + seventhBounds.height - tableBounds.y,
} });
await open('theme=light&locale=zh-CN&small=1');
await page.locator('.auth-file-table-scroll').screenshot({ path: path.join(screenshotDir, 'reference-small-idle-preview.png') });
assert.deepEqual(errors, []);
assert.deepEqual(externalRequests, []);
console.log('PASS: offline credential list, seven desktop columns, ten/two pagination, search/provider/status reset, page size twenty, details, all quota rows, request keyboard tooltip, cooldown controls, exact status PATCH, deletion, light/dark 1800/1280/390 and English desktop/mobile without page or card overflow.');
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(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+24 -1
View File
@@ -9,7 +9,7 @@ const cooldown = {
retry_at: '2026-01-01T10:00:32Z', remaining_seconds: 32, http_status: 429,
};
const render = (file: Record<string, unknown>, options: {
receivedAtMs?: number; resetting?: boolean; resetDisabled?: boolean; onReset?: () => void;
receivedAtMs?: number; resetting?: boolean; resetDisabled?: boolean; onReset?: () => void; compact?: boolean;
} = {}) => renderToStaticMarkup(<I18nProvider>
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} onReset={() => {}} {...options} />
</I18nProvider>);
@@ -46,4 +46,27 @@ describe('credential cooldown actions', () => {
expect(pending).toMatch(/<button[^>]*disabled=""[^>]*aria-busy="true"/);
expect(pending).toContain('Clearing…</button>');
});
it('keeps cooldown scope visible for disabled credentials in the compact strip', () => {
const html = render({ disabled: true, cooldowns: [
{ ...cooldown, scope: 'credential', model_key: undefined, reason: 'credential_quota' },
cooldown,
{ ...cooldown, model_key: 'model-b' },
] }, { compact: true });
const summary = html.slice(0, html.indexOf('</summary>'));
expect(summary).toContain('auth-file-health-compact');
expect(summary).toContain('Credential and 2 models cooling down');
expect(summary).not.toContain('<strong>Disabled</strong>');
expect(html).toContain('HTTP 429');
expect(html.slice(html.indexOf('</details>'))).toContain('Clear cooldown</button>');
});
it('does not report unknown snapshots as a known clear cooldown state', () => {
const unknown = render({ status: 'active', cooldowns: null }, { compact: true });
expect(unknown).toContain('auth-file-health-compact');
expect(unknown).toContain('Cooldown state is unknown');
expect(unknown).not.toContain('Clear cooldown</button>');
expect(render({ status: 'active', cooldowns: [] }, { compact: true })).not.toContain('auth-file-health');
expect(render({ status: 'active' }, { compact: true })).not.toContain('auth-file-health');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
import { I18nProvider } from '../src/i18n';
import type { AuthFile, QuotaState } from '../src/services/quotaService';
const renderDense = (quota: QuotaState, file: AuthFile = { name: 'test.json', provider: 'codex' }, busy = false) =>
renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={file} quota={quota}
compact dense disabled={busy || file.disabled === true} onRefresh={() => {}} onReset={() => {}} /></I18nProvider>);
it('keeps window percentages, reset times and additional windows available in dense mode', () => {
const html = renderDense({ status: 'success', rows: [
{ label: '5h', remainingPercent: 140, reset: '10:30', detail: 'Window detail' },
{ label: 'weekly', remainingPercent: null, reset: 'Friday' },
{ label: 'extra', remainingPercent: 0, reset: 'Monday' },
] });
expect(html).toContain('credential-quota-dense');
expect(html).toContain('aria-valuenow="100"');
expect(html).not.toContain('140%');
expect(html).toContain('credential-quota-row unknown');
expect(html).toContain('title="10:30">10:30</small>');
expect(html).toContain('Window detail');
expect(html).toContain('<details class="credential-quota-more">');
expect(html).toContain('extra');
});
it('shows reset credits in the button while preserving applicability and expiry', () => {
const html = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 0 }],
resetCredits: 2, resetCreditsApplicable: 0, subscriptionActiveUntil: '2030-01-01T00:00:00Z',
});
const resetButton = html.match(/<button[^>]*credential-quota-reset[^>]*>[\s\S]*?<\/button>/)?.[0];
expect(resetButton).toContain('2');
expect(resetButton).not.toContain('disabled=""');
expect(html).toContain('Currently applicable: 0');
expect(html).toContain('Subscription expires');
});
it('does not confuse a busy list with a disabled credential or discard cached quota', () => {
const idle = renderDense({ status: 'idle', rows: [] }, undefined, true);
expect(idle).toContain('Quota has not been fetched');
expect(idle).not.toContain('Disabled');
const disabled = renderDense({ status: 'idle', rows: [] }, { name: 'test.json', disabled: true });
expect(disabled).toContain('Disabled');
const cached = renderDense({ status: 'success', rows: [{ label: '5h', remainingPercent: 75 }] },
{ name: 'test.json', disabled: true }, true);
expect(cached).toContain('aria-valuenow="75"');
});
it('keeps raw failures accessible without manufacturing progress in dense error and loading states', () => {
const failed = renderDense({ status: 'error', rows: [], error: 'upstream diagnostic details' });
expect(failed).toContain('<details class="credential-quota-error-details">');
expect(failed).toContain('title="upstream diagnostic details"');
expect(failed).toContain('<small>upstream diagnostic details</small>');
expect(failed).not.toContain('role="progressbar"');
const pending = renderDense({ status: 'loading', rows: [], pendingAction: 'reset' });
expect(pending).toContain('aria-busy="true"');
expect(pending).toContain('indeterminate');
expect(pending).not.toContain('aria-valuenow');
});
@@ -0,0 +1,56 @@
import { expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileRequestStatus } from '../src/components/AuthFileRequestStatus';
import { I18nProvider } from '../src/i18n';
const render = (file: Record<string, unknown>, summary = true, compact = false) =>
renderToStaticMarkup(<I18nProvider><AuthFileRequestStatus file={file} summary={summary} compact={compact} /></I18nProvider>);
it('keeps cumulative counters separate from the recent rate and preserves interval labels', () => {
const html = render({ success: 900, failed: 100, recent_requests: [
{ time: '23:50-00:00', success: 3, failed: 1 },
{ time: '00:00-00:10', success: 0, failed: 2 },
] });
expect(html).toContain('auth-file-requests-summary');
expect(html).toContain('>Success 900</span>');
expect(html).toContain('>Failed 100</span>');
expect(html).toContain('aria-label="Success rate 50%"');
expect(html.match(/<button /g)).toHaveLength(20);
expect(html).toContain('aria-label="23:50-00:00 · Success 3 · Failed 1 · Success rate 75%"');
expect(html).toContain('aria-label="00:00-00:10 · Success 0 · Failed 2 · Success rate 0%"');
expect(html).not.toContain('class="auth-file-requests-window"');
});
it('distinguishes missing statistics, known zero counters, and an actual zero percent success rate', () => {
const unknown = render({});
expect(unknown).toContain('>Success —</span>');
expect(unknown).toContain('>Failed —</span>');
expect(unknown).toContain('The core did not provide recent request statistics');
expect(unknown).toContain('aria-label="Success rate —"');
expect(unknown).not.toContain('<button ');
const empty = render({ success: 0, failed: 0, recent_requests: [] });
expect(empty).toContain('>Success 0</span>');
expect(empty).toContain('>Failed 0</span>');
expect(empty).toContain('aria-label="Success rate —"');
expect(empty.match(/<button /g)).toHaveLength(20);
const failed = render({ success: 0, failed: 2, recent_requests: [{ time: '10:00-10:10', success: 0, failed: 2 }] });
expect(failed).toContain('aria-label="Success rate 0%"');
expect(failed).toContain('background-color:rgb(239, 68, 68)');
});
it('preserves existing full and compact layouts while making summary take precedence', () => {
const file = { success: 2, failed: 0, recent_requests: [{ time: '10:00-10:10', success: 2, failed: 0 }] };
const full = render(file, false);
expect(full).toContain('class="auth-file-requests-window"');
expect(full).toContain('auth-file-requests-rate success');
const compact = render(file, false, true);
expect(compact).toContain('auth-file-requests-compact');
expect(compact).toContain('auth-file-requests-latest');
expect(compact).not.toContain('auth-file-requests-rate');
const summary = render(file, true, true);
expect(summary).toContain('auth-file-requests-summary');
expect(summary).not.toContain('auth-file-requests-compact');
expect(summary).toContain('aria-label="Success rate 100%"');
});
+52
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import {
authFileCooldownResetIndex,
changedOAuthAuthFileNames,
dedupeAuthFiles,
isOAuthCredentialFile,
@@ -41,6 +42,57 @@ describe('认证文件列表规范化', () => {
expect(files[0].account_type).toBeUndefined();
expect(isOAuthCredentialFile(files[0])).toBe(true);
});
const cooldown = {
scope: 'model', model_key: 'model-b', reason: 'quota',
retry_at: '2040-01-01T00:00:00Z', remaining_seconds: 30,
};
it('keeps different auth indexes separate when filenames match', () => {
const disk = { name: 'same.json', auth_index: 'A', source: 'file', path: '/synthetic/a', disabled: false };
const runtime = { name: 'same.json', auth_index: 'B', runtime_only: true, cooldowns: [cooldown] };
for (const entries of [[disk, runtime], [runtime, disk]]) {
const files = dedupeAuthFiles(entries);
expect(files).toHaveLength(2);
expect(files.find((file) => authFileCooldownResetIndex(file) === 'A')).toEqual(disk);
expect(files.find((file) => authFileCooldownResetIndex(file) === 'B')).toEqual(runtime);
}
});
it('merges the same normalized index and preserves atomic empty or unknown cooldowns', () => {
for (const cooldowns of [[], null]) {
const files = dedupeAuthFiles([
{ name: 'same.json', auth_index: 0, source: 'file', path: '/synthetic/a', cooldowns },
{ name: 'same.json', authIndex: ' 0 ', runtime_only: true, email: 'account@example.test', cooldowns: [cooldown] },
{ name: 'same.json', auth_index: 'B', runtime_only: true, cooldowns: [cooldown] },
]);
expect(files).toHaveLength(2);
const sameIdentity = files.find((file) => authFileCooldownResetIndex(file) === '0')!;
expect(sameIdentity.path).toBe('/synthetic/a');
expect(sameIdentity.email).toBe('account@example.test');
expect(sameIdentity.runtime_only).toBeUndefined();
expect(sameIdentity.cooldowns).toEqual(cooldowns);
expect(files.find((file) => authFileCooldownResetIndex(file) === 'B')?.cooldowns).toEqual([cooldown]);
}
});
it('keeps unindexed records separate when the filename has conflicting identities', () => {
const files = dedupeAuthFiles([
{ name: 'same.json', source: 'file', path: '/synthetic/unknown', cooldowns: [cooldown] },
{ name: 'same.json', auth_index: 'A', runtime_only: true, cooldowns: [] },
{ name: 'same.json', authIndex: 'B', runtime_only: true, cooldowns: null },
]);
expect(files).toHaveLength(3);
const unknown = files.find((file) => authFileCooldownResetIndex(file) === undefined)!;
expect(unknown.path).toBe('/synthetic/unknown');
expect(unknown.cooldowns).toEqual([cooldown]);
for (const index of ['A', 'B']) {
const file = files.find((entry) => authFileCooldownResetIndex(entry) === index)!;
expect(file.path).toBeUndefined();
expect(file.runtime_only).toBe(true);
expect(file.cooldowns).toEqual(index === 'A' ? [] : null);
}
});
});
const oauthFile = { name: 'codex-user.json', provider: 'codex', source: 'file', account_type: 'oauth' };
+12 -1
View File
@@ -27,6 +27,7 @@ const harnessModels=[
const harnessSnapshot=()=>({revision:String(harnessRevision),provider:harnessProvider,baseUrl:'http://127.0.0.1:8317/v1',defaultModel:currentModel,configured:!!currentModel,models:harnessModels.map(m=>({...m,configuration:harnessConfigurations[m.id]??{}}))});
let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null};
let appliedCount=0;
let detectionCount=0;
(window as any).fixtureOauthLoggedIn=!params.has('no-oauth-login');
const calls:any[]=[];(window as any).fixtureCalls=calls;
let embedded=params.has('embedded');
@@ -35,7 +36,17 @@ mockIPC(async (cmd,args:any) => {
calls.push({cmd,args});
if(cmd==='plugin:event|listen') return 1;
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed')&&params.get('config-only')!==id,pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')||params.get('config-only')===id?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configExists:!params.has('not-installed')||params.get('config-only')===id,configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status);
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') {
if(params.has('fail-detection')&&++detectionCount===1)throw new Error('模拟客户端检测失败');
return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed')&&params.get('config-only')!==id,pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')||params.get('config-only')===id?[]:['claude-desktop','zcode','workbuddy'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configExists:!params.has('not-installed')||params.get('config-only')===id,configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null}))
.map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status)
.map(status=>{
if(!params.has('mixed-clients'))return status;
const installed=['claude-code','codex','opencode'].includes(status.id);
return {...status,installed,pluginInstalled:status.id==='pi',configExists:installed||status.id==='claude-desktop',configured:installed&&!!currentModel,configurationSynchronized:installed&&!!currentModel,connectionState:installed&&currentModel?'configured':'not-configured',currentModel:installed?currentModel:null,appliedModel:installed?currentModel:null,modificationEnabled:installed&&!!currentModel,modificationState:installed&&currentModel?'applied':'unconfigured',launchTargets:installed?status.launchTargets:[],version:installed?status.version:null,cliVersion:installed?status.cliVersion:null,pluginVersion:status.id==='pi'?'1.0':null};
})
.map(status=>({...status,...((window as any).fixtureClientStatusesOverride?.[status.id]??{})}));
}
if(cmd==='close_codex_config_modification') {
closeCount++;
if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');
+5
View File
@@ -53,6 +53,11 @@ mockIPC(async (cmd, args) => {
observed_at: new Date().toISOString(),
files: state.files.map(file => ({
provider: 'codex', source: 'file', size: 1024, updated_at: '2026-10-01T00:00:00Z',
recent_requests: Array.from({ length: 20 }, (_, index) => ({
time: `${String(8 + Math.floor(index / 6)).padStart(2, '0')}:${String(index % 6 * 10).padStart(2, '0')}`,
success: index === 19 ? 0 : index === 18 ? 2 : 1,
failed: index === 18 ? 1 : 0,
})),
success: 20, failed: 2, ...file,
...(Array.isArray(file.cooldowns) ? { cooldowns: file.cooldowns.map(record => ({
...record, remaining_seconds: Math.max(1, Number(record.remaining_seconds) - elapsed),
+10 -7
View File
@@ -12,26 +12,29 @@ const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
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: 12 }, (_, index) => ({
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: index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
auth_index: `fixture-account-${index + 1}`, provider: smallList ? ['codex', 'devin', 'xai'][index] : index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
source: 'file', size: 1800 + index * 350, updated_at: '2026-10-01T00:00:00Z',
disabled: index === 2 || index === 6, status: index === 2 || index === 6 ? 'disabled' : 'active',
priority: index % 3, plan_type: index % 3 === 0 ? 'Pro 20x' : 'Team',
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',
note: index === 0 ? 'Fictional account note, visible after opening credential details.' : '',
success: 1200 + index * 2450, failed: 3 + index * 97,
recent_requests: Array.from({ length: 20 }, (_, bucket) => ({
success: smallList ? 0 : 1200 + index * 2450, failed: smallList ? 0 : 3 + index * 97,
recent_requests: Array.from({ length: smallList ? 0 : 20 }, (_, bucket) => ({
time: `${String(8 + Math.floor(bucket / 6)).padStart(2, '0')}:${String(bucket % 6 * 10).padStart(2, '0')}`,
success: bucket === 19 ? 0 : (bucket + index) % 7 + 1,
failed: bucket === 19 || (bucket + index) % 5 ? 0 : 2,
})),
cooldowns: index === 1 ? [{ scope: 'model', model_key: 'gpt-fictional-layout', reason: 'quota',
cooldowns: !smallList && index === 1 ? [{ scope: 'model', model_key: 'gpt-fictional-layout', reason: 'quota',
retry_at: new Date(startedAt + 180000).toISOString(), remaining_seconds: 180, http_status: 429, backoff_level: 0 }] : [],
}));
updateQuotaCache(Object.fromEntries(files.map((file, index) => {
if (smallList) 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,
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Plugin OAuth providers fixture</title></head><body><div id="root"></div><script type="module" src="./plugin-oauth-providers.tsx"></script></body></html>
+99
View File
@@ -0,0 +1,99 @@
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { OAuthLoginPage } from '../../src/pages/ManagementPages';
import { notifyPluginResourcesChanged } from '../../src/services/pluginResources';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en');
localStorage.setItem('easy-cli-proxy-api.oauth-browser.v3', 'none');
document.documentElement.dataset.theme = params.get('theme') ?? 'light';
const plugin = (id: string, name: string, extra: Record<string, unknown> = {}) => ({
id, path: `/plugins/${id}`, configured: true, registered: true,
enabled: true, effective_enabled: true, supports_oauth: true, oauth_provider: id,
supports_quota: false, logo: '', menus: [], config_fields: [],
metadata: { name, version: '1.0.0', author: 'Fixture', logo: '', config_fields: [] },
...extra,
});
const installedPlugins = [plugin('zcode', 'ZCode'), plugin('codebuddy', 'CodeBuddy')];
const filteredPlugins = [
plugin('disabled', 'Disabled provider', { enabled: false, effective_enabled: false }),
plugin('unregistered', 'Unregistered provider', { registered: false, effective_enabled: false }),
plugin('non-oauth', 'Non OAuth provider', { supports_oauth: false }),
plugin('invalid', 'Invalid provider', { oauth_provider: '../unsafe' }),
plugin('missing', 'Missing provider', { oauth_provider: '' }),
plugin('zcode-duplicate', 'Duplicate ZCode', { oauth_provider: 'zcode' }),
plugin('builtin-duplicate', 'Duplicate Codex', { oauth_provider: 'codex' }),
];
const fixture = {
calls: [] as { cmd: string; args: Record<string, unknown> }[],
plugins: params.has('empty') ? [] : params.has('pendingRegistration')
? [plugin('zcode', 'ZCode', { registered: false, effective_enabled: false })]
: [...installedPlugins, ...filteredPlugins],
installedPlugins,
listCalls: 0,
holdNextList: false,
releaseList: null as (() => void) | null,
releasedLists: 0,
starts: 0,
status: 'wait',
cancelled: [] as string[],
refresh: notifyPluginResourcesChanged,
};
Object.assign(window, { pluginOAuthProvidersFixture: fixture });
const nativeSetTimeout = window.setTimeout.bind(window);
window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) =>
nativeSetTimeout(handler, timeout === 3000 ? 25 : timeout, ...args)) as typeof window.setTimeout;
mockIPC(async (cmd, unknownArgs) => {
const args = (unknownArgs ?? {}) as Record<string, unknown>;
fixture.calls.push({ cmd, args });
if (cmd === 'set_app_locale') return null;
if (cmd === 'list_oauth_browsers') return [
{ id: 'default', label: 'System Default' }, { id: 'edge', label: 'Microsoft Edge' },
];
if (cmd === 'get_plugin_support') return !params.has('unsupported');
if (cmd === 'start_oauth_login') {
const state = `plugin-session-${++fixture.starts}`;
const device = args.provider === 'codebuddy';
return {
url: `https://login.example.test/authorize?state=${state}`,
state, userCode: device ? 'BUDDY-CODE' : null,
flow: device ? 'device' : 'authorization_code', expiresIn: 900, opened: false,
};
}
if (cmd === 'get_oauth_status') return { status: fixture.status };
if (cmd === 'submit_oauth_callback' || cmd === 'open_oauth_url') return null;
if (cmd === 'management_request') {
const request = args.request as { method: string; path: string; query?: Record<string, string> };
if (request.path === '/plugins') {
fixture.listCalls += 1;
if (params.has('listError')) throw new Error('Fixture plugin list unavailable');
const response = {
plugins_enabled: true, plugins_dir: '/plugins', plugins: structuredClone(fixture.plugins),
};
if (fixture.holdNextList) {
fixture.holdNextList = false;
await new Promise<void>(resolve => {
fixture.releaseList = () => { fixture.releaseList = null; fixture.releasedLists += 1; resolve(); };
});
}
return response;
}
if (request.method === 'DELETE' && request.path === '/oauth/session') {
fixture.cancelled.push(request.query?.state ?? '');
return { status: 'ok', cancelled: true };
}
if (request.path === '/credentials') return { files: [] };
throw new Error(`Unexpected management request: ${request.method} ${request.path}`);
}
throw new Error(`Unexpected command: ${cmd}`);
}, { shouldMockEvents: true });
createRoot(document.getElementById('root')!).render(
<I18nProvider><main style={{ padding: 20 }}><OAuthLoginPage /></main></I18nProvider>,
);
+46
View File
@@ -316,6 +316,52 @@ const base = process.env.HOME_DASHBOARD_TEST_BASE_URL || 'http://127.0.0.1:1421'
await page.screenshot({ path: `misc/home-health-dialog-app-${theme}-${width}.png`, animations: 'disabled' });
if (theme === 'light' && width === 1280) await page.screenshot({ path: 'misc/home-health-dialog-app.png', animations: 'disabled' });
}
// The normal desktop dashboard must fit the usable viewport, including the
// shorter CSS viewport produced by desktop display scaling.
const desktopViewports = [
{ width: 1464, height: 845 },
{ width: 1280, height: 800 },
{ width: 1440, height: 900 },
{ width: 1280, height: 720 },
];
for (const locale of ['zh-CN', 'en']) {
for (const theme of ['light', 'dark']) {
await page.setViewportSize(desktopViewports[0]);
await page.evaluate(({ locale, theme }) => {
localStorage.setItem('easy-cli-proxy-api.locale', locale);
localStorage.setItem('easy-cli-proxy-api.theme', theme);
}, { locale, theme });
await page.goto(`${base}/?mock=running`, { waitUntil: 'domcontentloaded' });
await page.locator('.home-overview[aria-busy="false"]').waitFor();
const toolbar = page.locator('#browser-mock-toolbar');
if (await toolbar.count()) await toolbar.evaluate(node => { node.style.display = 'none'; });
assert.equal(await page.locator('html').getAttribute('lang'), locale);
assert.equal(await page.locator('html').getAttribute('data-theme'), theme);
for (const viewport of desktopViewports) {
await page.setViewportSize(viewport);
const layout = await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
window.scrollTo(0, 0);
const root = document.scrollingElement;
const cards = [...document.querySelectorAll('.home-top-grid > .panel, .home-stat-card')].map(node => {
const bounds = node.getBoundingClientRect();
return { name: node.getAttribute('data-stat') || node.className, top: bounds.top, bottom: bounds.bottom, height: bounds.height };
});
return { scrollHeight: root.scrollHeight, clientHeight: root.clientHeight, viewportHeight: innerHeight, cards };
});
const context = `${locale}/${theme}/${viewport.width}x${viewport.height}`;
assert.equal(layout.cards.length, 6, `${context} must show both top panels and all four overview cards`);
assert.ok(layout.scrollHeight <= layout.clientHeight + 1,
`${context} homepage causes a vertical page scrollbar: ${layout.scrollHeight}px content in ${layout.clientHeight}px viewport`);
for (const card of layout.cards) {
assert.ok(card.height > 0 && card.top >= -1 && card.bottom <= layout.viewportHeight + 1,
`${context} ${card.name} must be fully visible without scrolling: ${JSON.stringify(card)}`);
}
}
}
}
console.log('Desktop homepage fits all four viewport sizes in Chinese/English and light/dark themes without vertical page overflow.');
assert.deepEqual(errors, []);
console.log('Home dashboard browser checks passed. Screenshots: misc/home-{dashboard,health-dialog}-{light,dark}-{1280,640,390}.png and app.png');
} finally {
+174
View File
@@ -0,0 +1,174 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
(async () => {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
const server = await createServer({
configFile: false, root: path.resolve(__dirname, '..'),
cacheDir: 'node_modules/.vite-plugin-oauth-providers-test', plugins: [react()],
logLevel: 'error', optimizeDeps: { entries: ['tests/fixtures/plugin-oauth-providers.html'] },
server: { host: '127.0.0.1', port: 1436, strictPort: false, watch: null },
});
let browser;
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1366, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
const open = async (query = '') => {
await page.goto(`${base}/tests/fixtures/plugin-oauth-providers.html?${query}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('heading', { name: 'Codex OAuth', exact: true }).waitFor();
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.calls.some(call => call.cmd === 'get_plugin_support'));
await page.waitForFunction(() => document.querySelector('.oauth-browser-picker select')?.disabled === false);
};
const card = name => page.locator('.oauth-card').filter({ has: page.getByRole('heading', { name, exact: true }) });
const calls = cmd => page.evaluate(command => window.pluginOAuthProvidersFixture.calls.filter(call => call.cmd === command), cmd);
const settle = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const screenshot = async (name, fullPage = true) => {
const directory = process.env.PLUGIN_OAUTH_PROVIDERS_SCREENSHOT_DIR;
if (!directory) return;
fs.mkdirSync(directory, { recursive: true });
await page.screenshot({ path: path.join(directory, name), fullPage });
};
await open();
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
assert.equal(await page.locator('.oauth-card').count(), 9, 'seven built-ins plus two eligible plugin providers');
for (const name of ['Disabled provider', 'Unregistered provider', 'Non OAuth provider', 'Invalid provider', 'Missing provider', 'Duplicate ZCode', 'Duplicate Codex']) {
assert.equal(await page.getByRole('heading', { name: `${name} OAuth`, exact: true }).count(), 0, `${name} must not become a provider card`);
}
await screenshot('plugin-oauth-providers-light-desktop.png');
await page.getByLabel('Choose Browser', { exact: true }).selectOption('edge');
const zcodeLogin = card('ZCode OAuth').getByRole('button', { name: 'Start Sign-In', exact: true });
await zcodeLogin.focus();
await page.keyboard.press('Enter');
await page.getByRole('dialog', { name: 'Plugin sign-in', exact: true }).waitFor();
await page.getByLabel('Sign-in URL', { exact: true }).waitFor();
assert.deepEqual((await calls('start_oauth_login'))[0].args, { provider: 'zcode', browser: 'none', pluginProvider: true });
await page.getByRole('button', { name: 'Open in browser', exact: true }).click();
assert.deepEqual((await calls('open_oauth_url'))[0].args, {
url: 'https://login.example.test/authorize?state=plugin-session-1', browser: 'edge',
});
for (let index = 0; index < 10; index += 1) {
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => Boolean(document.activeElement?.closest('[role="dialog"]'))), true);
}
await page.keyboard.press('Escape');
await page.getByRole('dialog').waitFor({ state: 'detached' });
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.cancelled.includes('plugin-session-1'));
assert.equal(await zcodeLogin.evaluate(element => element === document.activeElement), true);
await card('CodeBuddy OAuth').getByRole('button', { name: 'Start Sign-In', exact: true }).click();
await page.getByText('BUDDY-CODE', { exact: true }).waitFor();
assert.deepEqual((await calls('start_oauth_login'))[1].args, { provider: 'codebuddy', browser: 'none', pluginProvider: true });
assert.equal(await page.getByLabel('Callback URL', { exact: true }).count(), 0, 'device flow must not show callback input');
await page.keyboard.press('Escape');
await page.getByRole('dialog').waitFor({ state: 'detached' });
// Reloading provider metadata must expose newly installed plugins and remove disabled ones.
await open('empty');
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.listCalls === 1);
assert.equal(await page.locator('.oauth-card').count(), 7);
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = structuredClone(fixture.installedPlugins);
fixture.refresh();
});
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = fixture.plugins.map(plugin => plugin.id === 'zcode' ? { ...plugin, enabled: false, effective_enabled: false } : plugin);
fixture.refresh();
});
await card('ZCode OAuth').waitFor({ state: 'detached' });
assert.equal(await card('CodeBuddy OAuth').count(), 1);
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = structuredClone(fixture.installedPlugins);
});
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await card('ZCode OAuth').waitFor();
// A previous in-flight list response cannot restore an already disabled provider.
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.holdNextList = true;
fixture.refresh();
});
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.releaseList !== null);
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = [];
fixture.refresh();
});
await card('CodeBuddy OAuth').waitFor({ state: 'detached' });
await page.evaluate(() => window.pluginOAuthProvidersFixture.releaseList());
await settle();
assert.equal(await page.locator('.oauth-card').count(), 7, 'stale provider response must be ignored');
// Refocusing the window discovers changes made outside this page.
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = structuredClone(fixture.installedPlugins);
window.dispatchEvent(new Event('focus'));
});
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
// Plugin registration can finish after navigating away from the installer.
// The provider should appear from the bounded retry without another event.
await open('pendingRegistration');
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.listCalls === 1);
await settle();
assert.equal(await card('ZCode OAuth').count(), 0, 'pending plugin is not ready for login');
await page.evaluate(() => {
const fixture = window.pluginOAuthProvidersFixture;
fixture.plugins = [structuredClone(fixture.installedPlugins[0])];
});
await card('ZCode OAuth').waitFor();
assert.ok(await page.evaluate(() => window.pluginOAuthProvidersFixture.listCalls > 1), 'registration must be refreshed automatically');
assert.equal(await page.locator('.oauth-card').count(), 8);
await open('unsupported');
await settle();
assert.equal(await page.locator('.oauth-card').count(), 7);
assert.equal(await page.evaluate(() => window.pluginOAuthProvidersFixture.listCalls), 0, 'unsupported kernel must not request plugins');
await open('listError');
await page.waitForFunction(() => window.pluginOAuthProvidersFixture.listCalls > 0);
await settle();
assert.equal(await page.locator('.oauth-card').count(), 7, 'plugin failure must leave built-in login available');
assert.equal(await card('Codex OAuth').getByRole('button', { name: 'Start Sign-In', exact: true }).isEnabled(), true);
await page.setViewportSize({ width: 390, height: 844 });
await open('theme=dark&locale=zh-CN');
await card('ZCode OAuth').waitFor();
await card('CodeBuddy OAuth').waitFor();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await screenshot('plugin-oauth-providers-dark-mobile.png');
await card('CodeBuddy OAuth').getByRole('button', { name: '开始登录', exact: true }).click();
await page.getByText('BUDDY-CODE', { exact: true }).waitFor();
const box = await page.getByRole('dialog').boundingBox();
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844);
await screenshot('plugin-oauth-providers-dark-mobile-dialog.png', false);
assert.deepEqual(errors, []);
console.log('Plugin OAuth provider UI passed: dynamic visibility, eligibility/deduplication, refresh/install/disable, stale responses, native plugin login, focus trap/cancellation, unsupported kernels and responsive layout.');
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await browser?.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'bun:test';
import { collectPluginOAuthProviders } from '../src/services/pluginOAuthProviders';
import { getPluginTitle } from '../src/services/pluginResources';
import { normalizePluginList, type PluginListEntry } from '../src/services/plugins';
function rawPlugin(id: string, provider: string, overrides: Record<string, unknown> = {}) {
return {
id,
path: `plugins/windows/amd64/${id}.dll`,
configured: true,
registered: true,
enabled: true,
effective_enabled: true,
supports_oauth: true,
oauth_provider: provider,
supports_quota: false,
quota_provider: '',
logo: '',
config_fields: [],
menus: [],
metadata: null,
...overrides,
};
}
function normalize(plugins: unknown[]): PluginListEntry[] {
return normalizePluginList({
plugins_enabled: true,
plugins_dir: 'plugins',
plugins,
}).plugins;
}
describe('plugin OAuth provider discovery', () => {
it('shows ZCode and CodeBuddy from the backend capabilities without menus or quota support', () => {
const plugins = normalize([
rawPlugin('zcode', 'zcode', { metadata: { name: 'ZCode', version: '1.0.0' } }),
rawPlugin('codebuddy', 'codebuddy', { metadata: { name: 'CodeBuddy', version: '1.0.0' } }),
]);
const providers = collectPluginOAuthProviders(plugins);
expect(providers.map(plugin => plugin.oauthProvider)).toEqual(['zcode', 'codebuddy']);
expect(providers.map(getPluginTitle)).toEqual(['ZCode', 'CodeBuddy']);
expect(providers.every(plugin => plugin.menus.length === 0 && !plugin.supportsQuota)).toBe(true);
expect(providers[0]).toBe(plugins[0]);
expect(providers[1]).toBe(plugins[1]);
});
it('only includes effective plugins that declare OAuth and a valid provider key', () => {
const plugins = normalize([
rawPlugin('active', 'zcode'),
rawPlugin('disabled', 'disabled', { enabled: false, effective_enabled: false }),
rawPlugin('not-loaded', 'not-loaded', { registered: false, effective_enabled: false }),
rawPlugin('no-oauth', 'no-oauth', { supports_oauth: false }),
rawPlugin('missing-provider', ''),
rawPlugin('invalid-provider', '../config'),
]);
expect(collectPluginOAuthProviders(plugins).map(plugin => plugin.id)).toEqual(['active']);
});
it('rejects invalid provider keys even if an entry reaches the helper without normalization', () => {
const [plugin] = normalize([rawPlugin('active', 'zcode')]);
const invalidProviders = [undefined, '', ' ', '../config', 'a/b', 'a?b', 'https://provider.test'];
expect(collectPluginOAuthProviders(invalidProviders.map(oauthProvider => ({
...plugin, oauthProvider,
})))).toEqual([]);
});
it('deduplicates by OAuth provider and excludes built-ins rather than using plugin registration IDs', () => {
const plugins = normalize([
rawPlugin('disabled-copy', 'zcode', { effective_enabled: false }),
rawPlugin('zcode-auth-extension', 'zcode'),
rawPlugin('another-zcode-extension', 'zcode'),
rawPlugin('codex-auth-extension', 'codex'),
rawPlugin('claude-auth-extension', 'claude'),
rawPlugin('codebuddy-auth-extension', 'codebuddy'),
]);
expect(collectPluginOAuthProviders(plugins, ['codex', 'claude']).map(plugin => ({
id: plugin.id, provider: plugin.oauthProvider,
}))).toEqual([
{ id: 'zcode-auth-extension', provider: 'zcode' },
{ id: 'codebuddy-auth-extension', provider: 'codebuddy' },
]);
});
it('accepts future backend-declared providers without a provider name allowlist', () => {
const plugins = normalize([
rawPlugin('custom-auth-extension', ' Future-Provider-42 ', {
metadata: { name: 'Future Provider' },
}),
]);
const providers = collectPluginOAuthProviders(plugins, ['codex', 'claude']);
expect(providers).toHaveLength(1);
expect(providers[0].oauthProvider).toBe('future-provider-42');
expect(getPluginTitle(providers[0])).toBe('Future Provider');
});
it('keeps a usable plugin title when metadata is absent or its name is blank', () => {
const providers = collectPluginOAuthProviders(normalize([
rawPlugin('zcode', 'zcode'),
rawPlugin('codebuddy', 'codebuddy', { metadata: { name: ' ' } }),
]));
expect(providers.map(getPluginTitle)).toEqual(['zcode', 'codebuddy']);
});
});
+9 -12
View File
@@ -71,19 +71,16 @@ const viewports = [
};
const assertRequestToolbar = async label => {
const metrics = await page.locator('.usage-events-panel').evaluate(panel => {
const summary = panel.querySelector('.usage-events-summary');
const table = panel.querySelector('.usage-table-wrap');
const scrollbar = panel.querySelector('.usage-table-top-scrollbar');
const footer = panel.querySelector('.usage-events-footer');
const pagination = panel.querySelector('.usage-pagination-controls');
const summaryRect = summary.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
const scrollbarRect = scrollbar.getBoundingClientRect();
const footerRect = footer.getBoundingClientRect();
const paginationRect = pagination.getBoundingClientRect();
const controls = [...pagination.querySelectorAll('button, select, .usage-pagination-info')];
const controls = [...footer.querySelectorAll('button, select, .usage-pagination-info')];
return {
summaryBottom: summaryRect.bottom,
panelTop: panelRect.top,
tableTop: tableRect.top,
tableBottom: tableRect.bottom,
scrollbarTop: scrollbarRect.top,
@@ -92,18 +89,18 @@ const viewports = [
controlsContained: controls.every(control => {
const rect = control.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
&& rect.left >= paginationRect.left - 1 && rect.right <= paginationRect.right + 1
&& rect.top >= paginationRect.top - 1 && rect.bottom <= paginationRect.bottom + 1
&& rect.left >= footerRect.left - 1 && rect.right <= footerRect.right + 1
&& rect.top >= footerRect.top - 1
&& rect.bottom <= footerRect.bottom + 1;
}),
};
});
assert.ok(metrics.summaryBottom <= metrics.tableTop + 1, `${label}: request heading and filters must remain above the table`);
assert.ok(Math.abs(metrics.tableTop - metrics.panelTop) <= 1, `${label}: the request table starts directly at the top of the panel`);
assert.ok(metrics.tableBottom <= metrics.scrollbarTop + 1, `${label}: the horizontal scrollbar must remain below the table`);
assert.ok(metrics.scrollbarBottom <= metrics.footerTop + 1, `${label}: the scrollbar must not overlap the pagination footer`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their footer`);
for (const control of await page.locator('.usage-pagination-controls button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} pagination`);
assert.equal(metrics.controlsContained, true, `${label}: table actions and pagination controls must fit completely inside their footer`);
for (const control of await page.locator('.usage-events-footer button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} footer controls`);
}
const scrolled = await page.locator('.usage-events-panel .usage-table-wrap').evaluate(table => {
table.scrollLeft = table.scrollWidth;
+2 -2
View File
@@ -164,7 +164,7 @@ function parseCsv(text) {
await page.locator('.usage-page-size-select').selectOption('200');
await page.waitForFunction(() => document.querySelectorAll('.usage-events-table tbody tr').length === 200);
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 1);
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 0, 'The redundant request log heading is removed');
const firstRow = page.locator('.usage-events-table tbody tr').first();
assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again');
assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000');
@@ -175,7 +175,7 @@ function parseCsv(text) {
assert.equal(await firstRow.locator('.tone-cache-write').textContent(), '50');
assert.equal(await firstRow.locator('.usage-td-time small').count(), 1, 'The request date remains visible below its time');
assert.ok((await firstRow.locator('.usage-td-latency small').textContent()).includes('200'), 'The latency cell includes first-token latency');
assert.equal(await page.locator('.usage-events-summary .usage-page-size-select').count(), 0);
assert.equal(await page.locator('.usage-events-summary').count(), 0, 'There is no separate toolbar above the request table');
assert.equal(await page.locator('.usage-events-footer .usage-page-size-select').count(), 1, 'Pagination remains in the bottom footer');
// Multiple input updates can arrive before the next animation frame. None