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:
1 parent
c7d58128b9
commit
5a5eab96e2
17 files changed
+1798
-153
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
8.0.6
|
||||
8.0.10
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; });
|
||||
Vendored
+54
-5
@@ -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') {
|
||||
|
||||
@@ -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; });
|
||||
Reference in new issue
Block a user