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:
1 parent
cc24f8419f
commit
3b11361f1b
46 files changed
+2301
-418
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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': '操作',
|
||||
|
||||
@@ -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': 'インストールを検出できません',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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.', '認証が完了し、認証情報が保存されました。'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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); } }
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!))}
|
||||
/>}
|
||||
</>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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) ?? '');
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,4 @@
|
||||
@import './responsive-settings.css';
|
||||
@import './config-settings.css';
|
||||
@import '../pages/UsageEventsView.css';
|
||||
@import '../pages/AgentClientList.css';
|
||||
@@ -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); });
|
||||
@@ -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,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 => {
|
||||
|
||||
@@ -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
@@ -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; });
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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%"');
|
||||
});
|
||||
@@ -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' };
|
||||
|
||||
Vendored
+12
-1
@@ -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')&¶ms.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'&¤tOauth,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')&¶ms.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'&¤tOauth,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&¤tModel?'configured':'not-configured',currentModel:installed?currentModel:null,appliedModel:installed?currentModel:null,modificationEnabled:installed&&!!currentModel,modificationState:installed&¤tModel?'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
@@ -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),
|
||||
|
||||
Vendored
+10
-7
@@ -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
@@ -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
@@ -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>,
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; });
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user