fix(ui): improve responsive layouts and refine API access page

- Fix overflow, clipped controls, and toolbar overlap in narrow windows
- Improve responsive layouts across usage, OAuth, agents, settings, and dialogs
- Unify API category navigation and compact provider rows and actions
- Preserve inherited provider priorities instead of displaying zero
- Add responsive layout regression tests and dense provider fixtures
- Bump proxy core version from 8.0.6 to 8.0.10
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent c7d58128b9
commit 5a5eab96e2
17 files changed
+1798 -153

No files matched your search

+1 -1
View File
@@ -1 +1 @@
8.0.6
8.0.10
+2
View File
@@ -10,6 +10,8 @@
"preview": "vite preview",
"test:ui:accessibility": "node tests/app-shell-accessibility-ui.cjs",
"test:ui:api-provider-dialog": "node tests/api-provider-dialog-layout-ui.cjs",
"test:ui:api-access-layout": "node tests/api-access-layout-ui.cjs",
"test:ui:responsive": "node tests/responsive-layout-ui.cjs",
"screenshots": "node scripts/capture-readme-screenshots.cjs",
"tauri": "tauri"
},
+1 -1
View File
@@ -2058,7 +2058,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
disabled={busy}
>
<span className="agent-client-icon"><AgentMark definition={agent} /></span>
<span><strong>{agent.name}</strong><small>{listStatusText(status)}</small></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}
+16 -17
View File
@@ -336,7 +336,7 @@ const rowFromRecord = (
disabled: grouped ? providerGroupStatus(record) === 'disabled' : definitionFor(section).openAi
? readBoolean(record, 'disabled')
: excludedModels.some((model) => model.trim() === '*'),
priority: readNumber(record, 'priority'),
priority: record.priority == null ? null : readNumber(record, 'priority'),
authIndex: entry
? readString(entry, 'auth-index', 'authIndex')
: readString(record, 'auth-index', 'authIndex'),
@@ -1477,6 +1477,7 @@ export function ApiAccessPage() {
type="button"
key={definition.id}
className={definition.id === activeCategory ? 'active' : ''}
aria-pressed={definition.id === activeCategory}
onClick={() => {
setActiveCategory(definition.id);
feedback.clearNotice();
@@ -1496,6 +1497,10 @@ export function ApiAccessPage() {
<h2 title={t(activeDefinition.labelKey)}>{t(activeDefinition.labelKey)}</h2>
<span>{t('apiAccess.groups.summary', { count: rows.length })}</span>
</div>
<div className="management-toolbar compact-toolbar api-access-search">
<Search size={16} aria-hidden="true" />
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('apiAccess.search')} aria-label={t('apiAccess.search')} />
</div>
<div className="api-access-heading-tools">
<button type="button" className="secondary-button compact-button" onClick={() => void loadProviders()} disabled={loading || busy}>
<RefreshCw size={16} aria-hidden="true" />
@@ -1505,10 +1510,6 @@ export function ApiAccessPage() {
<Plus size={16} aria-hidden="true" />
{t('apiAccess.groups.add')}
</button>
<div className="management-toolbar compact-toolbar">
<Search size={16} aria-hidden="true" />
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('apiAccess.search')} />
</div>
</div>
</div>
@@ -1545,21 +1546,20 @@ export function ApiAccessPage() {
<div className="provider-row-main">
<div className="provider-row-title">
<strong title={row.name}>{row.name}</strong>
{row.remark ? <span>{row.remark}</span> : null}
{providerGroupStatus(row.record) === 'partial' ? <span className="state-pill">{t('apiAccess.groups.partial')}</span> : null}
{row.remark ? <span title={row.remark}>{row.remark}</span> : null}
</div>
<code title={t('apiAccess.keys.count', { count: row.apiKeys.length })}>
{row.apiKeys.length > 1
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
: maskSecret(row.apiKey)}
</code>
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
{row.models.length > 0 ? <span className="provider-row-models">{t('apiAccess.models.summary', { count: row.models.length })}</span> : null}
</div>
{row.priority === null ? null : (
<div className="provider-row-meta">
<span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>
<code title={t('apiAccess.keys.count', { count: row.apiKeys.length })}>
{row.apiKeys.length > 1
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
: maskSecret(row.apiKey)}
</code>
{row.models.length > 0 ? <span className="provider-row-models">{t('apiAccess.models.summary', { count: row.models.length })}</span> : null}
{row.priority === null ? null : <span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>}
</div>
)}
</div>
<div className="provider-row-actions">
<button
type="button"
@@ -1582,7 +1582,6 @@ export function ApiAccessPage() {
<span className="switch-track" />
</span>
</label>
{providerGroupStatus(row.record) === 'partial' ? <span className="state-pill">{t('apiAccess.groups.partial')}</span> : null}
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title={t('common.edit')} aria-label={t('common.edit')}>
<Edit3 size={16} aria-hidden="true" />
</button>
+22 -18
View File
@@ -2616,22 +2616,26 @@ function PricingView({
</span>
</div>
<div className="usage-pricing-actions">
<input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={t('usage.pricing.search')}
aria-label={t('usage.pricing.search')}
/>
<button type="button" className="secondary-button" onClick={() => setDraft(emptyPriceDraft())}>
{t('usage.pricing.add')}
</button>
<select value={syncSource} onChange={(event) => changeSyncSource(event.currentTarget.value as 'models-dev' | 'litellm')} aria-label={t('usage.pricing.syncSource')}>
<option value="models-dev">Models.dev</option>
<option value="litellm">LiteLLM</option>
</select>
<button type="button" className="primary-button" disabled={syncing} onClick={() => void previewPrices()}>
{syncing ? t('usage.pricing.syncing') : t('usage.pricing.sync')}
</button>
<div className="usage-pricing-action-group">
<input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={t('usage.pricing.search')}
aria-label={t('usage.pricing.search')}
/>
<button type="button" className="secondary-button" onClick={() => setDraft(emptyPriceDraft())}>
{t('usage.pricing.add')}
</button>
</div>
<div className="usage-pricing-action-group">
<select value={syncSource} onChange={(event) => changeSyncSource(event.currentTarget.value as 'models-dev' | 'litellm')} aria-label={t('usage.pricing.syncSource')}>
<option value="models-dev">Models.dev</option>
<option value="litellm">LiteLLM</option>
</select>
<button type="button" className="primary-button" disabled={syncing} onClick={() => void previewPrices()}>
{syncing ? t('usage.pricing.syncing') : t('usage.pricing.sync')}
</button>
</div>
</div>
</div>
@@ -2749,7 +2753,7 @@ function PricingView({
) : null}
{visibleRows.length ? (
<div className="usage-table-wrap usage-pricing-table-wrap">
<div className="usage-table-wrap usage-pricing-table-wrap" tabIndex={0} role="region" aria-label={t('usage.tab.pricing')}>
<table className="usage-pricing-table">
<thead>
<tr>
@@ -2768,7 +2772,7 @@ function PricingView({
{visibleRows.map((row) => (
<tr key={row.model}>
<td>
<strong>{row.model}</strong>
<strong title={row.model}>{row.model}</strong>
</td>
<td>{compactNumber(row.requests)}</td>
<td>{compactNumber(row.totalTokens)}</td>
+58 -29
View File
@@ -9787,16 +9787,13 @@ body.table-col-resizing * {
overflow: auto;
}
.usage-pricing-panel {
min-height: 0;
overflow: auto;
}
/* Pricing Tab */
.usage-pricing-panel {
display: grid;
display: flex;
min-width: 0;
align-content: start;
min-height: 0;
flex-direction: column;
overflow: hidden;
gap: 14px;
padding: 16px;
border: 1px solid var(--theme-e3e1db);
@@ -9936,6 +9933,7 @@ body.table-col-resizing * {
.usage-pricing-toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 14px;
@@ -9944,6 +9942,7 @@ body.table-col-resizing * {
.usage-pricing-summary {
display: grid;
flex: 1 1 240px;
gap: 2px;
}
@@ -9961,13 +9960,26 @@ body.table-col-resizing * {
.usage-pricing-actions {
display: flex;
min-width: 0;
flex: 1 1 560px;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.usage-pricing-action-group {
display: flex;
min-width: 0;
flex: 1 1 250px;
align-items: center;
gap: 8px;
}
.usage-pricing-actions input,
.usage-pricing-actions select {
min-width: 180px;
width: 100%;
min-width: 0;
flex: 1 1 0;
height: 34px;
padding: 0 10px;
border: 1px solid var(--theme-d5d2cb);
@@ -9979,7 +9991,10 @@ body.table-col-resizing * {
.usage-pricing-actions button,
.usage-price-editor-actions button {
flex: none;
min-height: 34px;
height: 34px;
white-space: nowrap;
}
.usage-price-sync-dialog {
@@ -10035,6 +10050,10 @@ body.table-col-resizing * {
.usage-price-editor {
display: grid;
min-height: 0;
flex: 0 1 auto;
max-height: 50%;
overflow: auto;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
padding: 14px;
@@ -10073,13 +10092,26 @@ body.table-col-resizing * {
}
.usage-pricing-table-wrap {
min-height: 380px;
min-height: 120px;
}
.usage-pricing-table-wrap::-webkit-scrollbar,
.usage-pricing-table-wrap::-webkit-scrollbar:horizontal {
display: block;
width: 8px;
height: 8px;
}
.usage-pricing-table-wrap::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--border-primary);
}
.usage-pricing-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-size-meta);
}
@@ -10105,7 +10137,22 @@ body.table-col-resizing * {
.usage-pricing-table th:first-child,
.usage-pricing-table td:first-child {
text-align: center;
position: sticky;
left: 0;
min-width: 180px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 1px 0 0 var(--border-color);
}
.usage-pricing-table td:first-child {
z-index: 1;
background: var(--bg-primary);
}
.usage-pricing-table th:first-child {
z-index: 2;
}
.usage-price-row-actions {
@@ -11271,24 +11318,6 @@ body.table-col-resizing * {
grid-template-columns: 1fr;
}
.usage-pricing-toolbar {
align-items: stretch;
flex-direction: column;
}
.usage-pricing-actions {
display: grid;
grid-template-columns: 1fr;
}
.usage-pricing-actions input {
width: 100%;
}
.usage-price-editor {
grid-template-columns: 1fr;
}
.thinking-alias-workbench {
grid-template-columns: 1fr;
}
-31
View File
@@ -2976,11 +2976,6 @@
grid-template-columns: 1fr;
}
.usage-topbar {
align-items: stretch;
flex-direction: column;
}
.usage-tabs {
display: flex;
width: 100%;
@@ -2996,42 +2991,16 @@
padding-inline: 12px;
}
.usage-topbar-actions {
width: 100%;
}
.usage-events-summary-left,
.usage-events-summary-right {
width: 100%;
justify-content: space-between;
}
.usage-collector-state {
flex: 1;
}
.usage-analysis-grid {
grid-template-columns: 1fr;
}
.usage-pricing-toolbar {
align-items: stretch;
flex-direction: column;
}
.usage-pricing-actions {
display: grid;
grid-template-columns: 1fr;
}
.usage-pricing-actions input {
width: 100%;
}
.usage-price-editor {
grid-template-columns: 1fr;
}
.auth-files-toolbar {
grid-template-columns: 18px minmax(0, 1fr);
}
+4
View File
@@ -1,3 +1,7 @@
@import '../styles.css';
@import './feature-pages.css';
@import './ui-refinements.css';
@import './responsive-shell-usage.css';
@import './responsive-providers.css';
@import './responsive-agents.css';
@import './responsive-settings.css';
+244
View File
@@ -0,0 +1,244 @@
/* Use the space left for the workbench, including the app sidebar and padding. */
.agents-page:not(.agents-page-embedded) {
container: agent-workspace / inline-size;
}
@container agent-workspace (max-width: 760px) {
.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-workbench {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list {
height: auto;
max-height: none;
gap: 6px;
padding: 10px 12px;
}
.agents-page:not(.agents-page-embedded) .agent-list-items {
display: grid;
flex: none;
grid-auto-flow: column;
grid-auto-columns: 192px;
grid-template-columns: none;
grid-template-rows: auto;
overflow-x: auto;
overflow-y: hidden;
padding: 0 0 4px;
scrollbar-gutter: auto;
scroll-snap-type: x proximity;
}
.agents-page:not(.agents-page-embedded) .agent-list-items button {
min-height: 58px;
scroll-snap-align: start;
}
.agents-page:not(.agents-page-embedded) .agent-list-items strong,
.agents-page:not(.agents-page-embedded) .agent-list-items small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agent-save-bar,
.agent-codex-save-actions,
.agent-signin-label,
.agent-section-heading-actions {
flex-wrap: wrap;
}
.agent-save-bar .agent-save-feedback {
flex-basis: 180px;
}
.agent-save-actions button,
.agent-management-actions button {
max-width: 100%;
}
.agent-section-heading > div:first-child,
.agent-claude-code-disable-compact > span:first-child {
overflow-wrap: anywhere;
}
@container agent-panel (max-width: 640px) {
.agent-desktop-models > .agent-section-heading {
align-items: flex-start;
flex-direction: column;
}
.agent-desktop-models > .agent-section-heading > .agent-section-heading-actions {
width: 100%;
justify-content: flex-start;
}
.agent-desktop-model-fields {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.agent-desktop-model-field:first-child {
grid-column: 1 / -1;
}
.agent-desktop-model-actions {
height: 44px;
justify-self: end;
}
.agent-status-grid,
.agent-status-grid.dual-install-status-grid,
.agent-status-grid.pi-status-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr));
}
.codex-session-overview-head,
.codex-session-list-head {
flex-wrap: wrap;
align-items: flex-start;
}
.codex-session-selection-actions {
justify-content: flex-start;
}
.codex-session-pagination {
flex-wrap: wrap;
}
}
@container agent-panel (max-width: 420px) {
.agent-subpage-tabs {
width: fit-content;
overflow-x: auto;
}
.agent-subpage-tabs button {
flex: none;
width: auto;
padding-inline: 10px;
}
.agent-signin-explanation dl > div {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.codex-session-meta,
.codex-session-row:has(.codex-session-checkbox) .codex-session-meta {
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
}
.codex-session-meta time {
grid-column: 1 / -1;
justify-self: start;
}
}
/* Catalog forms must respond to the editor width, not the whole dialog. */
.codex-catalog-editor {
container: catalog-editor / inline-size;
}
.codex-catalog-sidebar,
.codex-catalog-form > label,
.codex-catalog-models > button {
min-width: 0;
}
.codex-catalog-model-heading,
.codex-catalog-footer,
.codex-catalog-footer > div:last-child,
.codex-catalog-confirm > div > div {
flex-wrap: wrap;
}
.codex-catalog-model-heading > button {
flex: none;
max-width: 100%;
}
.codex-catalog-footer > div:last-child {
margin-left: auto;
}
.codex-catalog-hint,
.codex-catalog-heading p {
overflow-wrap: anywhere;
}
@container catalog-editor (max-width: 500px) {
.codex-catalog-form,
.harness-reasoning-levels {
grid-template-columns: minmax(0, 1fr);
}
}
/* Keep the model list beside the editor on small desktop windows so it does
not consume most of the dialog's available height. */
@media (min-width: 600px) and (max-width: 900px) {
.codex-catalog-body,
.harness-catalog-dialog .codex-catalog-body {
grid-template-columns: 180px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.codex-catalog-sidebar {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 8px;
padding: 10px;
border-right: 1px solid var(--theme-d5d2cb);
border-bottom: 0;
}
.harness-catalog-dialog .codex-catalog-sidebar {
grid-template-rows: auto auto minmax(0, 1fr) auto;
}
.codex-catalog-sidebar > *,
.harness-catalog-dialog .codex-catalog-sidebar > * {
grid-column: 1;
grid-row: auto;
}
.codex-catalog-footer {
flex-direction: row;
gap: 8px;
}
.codex-catalog-footer > div:last-child {
display: flex;
}
}
.agent-backup-modal {
container: agent-backup / inline-size;
}
.agent-backup-modal footer {
flex-wrap: wrap;
}
.agent-backup-columns nav {
min-width: 0;
}
.agent-backup-columns nav button {
overflow-wrap: anywhere;
}
@container agent-backup (max-width: 680px) {
.agent-backup-columns {
grid-template-columns: minmax(0, 1fr);
}
.agent-backup-columns nav {
max-height: 160px;
}
}
+435
View File
@@ -0,0 +1,435 @@
/* Keep API navigation above the list at every width. */
.api-access-page .real-provider-workbench {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.api-access-page .provider-category-panel {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
height: auto;
padding: 8px;
}
.api-access-page .provider-category-panel button {
grid-template-columns: 24px minmax(0, auto) auto;
justify-content: center;
gap: 8px;
height: 50px;
min-height: 50px;
padding: 8px;
border-radius: 8px;
}
.api-access-page .provider-category-panel button.active {
background: var(--ui-accent-soft);
border-color: color-mix(in srgb, var(--ui-accent) 20%, transparent);
color: var(--ui-accent);
}
.api-access-page .provider-category-panel .provider-logo {
width: 24px;
height: 24px;
}
.api-access-page .provider-category-panel span {
white-space: normal;
overflow-wrap: normal;
overflow: hidden;
text-overflow: clip;
}
.api-access-page .provider-category-panel strong {
justify-self: center;
min-width: 22px;
padding: 2px 5px;
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-quaternary);
font-size: 12px;
line-height: 18px;
text-align: center;
}
.api-access-page .provider-category-panel .active strong {
background: color-mix(in srgb, var(--ui-accent) 10%, var(--bg-primary));
color: var(--ui-accent);
}
.api-access-page .provider-resource-panel {
container: provider-resources / inline-size;
min-width: 0;
min-height: 0;
}
.api-access-page .management-panel-heading {
display: grid;
grid-template-columns: auto minmax(180px, 1fr) auto;
grid-template-areas: 'title search actions';
gap: 14px 24px;
align-items: center;
}
.api-access-page .management-panel-heading > div:first-child {
grid-area: title;
text-align: left;
}
.api-access-page .api-access-search {
grid-area: search;
width: 100%;
max-width: 440px;
justify-self: end;
}
.api-access-page .api-access-heading-tools {
grid-area: actions;
display: flex;
width: auto;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.api-access-page .api-access-heading-tools > button {
flex: 0 0 auto;
width: auto;
}
.api-access-page .real-provider-list {
margin-top: 18px;
}
.api-access-page .real-provider-row {
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 104px;
padding: 16px 0;
}
.api-access-page .real-provider-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.api-access-page .provider-drag-handle {
grid-column: 1;
grid-row: 1;
width: 24px;
min-width: 24px;
height: 32px;
padding: 0;
margin: 0;
align-self: start;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.api-access-page .provider-row-main {
grid-column: 2;
grid-row: 1;
gap: 7px;
}
.api-access-page .provider-row-title {
align-items: baseline;
flex-wrap: wrap;
gap: 2px 10px;
}
.api-access-page .provider-row-title strong {
max-width: 100%;
font-size: 15px;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-access-page .provider-row-title > span:not(.state-pill) {
min-width: 0;
max-width: 100%;
overflow: hidden;
color: var(--text-quaternary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-access-page .provider-row-title .state-pill {
min-height: 22px;
padding: 1px 7px;
font-size: 11px;
line-height: 18px;
}
.api-access-page .provider-row-main > .provider-row-url {
overflow: hidden;
font-size: 13px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-access-page .provider-row-meta {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
gap: 6px 12px;
padding: 0;
line-height: 20px;
}
.api-access-page .provider-row-meta code {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
color: var(--text-quaternary);
font-size: 12px;
}
.api-access-page .provider-row-meta span {
flex: none;
color: var(--text-quaternary);
font-size: 12px;
}
.api-access-page .provider-row-actions {
grid-column: 3;
grid-row: 1;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 340px;
}
.api-access-page .provider-enabled-control {
min-width: 0;
}
.api-access-page .provider-row-actions .icon-button {
width: 32px;
height: 32px;
min-width: 32px;
}
/* Compact tabs keep every category visible instead of wrapping a lone tab. */
@container workspace (max-width: 820px) {
.api-access-page .provider-category-panel button {
grid-template-columns: 24px auto;
grid-template-rows: 24px auto;
gap: 4px 8px;
height: auto;
min-height: 68px;
}
.api-access-page .provider-category-panel span {
grid-column: 1 / -1;
grid-row: 2;
font-size: 12px;
text-align: center;
white-space: normal;
overflow-wrap: normal;
}
.api-access-page .provider-category-panel strong {
grid-column: 2;
grid-row: 1;
}
}
@container provider-resources (max-width: 720px) {
.api-access-page .management-panel-heading {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: 'title actions' 'search search';
column-gap: 12px;
}
.api-access-page .api-access-search {
max-width: none;
}
}
@container provider-resources (max-width: 580px) {
.api-access-page .real-provider-row {
grid-template-columns: 24px minmax(0, 1fr);
gap: 10px;
}
.api-access-page .provider-row-actions {
grid-column: 2;
grid-row: 2;
max-width: none;
justify-content: flex-start;
}
}
/* Keep controls compact while allowing complete groups to move to the next line. */
.auth-files-page .management-heading-actions {
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
}
.auth-files-page .management-heading-actions button {
flex: 0 0 auto;
width: auto;
}
.auth-files-page .real-auth-files-panel {
container: credential-files / inline-size;
min-width: 0;
}
.auth-files-page .auth-files-toolbar select {
min-width: 0;
}
@container credential-files (max-width: 680px) {
.auth-files-page .auth-files-toolbar {
grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
gap: 0 9px;
}
.auth-files-page .auth-files-toolbar input {
grid-column: 2 / -1;
}
.auth-files-page .auth-files-toolbar select:first-of-type {
grid-column: 1 / 3;
}
.auth-files-page .auth-files-toolbar select:last-of-type {
grid-column: 3;
}
.auth-files-page .auth-files-toolbar select {
border-top: 1px solid var(--theme-e3e1db);
border-left: 0;
}
}
.oauth-login-page > .oauth-grid {
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
}
.oauth-browser-picker {
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
}
.oauth-browser-picker select {
min-width: 0;
max-width: 100%;
flex: 0 1 240px;
}
.oauth-card .oauth-auth-url-actions,
.oauth-card .management-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.oauth-card .management-card-actions > button {
flex: 1 1 auto;
}
.oauth-card .oauth-callback-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.oauth-card .oauth-callback-row > button {
width: auto;
white-space: nowrap;
}
.quota-group-heading {
flex-wrap: wrap;
}
.quota-group-heading .quota-group-actions {
flex: 1 1 280px;
}
.quota-group-actions .muted-summary {
width: auto;
}
.real-quota-row > div:first-child {
flex-wrap: wrap;
}
.real-quota-row small,
.quota-reset-credit-summary span {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
/* Optional key selectors must not shift fixed dialog grid tracks onto the model list. */
.model-discovery-dialog.provider-health-dialog {
display: flex;
flex-direction: column;
height: min(660px, calc(100dvh - 48px));
min-height: 0;
overflow: auto;
}
.provider-health-dialog > :not(.provider-health-model-list) {
flex-shrink: 0;
}
.provider-health-dialog .provider-health-model-list {
flex: 1 1 180px;
min-height: 100px;
height: auto;
}
.provider-health-dialog .model-discovery-header > div {
flex-wrap: wrap;
}
.provider-health-dialog .model-discovery-header span {
white-space: normal;
overflow-wrap: anywhere;
}
.provider-health-dialog .provider-health-key-selector {
min-width: 0;
padding: 0;
}
.model-discovery-dialog .model-discovery-toolbar,
.model-discovery-dialog .model-discovery-actions,
.model-discovery-dialog .model-transfer-summary {
flex-wrap: wrap;
}
.model-discovery-dialog .model-discovery-toolbar > span {
white-space: normal;
}
.api-provider-dialog .provider-group-heading,
.api-provider-dialog .model-config-heading,
.api-provider-dialog .thinking-level-heading {
flex-wrap: wrap;
}
@container workspace (max-width: 760px) {
.oauth-browser-picker {
justify-content: flex-start;
}
.auth-files-page .management-heading-actions,
.quota-group-heading .quota-group-actions {
justify-content: flex-start;
}
}
+342
View File
@@ -0,0 +1,342 @@
/* Keep each section responsive to its available content width, including the
sidebar and the narrower cards in the two-column home layout. */
.home-quick-actions {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@container workspace (max-width: 1080px) {
.home-quick-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container workspace (max-width: 480px) {
.home-quick-actions {
grid-template-columns: minmax(0, 1fr);
}
}
.client-api-panel {
container: home-api / inline-size;
}
.client-api-panel .client-api-heading {
flex-wrap: wrap;
gap: 10px;
}
.client-api-panel .client-api-heading > div {
flex: 1 1 260px;
}
.client-api-panel .client-api-key-actions {
max-width: 100%;
flex-shrink: 1;
}
.client-api-key-code {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
@container home-api (max-width: 480px) {
.client-api-card {
height: auto;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.client-api-card-heading {
flex: none;
width: auto;
min-width: 0;
}
}
/* The download source is one control group: move the group together, then let
the select shrink while the action retains a readable label. */
.version-management-page > .version-list {
padding: 0;
}
.version-source-row {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
}
.version-source-copy {
flex: 1 1 240px;
}
.version-source-control {
grid-template-columns: minmax(0, 1fr) auto;
width: min(360px, 100%);
min-width: 0;
}
.version-source-control label,
.version-source-control select {
min-width: 0;
width: 100%;
}
.version-source-add-button {
grid-column: auto;
white-space: nowrap;
}
@container workspace (max-width: 620px) {
.app-release-notes-heading {
flex-wrap: wrap;
padding: 16px;
}
.app-release-notes-body {
padding: 0 16px 16px;
}
.app-release-notes-markdown {
gap: 14px 12px;
}
.app-release-notes-markdown > .release-notes-category {
max-width: 80px;
}
}
/* Settings have variable-length descriptions; fixed row heights and controls
sized from the window instead of the workspace can make adjacent cards overlap. */
.config-page .config-panel-heading,
.config-page .config-heading-actions,
.config-page .config-network-section-heading,
.config-page .config-proxy-heading {
flex-wrap: wrap;
}
.config-page .config-panel-heading {
gap: 10px 14px;
}
.config-page .config-heading-actions {
margin-left: auto;
}
.config-keys-panel {
grid-template-rows: auto minmax(0, 1fr);
}
.config-network-grid {
grid-auto-rows: minmax(154px, auto);
}
.config-network-field small,
.config-diagnostics-setting-copy,
.config-software-setting-copy {
overflow-wrap: anywhere;
}
.config-diagnostics-actions,
.config-management-actions {
flex-wrap: wrap;
}
@container workspace (max-width: 900px) {
.config-network-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-diagnostics-toggle-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@container workspace (max-width: 700px) {
.config-diagnostics-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-software-setting-row {
gap: 16px;
padding: 15px 16px;
}
.config-software-close-row {
align-items: stretch;
flex-direction: column;
}
.config-software-select {
width: 100%;
min-width: 0;
padding-left: 54px;
}
.config-diagnostics-footer,
.config-management-form-footer {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.config-diagnostics-actions {
justify-content: flex-end;
}
}
@container workspace (max-width: 560px) {
.config-network-grid,
.config-diagnostics-fields {
grid-template-columns: minmax(0, 1fr);
}
.config-network-grid {
grid-auto-rows: auto;
}
.config-software-select {
padding-left: 0;
}
.config-heading-title h2 {
white-space: normal;
}
}
/* Alias routes should keep their names readable before metadata and actions
wrap onto a second line. The editor uses its own width inside a modal. */
.thinking-alias-list-panel {
container: alias-list / inline-size;
}
.thinking-alias-dialog {
container: alias-editor / inline-size;
}
.thinking-alias-field {
min-width: 0;
}
@container alias-list (max-width: 560px) {
.thinking-alias-row {
grid-template-columns: minmax(0, 1fr) 34px;
}
.thinking-alias-route {
grid-column: 1 / -1;
}
.thinking-alias-badges {
flex-wrap: wrap;
justify-content: flex-start;
}
}
@container alias-editor (max-width: 580px) {
.thinking-effort-options {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Keep novice-mode actions compact at intermediate widths. Existing phone
rules made every action full-width even when two controls easily fit. */
.simple-mode-topbar,
.simple-mode-topbar-right,
.simple-mode-brand-title,
.simple-mode-choice-title,
.simple-mode-api-model-heading,
.simple-mode-api-model-selection-heading {
flex-wrap: wrap;
}
.simple-mode-topbar-left,
.simple-mode-topbar-right,
.simple-mode-brand,
.simple-mode-brand-text {
min-width: 0;
}
.simple-mode-topbar-left {
flex: 1 1 340px;
}
.simple-mode-topbar-right {
flex: 0 1 auto;
justify-content: flex-end;
margin-left: auto;
}
.simple-mode-topbar-right > *,
.simple-mode-guide-toggle {
flex-shrink: 0;
}
.simple-mode-choice-title strong,
.simple-mode-brand-title strong {
white-space: normal;
overflow-wrap: anywhere;
}
.simple-mode-provider-card-foot {
flex-wrap: wrap;
}
.simple-mode-field .text-input,
.simple-mode-field .select-input {
min-width: 0;
}
@container workspace (max-width: 720px) {
.simple-mode-topbar-left,
.simple-mode-topbar-right {
width: 100%;
}
.simple-mode-topbar-right {
justify-content: flex-start;
}
.simple-mode-choice-grid,
.simple-mode-api-fields {
grid-template-columns: minmax(0, 1fr);
}
.simple-mode-api-actions,
.simple-mode-task-footer {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.simple-mode-api-actions button,
.simple-mode-task-footer > .primary-button,
.simple-mode-task-footer > .secondary-button {
width: auto;
}
.simple-mode-task-footer > .primary-button {
margin-left: auto;
}
}
@container workspace (max-width: 520px) {
.simple-mode-api-model-options {
grid-template-columns: minmax(0, 1fr);
}
.simple-mode-step-status-track {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.simple-mode-step-status-connector {
display: none;
}
.simple-mode-step-status-copy strong,
.simple-mode-step-status-copy small {
white-space: normal;
overflow-wrap: anywhere;
}
}
+174
View File
@@ -0,0 +1,174 @@
/* Keep footer controls reachable when desktop navigation exceeds the height. */
@media (min-width: 901px) {
.sidebar-brand,
.sidebar-bottom {
flex-shrink: 0;
}
.sidebar > .nav-section {
min-height: 0;
margin-bottom: 18px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
}
}
/* Let toolbar rows keep their height instead of overlapping the table. */
.usage-events-summary {
flex: none;
flex-wrap: wrap;
}
.usage-events-summary-left,
.usage-events-summary-right {
width: auto;
max-width: 100%;
justify-content: flex-start;
}
.usage-events-summary-right {
margin-left: auto;
}
.usage-pagination-controls {
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
}
.usage-topbar,
.usage-filter-panel {
flex-shrink: 0;
}
.usage-page-navigation,
.usage-topbar-actions {
max-width: 100%;
}
.usage-tabs,
.config-subpage-tabs,
.oauth-subpage-tabs {
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.usage-tabs button,
.config-subpage-tabs button,
.oauth-subpage-tabs button {
flex-shrink: 0;
white-space: nowrap;
}
.usage-topbar-actions {
min-width: 0;
flex-wrap: wrap;
}
.usage-collector-state {
max-width: 100%;
}
.usage-collector-state > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
}
.usage-section-heading {
gap: 10px;
flex-wrap: wrap;
}
.usage-section-heading > div {
min-width: 0;
}
.usage-section-heading strong {
white-space: normal;
overflow-wrap: anywhere;
}
.usage-custom-range input {
min-width: 0;
max-width: 100%;
}
.usage-pricing-table-wrap:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: -2px;
}
.usage-pricing-panel {
overflow: auto;
}
.usage-price-editor {
min-height: 160px;
max-height: min(320px, 40vh);
flex-shrink: 0;
}
.usage-storage-limit-editor {
min-width: 0;
max-width: 100%;
}
.usage-storage-limit-editor label {
min-width: 0;
flex: 1;
}
.usage-storage-limit-editor input {
min-width: 0;
width: 100%;
max-width: 168px;
}
.usage-storage-limit-editor button {
flex: none;
white-space: nowrap;
}
@container workspace (max-width: 760px) {
.usage-overview-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: max-content minmax(280px, 1fr) auto;
}
.usage-health-panel {
height: auto;
}
.usage-data-management-action {
flex-wrap: wrap;
gap: 12px;
}
.usage-data-management-action > div:first-child {
min-width: 0;
flex: 1 1 260px;
}
}
@container workspace (max-width: 540px) {
.usage-events-summary-right {
margin-left: 0;
}
.usage-storage-limit-editor {
flex-direction: row;
}
.usage-storage-limit-editor .primary-button {
width: auto;
}
}
@media (min-width: 761px) {
.usage-events-panel > .usage-table-wrap,
.usage-events-panel > .usage-empty {
min-height: 0;
}
}
+19 -36
View File
@@ -387,40 +387,6 @@ body, html, #root {
font-weight: var(--font-weight-semibold);
}
/* API access toolbar */
.api-access-page .management-panel-heading {
align-items: center;
flex-wrap: wrap;
}
.api-access-heading-tools {
display: flex;
min-width: 0;
flex: 1 1 420px;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.api-access-heading-tools .compact-toolbar {
width: min(280px, 100%);
flex: 1 1 180px;
}
@container workspace (max-width: 760px) {
.api-access-heading-tools {
width: 100%;
flex-basis: 100%;
flex-wrap: wrap;
justify-content: flex-start;
}
.api-access-heading-tools .compact-toolbar {
flex-basis: 100%;
}
}
/* 7. OAuth Management Page Polish */
.oauth-management-page .management-header h1 {
display: none;
@@ -618,13 +584,18 @@ body, html, #root {
gap: 8px;
}
.usage-records-page .usage-filter-group {
display: grid;
min-width: 0;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.usage-records-page .usage-filter-item {
flex: 1 1 150px;
flex-direction: row;
align-items: center;
gap: 0;
min-width: 132px;
max-width: 210px;
min-width: 0;
height: 36px;
padding: 0 8px;
border: 1px solid var(--border-color);
@@ -688,6 +659,18 @@ body, html, #root {
color: var(--text-primary);
}
@container workspace (max-width: 880px) {
.usage-records-page .usage-filter-group {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container workspace (max-width: 520px) {
.usage-records-page .usage-filter-group {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.usage-records-page .usage-section-heading strong {
color: #1d1d1f;
font-weight: 650;
+32 -15
View File
@@ -128,27 +128,42 @@ const desktopViewports = [
const assertClientListScroll = async (label, { requireOverflow = true } = {}) => {
const metrics = await page.locator('.agent-list-items').evaluate(list => {
const style = getComputedStyle(list);
const last = list.lastElementChild;
list.scrollTop = list.scrollHeight;
const scrollTop = list.scrollTop;
const overflowX = list.scrollWidth > list.clientWidth + 1;
const overflowY = list.scrollHeight > list.clientHeight + 1;
const original = {
left: list.scrollLeft,
top: list.scrollTop,
snap: list.style.scrollSnapType,
behavior: list.style.scrollBehavior,
};
list.style.scrollSnapType = 'none';
list.style.scrollBehavior = 'auto';
const listRect = list.getBoundingClientRect();
const lastRect = last?.getBoundingClientRect();
const lastReachable = !!lastRect && lastRect.top >= listRect.top - 1 && lastRect.bottom <= listRect.bottom + 1;
list.scrollTop = 0;
const unreachable = Array.from(list.querySelectorAll('button')).filter(button => {
const before = button.getBoundingClientRect();
list.scrollLeft += before.left - listRect.left;
list.scrollTop += before.top - listRect.top;
const after = button.getBoundingClientRect();
return after.left < listRect.left - 1 || after.right > listRect.right + 1
|| after.top < listRect.top - 1 || after.bottom > listRect.bottom + 1;
}).map(button => button.textContent.trim());
const scrollable = (!overflowX || ['auto', 'scroll'].includes(style.overflowX))
&& (!overflowY || ['auto', 'scroll'].includes(style.overflowY));
list.scrollLeft = original.left;
list.scrollTop = original.top;
list.style.scrollSnapType = original.snap;
list.style.scrollBehavior = original.behavior;
return {
clientHeight: list.clientHeight,
scrollHeight: list.scrollHeight,
overflowY: style.overflowY,
scrollTop,
lastReachable,
overflows: overflowX || overflowY,
scrollable,
unreachable,
};
});
assert.equal(metrics.overflowY, 'auto', `${label}: the client list must own vertical scrolling`);
assert.equal(metrics.scrollable, true, `${label}: overflowing client options must remain scrollable`);
if (requireOverflow) {
assert.ok(metrics.scrollHeight > metrics.clientHeight + 1, `${label}: the complete client list must overflow internally`);
assert.ok(metrics.scrollTop > 0, `${label}: the client list must accept scrolling`);
assert.equal(metrics.overflows, true, `${label}: the fixture must exercise scrolling through the client list`);
}
assert.equal(metrics.lastReachable, true, `${label}: scrolling must reveal the final client`);
assert.deepEqual(metrics.unreachable, [], `${label}: scrolling must reveal every client option`);
};
const assertDesktopLayout = async (label, { requireClientOverflow = true } = {}) => {
@@ -421,6 +436,8 @@ const desktopViewports = [
`${shellNarrowLabel}: panels must stack at the same width after the workspace breakpoint`);
assert.ok(shellNarrow.client.bottom <= shellNarrow.configuration.y + 1,
`${shellNarrowLabel}: the client list must remain above the configuration panel`);
assert.ok(shellNarrow.configuration.height >= shellNarrow.workbench.height / 2,
`${shellNarrowLabel}: stacked client options must leave most of the workbench available for configuration`);
assert.ok(Math.abs(shellNarrow.bottomGap - shellNarrow.paddingBottom) <= 1,
`${shellNarrowLabel}: the stacked workbench must preserve the fixed window-edge gap`);
assert.ok(shellNarrow.documentScrollHeight <= shellNarrow.documentClientHeight + 1,
+138
View File
@@ -0,0 +1,138 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const widths = [1440, 980, 854, 640];
const locales = ['zh-CN', 'en'];
(async () => {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
const server = await createServer({
configFile: false,
root: path.resolve(__dirname, '..'),
plugins: [react()],
optimizeDeps: { entries: ['tests/fixtures/provider-groups.html'] },
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, 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 errors = [];
for (const locale of locales) for (const width of widths) {
const label = `${locale} ${width}x600`;
const page = await browser.newPage({ viewport: { width, height: 600 } });
page.setDefaultTimeout(10000);
page.on('pageerror', error => errors.push(`${label}: ${error}`));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.goto(`${base}/tests/fixtures/provider-groups.html?layout=dense&locale=${locale}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelectorAll('.real-provider-row').length === 4
&& document.querySelectorAll('.provider-row-title > span:not(.state-pill)').length === 4);
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const navigation = await page.locator('.provider-category-panel').evaluate(panel => {
const rect = panel.getBoundingClientRect();
const buttons = [...panel.querySelectorAll('button')];
return {
count: buttons.length,
rows: new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size,
allVisible: buttons.every(button => {
const item = button.getBoundingClientRect();
return item.width > 0 && item.height > 0 && item.left >= rect.left - 1 && item.right <= rect.right + 1
&& item.top >= rect.top - 1 && item.bottom <= rect.bottom + 1 && item.right <= innerWidth + 1;
}),
labelsFit: buttons.every(button => {
const label = button.querySelector('span');
const range = document.createRange();
range.selectNodeContents(label);
const text = range.getBoundingClientRect();
const bounds = label.getBoundingClientRect();
return text.left >= bounds.left - 1 && text.right <= bounds.right + 1
&& text.top >= bounds.top - 1 && text.bottom <= bounds.bottom + 1;
}),
bottom: rect.bottom,
listTop: document.querySelector('.provider-resource-panel').getBoundingClientRect().top,
};
});
assert.equal(navigation.count, 5, `${label}: every provider category remains available`);
assert.equal(navigation.rows, 1, `${label}: categories retain a single top row`);
assert.equal(navigation.allVisible, true, `${label}: all category controls fit without hidden columns`);
assert.equal(navigation.labelsFit, true, `${label}: full category names stay readable`);
assert.ok(navigation.bottom <= navigation.listTop + 1, `${label}: category navigation stays above the resource list`);
const overflow = await page.evaluate(() => [document.documentElement, document.body,
document.querySelector('.content'), document.querySelector('.api-access-page')]
.filter(element => element.scrollWidth > element.clientWidth + 1)
.map(element => element.className || element.tagName));
assert.deepEqual(overflow, [], `${label}: dense API content must not overflow the page horizontally`);
const groups = await page.evaluate(() => window.groupFixture.groups);
const rows = page.locator('.real-provider-row');
for (const group of groups) {
const row = rows.filter({ has: page.locator('.provider-row-title strong', { hasText: group.name }) });
const meta = row.locator('.provider-row-meta');
assert.equal(await row.count(), 1, `${label}: each group has exactly one row`);
assert.equal(await meta.isVisible(), true, `${label} ${group.name}: metadata stays visible`);
const metaText = await meta.innerText();
const expectedModels = locale === 'en' ? `${group.models.length} models` : `模型 ${group.models.length} 个`;
if (group.models.length) assert.ok(metaText.includes(expectedModels), `${label} ${group.name}: model count is preserved`);
const priorityLabel = locale === 'en' ? 'Priority' : '优先级';
if (group.priority === null) assert.ok(!metaText.includes(priorityLabel), `${label}: inherited priority is not displayed as zero`);
else assert.ok(metaText.includes(`${priorityLabel} ${group.priority}`), `${label} ${group.name}: priority, including zero, is preserved`);
if (group.keys.length > 1) {
const keyCount = locale === 'en' ? `${group.keys.length} keys total` : `共 ${group.keys.length} 个密钥`;
assert.ok(metaText.includes(keyCount), `${label} ${group.name}: multiple keys stay grouped and counted`);
}
assert.equal(await row.locator('.provider-row-url').getAttribute('title'), group['base-url'], `${label}: full URL remains available`);
const remark = row.locator('.provider-row-title > span:not(.state-pill)');
assert.equal(await remark.getAttribute('title'), await remark.textContent(), `${label}: the full remark remains available when shortened`);
const layout = await row.evaluate(element => {
const bounds = element.getBoundingClientRect();
const main = element.querySelector('.provider-row-main').getBoundingClientRect();
const actions = element.querySelector('.provider-row-actions').getBoundingClientRect();
const controls = [...element.querySelectorAll('.provider-row-actions > button, .provider-enabled-control')];
const rects = controls.map(control => control.getBoundingClientRect());
const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1
&& Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
return {
mainOverlapsActions: overlap(main, actions),
controlsContained: rects.every(rect => rect.width > 0 && rect.height > 0 && rect.left >= bounds.left - 1
&& rect.right <= bounds.right + 1 && rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1),
controlsOverlap: rects.some((rect, index) => rects.slice(index + 1).some(next => overlap(rect, next))),
};
});
assert.equal(layout.mainOverlapsActions, false, `${label} ${group.name}: details and actions do not overlap`);
assert.equal(layout.controlsContained, true, `${label} ${group.name}: every action fits its own row`);
assert.equal(layout.controlsOverlap, false, `${label} ${group.name}: action targets remain separate`);
for (const control of await row.locator('.provider-row-actions > button, .provider-enabled-control, .provider-drag-handle').all()) {
await control.scrollIntoViewIfNeeded();
assert.equal(await control.evaluate(element => {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return rect.top >= -1 && rect.bottom <= innerHeight + 1 && !!hit && element.contains(hit);
}), true, `${label} ${group.name}: actions and drag handle remain reachable by scrolling`);
}
}
const partial = rows.filter({ hasText: 'Mock Borealis partial' });
assert.equal(await partial.locator('.state-pill').textContent(), locale === 'en' ? 'Some keys disabled' : '部分密钥已停用');
assert.equal(await rows.filter({ hasText: 'Mock Dusk disabled' }).getByRole('checkbox').isChecked(), false);
assert.deepEqual(await page.evaluate(() => window.groupFixture.writes), [], 'Layout checks must not change provider records');
await page.close();
}
assert.deepEqual(errors, [], 'Dense API layouts must not produce runtime errors');
console.log('PASS: dense API groups retain navigation, metadata and reachable actions at four widths in Chinese and English.');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+54 -5
View File
@@ -2,22 +2,71 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { ApiAccessPage } from '../../src/pages/ApiAccessPage';
import {
ApiAccessPage,
apiAccessRemarkLocatorFromRecord,
providerRemarkIdentity,
type ApiAccessRemarkLocator,
type ProviderSection,
} from '../../src/pages/ApiAccessPage';
import '../../src/styles/index.css';
localStorage.setItem('easy-cli-proxy-api.locale', new URLSearchParams(location.search).get('locale') ?? 'en');
const query = new URLSearchParams(location.search);
const denseLayout = query.get('layout') === 'dense';
localStorage.setItem('easy-cli-proxy-api.locale', query.get('locale') ?? 'en');
const fixture = window as typeof window & { groupFixture: { groups: Record<string, unknown>[], writes: unknown[], probes: unknown[] } };
fixture.groupFixture = {
groups: [{ name: 'Primary gateway', 'base-url': 'https://gateway.example.test/v1', priority: 2,
groups: denseLayout ? [
{
name: 'Mock Atlas primary', 'base-url': 'https://atlas.example.test/v1', priority: 10,
models: [{ name: 'mock-codex-large' }, { name: 'mock-codex-small' }, { name: 'mock-codex-fast' }],
keys: [{ 'api-key': 'mock-atlas-key-one' }, { 'api-key': 'mock-atlas-key-two' }, { 'api-key': 'mock-atlas-key-three' }],
},
{
name: 'Mock Borealis partial',
'base-url': 'https://borealis-layout-only.example.test/a-deliberately-long-routing-path/organization-fictional-team/workspace-for-responsive-layout-verification/openai-compatible/v1',
priority: 0, models: [{ name: 'mock-codex-reasoning' }, { name: 'mock-codex-compact' }],
keys: [{ 'api-key': 'mock-borealis-active' }, { 'api-key': 'mock-borealis-paused', 'excluded-models': ['*'] }],
},
{
name: 'Mock Cirrus gateway with a deliberately long fictional group name',
'base-url': 'https://cirrus.example.test/v1', priority: null,
models: [{ name: 'mock-codex-standard' }], keys: [{ 'api-key': 'mock-cirrus-only-key' }],
},
{
name: 'Mock Dusk disabled', 'base-url': 'https://dusk.example.test/v1', priority: 5,
'excluded-models': ['*'], models: [],
keys: [{ 'api-key': 'mock-dusk-key-one' }, { 'api-key': 'mock-dusk-key-two' }],
},
] : [{ name: 'Primary gateway', 'base-url': 'https://gateway.example.test/v1', priority: 2,
headers: { 'X-Group': 'shared' }, models: [{ name: 'gpt-original' }, { name: 'gpt-original', alias: 'gpt-fast' }],
keys: [{ 'api-key': 'key-inherited', weight: 2, priority: null },
{ 'api-key': 'key-overridden', weight: 5, priority: 0, 'proxy-url': 'direct', headers: { 'X-Key': 'second' }, models: [{ name: 'private-model' }], 'excluded-models': [], 'disable-cooling': false }],
}, { name: 'Backup gateway', 'base-url': 'https://backup.example.test/v1', models: [{ name: 'backup-model' }], keys: [{ 'api-key': 'backup-key' }] }],
writes: [], probes: [],
};
const denseRemarks = [
'Fictional layout fixture · 三个测试密钥',
'One fictional key paused · 用于检查部分启用状态',
'仅用于响应式布局测试的虚构备注:故意保留很长的说明,验证窄窗口中的名称、备注、URL、密钥数量和操作仍然清楚。 Fictional layout-only remark with enough text to exercise wrapping and truncation without real credentials or services.',
'Disabled fictional group · 无已配置模型',
];
const remarks = new Map<string, string>();
if (denseLayout) fixture.groupFixture.groups.forEach((group, index) => {
remarks.set(providerRemarkIdentity('codex-api-key', apiAccessRemarkLocatorFromRecord('codex-api-key', group)), denseRemarks[index]);
});
mockIPC((cmd, args: any) => {
if (cmd === 'set_app_locale' || cmd === 'save_api_access_remark') return null;
if (cmd === 'resolve_api_access_remarks') return args.queries.map(() => '');
if (cmd === 'set_app_locale') return null;
if (cmd === 'resolve_api_access_remarks') return args.queries.map((item: ApiAccessRemarkLocator & { providerSection: ProviderSection }) =>
denseLayout ? remarks.get(providerRemarkIdentity(item.providerSection, item)) ?? '' : '');
if (cmd === 'save_api_access_remark') {
if (denseLayout) {
const update = args.update as { providerSection: ProviderSection; previousRecords: ApiAccessRemarkLocator[]; records: ApiAccessRemarkLocator[]; remark: string };
update.previousRecords.forEach(record => remarks.delete(providerRemarkIdentity(update.providerSection, record)));
update.records.forEach(record => remarks.set(providerRemarkIdentity(update.providerSection, record), update.remark));
}
return null;
}
if (cmd !== 'management_request') throw new Error(`Unexpected ${cmd}`);
const request = args.request;
if (request.path === '/requests/api-call') {
+256
View File
@@ -0,0 +1,256 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const viewports = [
{ width: 640, height: 600 },
{ width: 800, height: 600 },
{ width: 964, height: 600 },
{ width: 1280, height: 800 },
];
(async () => {
let server;
let browser;
let base = 'http://127.0.0.1:1420/';
try {
try {
const response = await fetch(base, { signal: AbortSignal.timeout(3000) });
if (!response.ok) throw new Error(String(response.status));
} catch {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
server = await createServer({
configFile: false,
root: path.resolve(__dirname, '..'),
plugins: [react()],
logLevel: 'error',
server: { host: '127.0.0.1', port: 1421, strictPort: false },
});
await server.listen();
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'] });
let page;
const errors = [];
const settle = () => page.evaluate(async () => {
await document.fonts.ready;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const open = async (label, ready) => {
await page.locator('.nav-section button').filter({ has: page.getByText(label, { exact: true }) }).click();
await page.locator(ready).first().waitFor();
await settle();
};
const tab = async (id, ready) => {
await page.locator(`#${id}`).click();
await page.locator(ready).first().waitFor();
await settle();
};
const assertNoPageOverflow = async label => {
const overflow = await page.evaluate(() => {
const nodes = [document.documentElement, document.body,
document.querySelector('.content'), document.querySelector('.content > .page')].filter(Boolean);
return nodes.filter(node => node.scrollWidth > node.clientWidth + 1)
.map(node => ({ element: node.className || node.tagName, client: node.clientWidth, scroll: node.scrollWidth }));
});
assert.deepEqual(overflow, [], `${label}: horizontal scrolling must stay inside intentional tables or navigation`);
};
const assertReachable = async (locator, label) => {
await locator.scrollIntoViewIfNeeded();
assert.equal(await locator.evaluate(element => {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0 || rect.left < -1 || rect.right > innerWidth + 1
|| rect.top < -1 || rect.bottom > innerHeight + 1) return false;
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return !!hit && element.contains(hit);
}), true, `${label}: the control must be visible and unobstructed after scrolling`);
};
const assertRequestToolbar = async label => {
const metrics = await page.locator('.usage-events-panel').evaluate(panel => {
const summary = panel.querySelector('.usage-events-summary');
const scrollbar = panel.querySelector('.usage-table-top-scrollbar');
const pagination = panel.querySelector('.usage-pagination-controls');
const summaryRect = summary.getBoundingClientRect();
const scrollbarRect = scrollbar.getBoundingClientRect();
const paginationRect = pagination.getBoundingClientRect();
const controls = [...pagination.querySelectorAll('button, select, .usage-pagination-info')];
return {
summaryBottom: summaryRect.bottom,
scrollbarTop: scrollbarRect.top,
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.bottom <= summaryRect.bottom + 1;
}),
};
});
assert.ok(metrics.summaryBottom <= metrics.scrollbarTop + 1, `${label}: request summary must not overlap the horizontal scrollbar`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their toolbar`);
for (const control of await page.locator('.usage-pagination-controls button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} pagination`);
}
const scrolled = await page.locator('.usage-events-panel .usage-table-wrap').evaluate(table => {
table.scrollLeft = table.scrollWidth;
return { overflowing: table.scrollWidth > table.clientWidth, scrolled: table.scrollLeft > 0 };
});
assert.equal(scrolled.overflowing, true, `${label}: the full request table remains available beyond the viewport`);
assert.equal(scrolled.scrolled, true, `${label}: request columns remain reachable by horizontal scrolling`);
};
const assertExpandedPricing = async () => {
await page.getByRole('combobox', { name: '时间范围', exact: true }).selectOption('custom');
await page.locator('.usage-custom-range').waitFor();
await page.getByRole('button', { name: '手动添加', exact: true }).click();
const editor = page.locator('.usage-price-editor');
await editor.waitFor();
await settle();
// A clipped ancestor can be scrolled programmatically by scrollIntoView,
// while still preventing the user from reaching the overflowing content.
const clipped = await page.locator('.usage-pricing-table-wrap').evaluate(table => {
const ancestors = [];
for (let parent = table.parentElement; parent; parent = parent.parentElement) {
if (['hidden', 'clip'].includes(getComputedStyle(parent).overflowY)
&& parent.scrollHeight > parent.clientHeight + 1) ancestors.push(parent.className);
}
return ancestors;
});
assert.deepEqual(clipped, [], 'Expanded pricing content must remain reachable through user scrolling');
await assertReachable(editor.getByRole('button', { name: '保存', exact: true }), '800x600 price editor save');
await assertReachable(editor.getByRole('button', { name: '取消', exact: true }), '800x600 price editor cancel');
const table = page.locator('.usage-pricing-table-wrap');
await assertReachable(table, '800x600 price table');
const scrollbarReachable = await table.evaluate(element => {
const rect = element.getBoundingClientRect();
const y = rect.bottom - 2;
if (y < 0 || y >= innerHeight) return false;
const hit = document.elementFromPoint(rect.left + rect.width / 2, y);
if (!hit || !element.contains(hit)) return false;
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowY !== 'visible') {
const bounds = parent.getBoundingClientRect();
if (y < bounds.top || y > bounds.bottom) return false;
}
}
element.scrollLeft = element.scrollWidth;
return element.scrollLeft > 0;
});
assert.equal(scrollbarReachable, true, 'Expanded pricing table scrollbar must remain visible and usable');
await editor.getByRole('button', { name: '取消', exact: true }).click();
await editor.waitFor({ state: 'detached' });
};
for (const viewport of viewports) {
const label = `${viewport.width}x${viewport.height}`;
// browser.newPage creates an isolated context, so persisted tabs, selected
// clients and table preferences cannot leak between viewport cases.
page = await browser.newPage({ viewport });
page.setDefaultTimeout(10000);
page.on('pageerror', error => errors.push(`${label}: ${error}`));
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.addInitScript(() => localStorage.setItem('easy-cli-proxy-api.locale', 'zh-CN'));
await page.goto(`${base}?mock=running`, { waitUntil: 'domcontentloaded' });
await page.locator('.home-page .client-api-card').first().waitFor();
// The native app does not include the development scenario picker overlay.
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
await settle();
await assertNoPageOverflow(`${label} home`);
if (viewport.width === 964) {
assert.equal(await page.locator('.sidebar-bottom').evaluate(footer => {
const rect = footer.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= innerHeight + 1;
}), true, `${label}: desktop sidebar footer must stay visible without scrolling the page`);
await assertReachable(page.locator('.sidebar-contact'), `${label} sidebar contact`);
}
await open('API 接入', '.real-provider-row');
await assertNoPageOverflow(`${label} API access`);
if (viewport.width === 964) {
const rows = await page.locator('.provider-category-panel button').evaluateAll(buttons =>
new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size);
assert.equal(rows, 1, `${label}: provider navigation must not consume several rows in a short window`);
await assertReachable(page.locator('.provider-category-panel button').last(), `${label} final provider category`);
}
await assertReachable(page.locator('.provider-row-actions button').first(), `${label} provider action`);
await open('OAuth', '.oauth-subpage-tabs');
await tab('oauth-subpage-tab-login', '.oauth-card');
await assertNoPageOverflow(`${label} OAuth login`);
await tab('oauth-subpage-tab-authFiles', '.auth-file-card');
await assertNoPageOverflow(`${label} OAuth credentials`);
await tab('oauth-subpage-tab-quota', '.real-quota-card');
await assertNoPageOverflow(`${label} OAuth quota`);
await open('智能体配置', '.agent-config-panel');
await page.waitForFunction(() => !document.querySelector('.agent-client-list-heading button')?.disabled);
await tab('agent-subpage-tab-core', '#agent-subpage-panel-core');
await assertNoPageOverflow(`${label} agents`);
const configuration = page.locator('#agent-subpage-panel-core');
assert.equal(await configuration.evaluate(element => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}), true, `${label}: the configuration area must not collapse below its tab bar`);
const agentControl = configuration.locator('button, input:not([type="hidden"]), select').filter({ visible: true }).first();
await assertReachable(agentControl, `${label} agent configuration`);
await open('使用记录', '.usage-tabs');
await tab('usage-tab-overview', '.usage-overview-layout');
await assertNoPageOverflow(`${label} usage overview`);
for (const [id, ready] of [
['analysis', '.usage-analysis-grid'],
['events', '.usage-events-table tbody tr'],
['pricing', '.usage-pricing-table-wrap tbody tr'],
['data-management', '.usage-data-management-panel'],
]) {
await tab(`usage-tab-${id}`, ready);
await assertNoPageOverflow(`${label} usage ${id}`);
if (id === 'events') await assertRequestToolbar(label);
if (id === 'pricing' && viewport.width === 800) await assertExpandedPricing();
}
await open('高级功能', '#config-subpage-panel');
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
await assertNoPageOverflow(`${label} settings ${id}`);
}
await open('版本管理', '.version-list-item');
await assertNoPageOverflow(`${label} versions`);
await page.close();
}
page = await browser.newPage({ viewport: { width: 640, height: 600 } });
page.setDefaultTimeout(10000);
page.on('pageerror', error => errors.push(`640x600 English: ${error}`));
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.addInitScript(() => localStorage.setItem('easy-cli-proxy-api.locale', 'en'));
await page.goto(`${base}?mock=running`, { waitUntil: 'domcontentloaded' });
await page.locator('.home-page').waitFor();
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
await open('Advanced Features', '.config-subpage-tabs');
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
await assertNoPageOverflow(`640x600 English settings ${id}`);
const button = page.locator(`#config-subpage-tab-${id}`);
await assertReachable(button, `English settings ${id} tab`);
assert.equal(await button.evaluate(element => {
const range = document.createRange();
range.selectNodeContents(element);
const text = range.getBoundingClientRect();
const bounds = element.getBoundingClientRect();
return text.left >= bounds.left - 1 && text.right <= bounds.right + 1;
}), true, `English ${id} label must fit inside its own clickable tab`);
}
await page.close();
assert.deepEqual(errors, [], 'Responsive navigation must not produce runtime errors');
console.log('PASS: responsive pages, short-window navigation, reachable controls, and independent request-table scrolling.');
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });