fix(ui): reduce whitespace around agent configuration save controls

- Remove empty feedback placeholders and fixed-height rows
- Place configuration status beside the save button
- Compact save controls in the simplified view
- Add responsive layout and notification stability regression tests
This commit is contained in:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent 18a9bfbaa9
commit a9297b86cc
5 files changed
+111 -42

No files matched your search

+8 -10
View File
@@ -62,18 +62,16 @@ export function AgentRunControls({
</section>;
}
export function AgentConfigurationFeedback({ pending, notice, error, description, status = '', onNoticeDismiss, onErrorDismiss }: {
pending: boolean; notice: string; error: string; description: string; status?: string; onNoticeDismiss?: () => void; onErrorDismiss?: () => void;
export function AgentConfigurationFeedback({ pending, description, status = '' }: {
pending: boolean; description: string; status?: string;
}) {
const { t } = useI18n();
return <>
<MessageNotice message={error} onDismiss={onErrorDismiss} />
<MessageNotice tone="success" message={!error ? notice : null} onDismiss={onNoticeDismiss} />
<div className="agent-save-feedback agent-shared-feedback" aria-live="polite">
<span className="agent-write-state">{pending ? t('agents.modify.pending') : status || ' '}</span>
<small tabIndex={description ? 0 : undefined}>{description || ' '}</small>
</div>
</>;
const state = pending ? t('agents.modify.pending') : status;
if (!state && !description) return null;
return <div className="agent-save-feedback agent-shared-feedback" aria-live="polite">
{state ? <span className="agent-write-state">{state}</span> : null}
{description ? <small>{description}</small> : null}
</div>;
}
export function AgentConfigManagementPanel({
+28 -21
View File
@@ -1687,6 +1687,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</button>
) : null;
const configurationFeedback = <AgentConfigurationFeedback pending={hasPendingChanges}
status={modelLoading || !activeStatus ? t('agents.modify.checking')
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : ''}
description={modificationDescription} />;
const configurationErrorMessage = configurationError || modelSelectionError || modelError;
return (
<section className={`page management-page agents-page${embedded ? ' agents-page-embedded' : ''}`}>
<header className="management-header">
@@ -1802,19 +1808,21 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{isDeepSeekHarnessClient ? <p className="agent-model-hint">{t('agents.harness.defaultHint')}</p> : null}
<div className="agent-minimal-actions">
<button
type="button"
className="primary-button"
onClick={applySelectedConfiguration}
disabled={busy || !canEnable || configurationWriteBlocked}
>
{['apply', 'install-pi', 'repair-pi'].includes(busyAction ?? '') ? <LoaderCircle size={16} className="spin" /> : null}
{isPiClient
? activeStatus?.pluginInstalled ? configurationActionLabel : t('agents.pi.install')
: configurationActionLabel}
</button>
<div className="agent-save-bar">
{configurationFeedback}
<div className="agent-save-actions">
<button
type="button"
className="primary-button"
onClick={applySelectedConfiguration}
disabled={busy || !canEnable || configurationWriteBlocked}
>
{['apply', 'install-pi', 'repair-pi'].includes(busyAction ?? '') ? <LoaderCircle size={16} className="spin" /> : null}
{isPiClient
? activeStatus?.pluginInstalled ? configurationActionLabel : t('agents.pi.install')
: configurationActionLabel}
</button>
</div>
</div>
</div>
@@ -1890,7 +1898,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
onChange={selectModel}
onRefresh={refreshModels}
/>
<span className="agent-model-hint agent-model-status" title={modelHint} aria-live="polite">{modelHint || ' '}</span>
{modelHint ? <span className="agent-model-hint agent-model-status" title={modelHint} aria-live="polite">{modelHint}</span> : null}
{isDeepSeekHarnessClient ? <p className="agent-model-hint">{t('agents.harness.defaultHint')}</p> : null}
{harnessCatalogButton}
{selected === 'codex' ? (
@@ -2060,6 +2068,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
) : null}
<div className="agent-save-bar">
{configurationFeedback}
{selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ? <p className="agent-inline-message warning">{t('agents.backup.mappingRequired')}</p> : null}
<div className="agent-save-actions">
<button type="button" className="primary-button" onClick={applySelectedConfiguration} disabled={busy || !canEnable || configurationWriteBlocked}>
@@ -2095,13 +2104,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
onUninstallPi={() => void uninstallPiProvider()} />
</div>
) : null}
<AgentConfigurationFeedback pending={hasPendingChanges}
notice={configurationNotice || clearNotice}
onNoticeDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }}
status={modelLoading || !activeStatus ? t('agents.modify.checking')
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : ''}
onErrorDismiss={() => { setConfigurationError(''); setModelSelectionError(''); setModelError(''); }}
error={configurationError || modelSelectionError || modelError} description={modificationDescription} />
{activeSubpage !== 'core' ? configurationFeedback : null}
<MessageNotice message={configurationErrorMessage}
onDismiss={() => { setConfigurationError(''); setModelSelectionError(''); setModelError(''); }} />
<MessageNotice tone="success" message={!configurationErrorMessage ? configurationNotice || clearNotice : null}
onDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }} />
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode'}
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
+2 -10
View File
@@ -13939,7 +13939,8 @@ body.table-col-resizing * {
.agent-model-section { grid-column: 1 / -1; }
.agent-save-bar { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0 0; border-top: 1px solid var(--theme-e3e1db); }
.agent-save-feedback { display: grid; gap: 6px; min-width: 0; }
.agent-save-feedback small { color: var(--theme-7a746c); font-size: var(--font-size-meta); }
.agent-save-bar .agent-save-feedback { flex: 1; }
.agent-save-feedback small { color: var(--theme-7a746c); font-size: var(--font-size-meta); line-height: var(--line-height-body); overflow-wrap: anywhere; }
.agent-save-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; min-width: 0; }
.agent-save-actions button { min-width: 128px; min-height: 40px; }
.agent-shared-feedback { min-width: 0; }
@@ -14066,15 +14067,6 @@ body.table-col-resizing * {
box-shadow: 0 4px 16px rgba(45, 42, 38, 0.18);
}
/* Persistent agent state uses reserved rows; transient results float above it. */
.agent-shared-feedback {
grid-template-rows: 20px 36px;
}
.agent-shared-feedback > .agent-write-state,
.agent-shared-feedback > small {
min-height: 0;
overflow: auto;
}
.agent-model-status {
display: block;
height: 20px;
+68
View File
@@ -0,0 +1,68 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = 'http://127.0.0.1:1421';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
const page = await browser.newPage();
page.setDefaultTimeout(10000);
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
const open = async query => {
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_models')
&& !document.querySelector('.agent-model-trigger')?.disabled);
};
const positions = () => page.locator('.agent-save-bar, .agent-save-actions button, .agent-run-controls').evaluateAll(nodes => nodes.map(node => {
const rect = node.getBoundingClientRect();
return [rect.x + scrollX, rect.y + scrollY, rect.width, rect.height];
}));
const assertCompact = async () => {
const geometry = await positions();
assert.ok(geometry[0][3] <= 60, 'The save bar occupies only a button-height row plus padding');
assert.ok(geometry[2][1] - geometry[0][1] - geometry[0][3] <= 20, 'No blank feedback region separates save and run controls');
assert.equal(await page.locator('.agent-shared-feedback').count(), 0, 'Empty state feedback does not reserve space');
assert.ok(await page.locator('.agent-save-actions button').evaluate(node => node.getBoundingClientRect().width <= 160), 'Save button stays compact in both entry points');
return geometry;
};
fs.mkdirSync('misc', { recursive: true });
for (const width of [1280, 640, 360]) for (const embedded of [false, true]) {
await page.setViewportSize({ width, height: 900 });
await open(`client=codex&fail-apply${embedded ? '&embedded' : ''}`);
const initial = await assertCompact();
const save = page.getByRole('button', { name: '更新配置', exact: true });
await page.locator('.agent-model-trigger').click();
await page.getByRole('option', { name: 'gpt-two' }).click();
await page.locator('.agent-save-bar').getByText('待应用', { exact: true }).waitFor();
assert.deepEqual(await positions(), initial, 'Pending state fits beside the existing save button');
await save.click();
const failure = page.locator('.app-notice-stack').getByRole('alert');
await failure.filter({ hasText: /模拟配置写入失败/ }).waitFor();
assert.deepEqual(await positions(), initial, 'Save failure does not shift controls');
await failure.getByRole('button').click();
await failure.waitFor({ state: 'detached' });
await save.click();
await page.waitForFunction(() => document.documentElement.dataset.fixtureApplied === '1');
const success = page.locator('.app-notice-stack').getByRole('status');
await success.waitFor();
assert.deepEqual(await positions(), initial, 'Saving and clearing pending state do not shift controls');
await success.getByRole('button').click();
await assertCompact();
if (width === 1280) await page.screenshot({ path: `misc/agent-layout-after-${embedded ? 'embedded' : 'codex'}.png`, fullPage: true });
await page.getByRole('tab', { name: '配置管理', exact: true }).click();
assert.equal(await page.locator('.agent-shared-feedback').count(), 0, 'Inactive configuration tabs have no empty state row');
await open(`client=zcode${embedded ? '&embedded' : ''}`);
const description = page.locator('.agent-save-bar .agent-save-feedback small');
await description.waitFor();
assert.match(await description.innerText(), /重新启动 ZCode/);
assert.ok(await description.evaluate(node => node.scrollHeight <= node.clientHeight + 1), 'Persistent instructions wrap naturally without clipping or nested scrollbars');
const geometry = await positions();
assert.ok(geometry[2][1] - geometry[0][1] - geometry[0][3] <= 20);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, 'The configuration page fits narrow windows');
}
assert.deepEqual(errors, []);
console.log('PASS: compact save rows, no empty feedback, stable pending/error/success states, readable instructions, full and embedded views at 1280/640/360px.');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+5 -1
View File
@@ -56,7 +56,11 @@ function Fixture() {
{mounted && <><FloatingNotice key={first.revision} notice={first.notice} onDismiss={first.clearNotice} />
<FloatingNotice key={second.revision} notice={second.notice} onDismiss={second.clearNotice} /></>}
<div data-testid="before">Existing form</div>
<AgentConfigurationFeedback pending={agent} notice={agent ? 'Agent saved' : ''} error="" description={agent ? 'Persistent description '.repeat(40) : ''} />
<div className="agent-save-bar">
<AgentConfigurationFeedback pending={agent} description="" />
<div className="agent-save-actions"><button>Apply agent</button></div>
</div>
<MessageNotice tone="success" message={agent ? 'Agent saved' : ''} />
{quota && <QuotaActionFeedback name="test.json" quota={{ status: 'success', rows: [], actionResult: { action: 'reset', status: 'refresh-error', error: 'query failed' } }} />}
<input data-testid="after" defaultValue="Keep focus" />
</section>