From c3ea47a895e284a0d15e2b1bb55ceb8796f2485c Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Wed, 23 Sep 2026 01:35:41 +0800
Subject: [PATCH] feat(agents): align workbench layout and scrolling with usage
records
- Implement full-height shell chaining to fix the workbench bottom gap
to the window edge via content padding, matching Usage Records
- Enforce equal-height panels with a 1:3 ratio for the client list and
configuration workbench on desktop viewports
- Pin subpage tabs to the top of the configuration panel and wrap body
content in an independent scroll region with max-content auto rows
- Unify Codex session list scrolling within the main configuration scroller
to avoid nested scrollbars on desktop
- Fall back to natural document scrolling on mobile and narrow viewports
- Add shell fixture support and comprehensive Playwright layout assertions
for fixed-edge gaps, panel ratios, and scroll accessibility
---
src/pages/AgentsPage.tsx | 2 +
src/styles.css | 237 +++++++--
tests/agent-configuration-layout-ui.cjs | 612 ++++++++++++++++++++++--
tests/agent-management-ui.cjs | 75 ++-
tests/fixtures/agent-backups.tsx | 10 +-
5 files changed, 854 insertions(+), 82 deletions(-)
diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx
index c7bfb81..8bcb6b3 100644
--- a/src/pages/AgentsPage.tsx
+++ b/src/pages/AgentsPage.tsx
@@ -2080,6 +2080,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
))}
) : null}
+
{embedded && activeSubpage === 'core' ? (
@@ -2491,6 +2492,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
onStop={() => void stopDeepSeekHarness()} onRestartWeb={() => void restartDeepSeekHarness()} error={launchError} onErrorDismiss={() => setLaunchError('')} /> : null}
+
diff --git a/src/styles.css b/src/styles.css
index 1bb4846..6459c59 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -5947,17 +5947,20 @@ textarea:focus-visible {
.agent-workbench {
display: grid;
- grid-template-columns: 230px minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
+ min-height: 0;
gap: 16px;
- align-items: start;
+ align-items: stretch;
}
.agent-client-list {
+ min-width: 0;
min-height: 0;
}
.agent-config-panel {
min-height: 0;
+ overflow: visible;
}
.agents-page {
@@ -6005,18 +6008,22 @@ textarea:focus-visible {
}
.agent-client-list {
- display: grid;
- align-content: start;
+ position: relative;
+ display: block;
+ overflow: hidden;
padding: 14px;
}
.agent-list-items button > span {
display: grid;
+ width: 100%;
min-width: 0;
gap: 3px;
}
.agent-list-items strong {
+ min-width: 0;
+ max-width: 100%;
color: var(--theme-2d2a26);
font-size: var(--font-size-section);
line-height: 1.3;
@@ -6024,6 +6031,8 @@ textarea:focus-visible {
}
.agent-list-items small {
+ min-width: 0;
+ max-width: 100%;
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
line-height: 1.4;
@@ -6032,12 +6041,15 @@ textarea:focus-visible {
}
.agent-list-items {
+ position: absolute;
+ inset: 14px;
display: grid;
align-content: start;
grid-auto-rows: max-content;
gap: 6px;
+ height: auto;
min-height: 0;
- max-height: min(800px, calc(100vh - 154px));
+ max-height: none;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
@@ -6125,14 +6137,22 @@ textarea:focus-visible {
}
.agent-config-panel {
- display: grid;
- grid-template-rows: auto auto;
- align-content: start;
+ display: flex;
+ flex-direction: column;
gap: 14px;
container-type: inline-size;
container-name: agent-panel;
}
+.agent-config-scroll-region {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ align-content: start;
+ grid-auto-rows: max-content;
+ gap: 14px;
+}
+
.agent-subpage-tabs {
display: inline-grid;
grid-auto-flow: column;
@@ -6445,7 +6465,11 @@ textarea:focus-visible {
.codex-session-list {
display: grid;
min-width: 0;
- overflow: hidden;
+ max-height: clamp(240px, calc(100dvh - 600px), 480px);
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
border: 1px solid var(--theme-e3e1db);
border-radius: 7px;
background: var(--theme-fff);
@@ -10882,10 +10906,6 @@ body.table-col-resizing * {
display: none;
}
- .agent-workbench {
- grid-template-columns: 230px minmax(0, 1fr);
- }
-
.control-panel,
.version-panel {
grid-column: auto;
@@ -11091,10 +11111,16 @@ body.table-col-resizing * {
max-height: 460px;
}
- .agent-client-list,
+ .agent-client-list {
+ height: 270px;
+ min-height: 0;
+ max-height: 270px;
+ }
+
.agent-config-panel {
height: auto;
min-height: 0;
+ overflow: visible;
}
.codex-session-metrics {
@@ -13754,7 +13780,6 @@ body.table-col-resizing * {
}
.agents-page-embedded .agent-workbench {
- grid-template-columns: minmax(280px, 310px) minmax(0, 1fr);
gap: 18px;
}
@@ -13783,7 +13808,6 @@ body.table-col-resizing * {
.agents-page-embedded .agent-client-list {
min-height: 0;
- max-height: min(560px, calc(100vh - 260px));
overflow: hidden;
padding: 16px;
}
@@ -13801,8 +13825,9 @@ body.table-col-resizing * {
}
.agents-page-embedded .agent-list-items {
+ inset: 16px;
min-height: 0;
- max-height: min(526px, calc(100vh - 294px));
+ max-height: none;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
@@ -13834,15 +13859,14 @@ body.table-col-resizing * {
}
.agents-page-embedded .agent-config-panel {
- max-height: min(720px, calc(100vh - 260px));
- overflow-y: auto;
+ max-height: none;
+ overflow: visible;
padding: 14px;
- scrollbar-gutter: stable;
}
.agents-page-embedded .agent-config-panel {
display: block;
- overflow: hidden;
+ overflow: visible;
padding: 0;
background: var(--theme-fffdf8);
}
@@ -13990,10 +14014,6 @@ body.table-col-resizing * {
}
@media (max-width: 760px) {
- .agents-page-embedded .agent-config-panel {
- max-height: calc(100vh - 180px);
- }
-
.agents-page-embedded .agent-minimal-config {
padding: 16px;
}
@@ -14572,9 +14592,6 @@ body.table-col-resizing * {
display: none;
}
- .agent-workbench {
- grid-template-columns: 230px minmax(0, 1fr);
- }
}
@container workspace (max-width: 760px) {
@@ -14706,11 +14723,21 @@ body.table-col-resizing * {
}
.thinking-alias-editor-panel,
- .thinking-alias-list-panel,
- .agent-client-list,
+ .thinking-alias-list-panel {
+ height: auto;
+ min-height: 0;
+ }
+
+ .agent-client-list {
+ height: 270px;
+ min-height: 0;
+ max-height: 270px;
+ }
+
.agent-config-panel {
height: auto;
min-height: 0;
+ overflow: visible;
}
.thinking-model-list {
@@ -14883,6 +14910,7 @@ body.table-col-resizing * {
.agent-management-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; max-width: 50%; }
.agent-management-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; }
.agents-page-embedded .agent-config-panel { display: grid; gap: 16px; max-height: none; overflow: visible; padding: 20px; }
+.agents-page-embedded .agent-config-scroll-region { gap: 16px; }
.agents-page-embedded .agent-minimal-config { padding: 0; }
.agents-page-embedded .agent-minimal-actions { grid-template-columns: minmax(0, 1fr); }
.agent-subpage-tabs button:focus-visible { outline: 2px solid var(--theme-2d2a26); outline-offset: -2px; }
@@ -15007,8 +15035,9 @@ body.table-col-resizing * {
}
.agent-codex-save-actions .primary-button {
- width: 128px;
- flex-shrink: 0;
+ width: auto;
+ min-width: 128px;
+ flex: none;
}
.agent-codex-save-actions button {
@@ -15016,3 +15045,147 @@ body.table-col-resizing * {
min-width: 0;
padding-inline: 12px;
}
+
+@container agent-panel (max-width: 420px) {
+ .agent-subpage-tabs {
+ grid-auto-flow: row;
+ grid-auto-columns: unset;
+ grid-template-columns: minmax(0, 1fr);
+ width: 100%;
+ overflow-x: visible;
+ }
+
+ .agent-subpage-tabs button {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .agent-auth-segmented {
+ grid-template-columns: minmax(0, 1fr);
+ height: auto;
+ }
+
+ .agent-auth-segmented-button {
+ height: 32px;
+ }
+}
+
+/* Keep the full agent page inset from the window like Usage Records. */
+.app-shell:has(.agents-page:not(.agents-page-embedded)) {
+ height: 100%;
+ min-height: 100%;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .workspace,
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .content {
+ display: flex;
+ min-height: 0;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .content {
+ overflow: hidden;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agents-page:not(.agents-page-embedded) {
+ min-height: 0;
+ flex: 1 1 auto;
+ grid-template-rows: minmax(54px, auto) minmax(0, 1fr);
+ overflow: hidden;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-workbench {
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list,
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-config-panel {
+ height: 100%;
+ min-height: 0;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-config-panel {
+ display: flex;
+ align-content: normal;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-subpage-tabs {
+ flex: none;
+}
+
+.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-config-scroll-region {
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding-right: 4px;
+ scrollbar-gutter: stable;
+}
+
+@media (min-width: 761px) {
+ /* The bounded page already has one right-panel scroller. Let long Codex session
+ results participate in it instead of trapping the wheel in a second scroller. */
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .codex-session-list {
+ max-height: none;
+ overflow: visible;
+ overscroll-behavior: auto;
+ scrollbar-gutter: auto;
+ }
+}
+
+@container workspace (max-width: 760px) {
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-workbench {
+ grid-template-columns: 1fr;
+ grid-template-rows: 270px minmax(0, 1fr);
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list {
+ height: auto;
+ max-height: none;
+ }
+}
+
+@media (max-width: 760px) {
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) {
+ height: auto;
+ min-height: 100%;
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .content {
+ overflow: visible;
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agents-page:not(.agents-page-embedded) {
+ flex: none;
+ grid-template-rows: auto auto;
+ overflow: visible;
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-workbench {
+ height: auto;
+ grid-template-rows: 270px auto;
+ overflow: visible;
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list {
+ height: 270px;
+ max-height: 270px;
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-config-panel {
+ height: auto;
+ overflow: visible;
+ }
+
+ .app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-config-scroll-region {
+ flex: none;
+ overflow: visible;
+ padding-right: 0;
+ scrollbar-gutter: auto;
+ }
+}
diff --git a/tests/agent-configuration-layout-ui.cjs b/tests/agent-configuration-layout-ui.cjs
index 2ebbf39..c0b62e4 100644
--- a/tests/agent-configuration-layout-ui.cjs
+++ b/tests/agent-configuration-layout-ui.cjs
@@ -1,7 +1,18 @@
+// Run Vite on port 1421, then node tests/agent-configuration-layout-ui.cjs.
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';
+const clients = ['codex', 'claude-desktop', 'deepseek-harness', 'zcode', 'cursor-cli'];
+const desktopViewports = [
+ { width: 1280, height: 700 },
+ { width: 1280, height: 941 },
+ { width: 1280, height: 1080 },
+ { width: 900, height: 700 },
+ { width: 900, height: 941 },
+ { width: 900, height: 1080 },
+];
+
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
try {
@@ -10,59 +21,572 @@ const base = 'http://127.0.0.1:1421';
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 tab = name => page.getByRole('tab', { name, exact: true });
+ const open = async ({ client = 'codex', embedded = false, shell = false, extra = '' } = {}) => {
+ const query = ['reset-selections', `client=${client}`, embedded ? 'embedded' : '', shell ? 'shell' : '', extra]
+ .filter(Boolean).join('&');
+ await page.goto(`${base}/tests/fixtures/agent-backups.html?${query}`, {
+ waitUntil: 'domcontentloaded',
+ timeout: 30000,
+ });
+ await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_config_statuses')
+ && document.querySelector('.agent-config-panel')?.getBoundingClientRect().height > 0
+ && !document.querySelector('.agent-header-actions button')?.disabled);
+ await page.evaluate(() => document.fonts.ready);
};
- const positions = () => page.locator('.agent-save-bar, .agent-save-actions .primary-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 .primary-button').evaluate(node => node.getBoundingClientRect().width <= 160), 'Save button stays compact in both entry points');
- return geometry;
+
+ const panelMetrics = () => page.locator('.agent-config-panel').evaluate(panel => {
+ const style = getComputedStyle(panel);
+ const rect = panel.getBoundingClientRect();
+ const visible = node => {
+ const nodeStyle = getComputedStyle(node);
+ const nodeRect = node.getBoundingClientRect();
+ return nodeStyle.display !== 'none' && nodeStyle.visibility !== 'hidden'
+ && nodeRect.width > 0 && nodeRect.height > 0;
+ };
+ const visibleBounds = node => {
+ if (!visible(node)) return null;
+ const nodeRect = node.getBoundingClientRect();
+ const bounds = {
+ left: nodeRect.left,
+ top: nodeRect.top,
+ right: nodeRect.right,
+ bottom: nodeRect.bottom,
+ };
+ for (let ancestor = node.parentElement; ancestor && panel.contains(ancestor); ancestor = ancestor.parentElement) {
+ const ancestorStyle = getComputedStyle(ancestor);
+ const ancestorRect = ancestor.getBoundingClientRect();
+ if (ancestorStyle.overflowX !== 'visible') {
+ bounds.left = Math.max(bounds.left, ancestorRect.left);
+ bounds.right = Math.min(bounds.right, ancestorRect.right);
+ }
+ if (ancestorStyle.overflowY !== 'visible') {
+ bounds.top = Math.max(bounds.top, ancestorRect.top);
+ bounds.bottom = Math.min(bounds.bottom, ancestorRect.bottom);
+ }
+ if (ancestor === panel) break;
+ }
+ return bounds.right - bounds.left > 0 && bounds.bottom - bounds.top > 0 ? bounds : null;
+ };
+ const controlsOutside = Array.from(panel.querySelectorAll('button, input, select, textarea, [role="tab"]'))
+ .map(node => {
+ const nodeRect = visibleBounds(node);
+ if (!nodeRect) return null;
+ return {
+ label: node.getAttribute('aria-label') || node.textContent?.trim().replace(/\s+/g, ' ').slice(0, 60)
+ || node.tagName.toLowerCase(),
+ left: nodeRect.left,
+ top: nodeRect.top,
+ right: nodeRect.right,
+ bottom: nodeRect.bottom,
+ };
+ })
+ .filter(Boolean)
+ .filter(item => item.left < rect.left - 1 || item.right > rect.right + 1
+ || item.top < rect.top - 1 || item.bottom > rect.bottom + 1);
+ const directChildren = Array.from(panel.children).filter(visible);
+ const finalContentBottom = directChildren.length
+ ? Math.max(...directChildren.map(node => node.getBoundingClientRect().bottom))
+ : rect.top;
+ return {
+ clientHeight: panel.clientHeight,
+ scrollHeight: panel.scrollHeight,
+ clientWidth: panel.clientWidth,
+ scrollWidth: panel.scrollWidth,
+ overflowX: style.overflowX,
+ overflowY: style.overflowY,
+ paddingBottom: Number.parseFloat(style.paddingBottom),
+ bottomGap: rect.bottom - finalContentBottom,
+ controlsOutside,
+ };
+ });
+
+ const assertNaturalPanel = async (label, { checkControls = true } = {}) => {
+ const metrics = await panelMetrics();
+ const clippingValues = new Set(['auto', 'scroll', 'hidden', 'clip']);
+ assert.equal(clippingValues.has(metrics.overflowX), false,
+ `${label}: the outer configuration panel must not clip or own horizontal scrolling`);
+ assert.equal(clippingValues.has(metrics.overflowY), false,
+ `${label}: the outer configuration panel must not clip or own vertical scrolling`);
+ assert.ok(metrics.scrollHeight <= metrics.clientHeight + 1,
+ `${label}: the outer configuration panel must expand to all vertical content`);
+ assert.ok(metrics.scrollWidth <= metrics.clientWidth + 1,
+ `${label}: the outer configuration panel must contain all horizontal content`);
+ assert.ok(metrics.bottomGap <= metrics.paddingBottom + 2,
+ `${label}: the natural-height panel must not leave a fixed-height blank region below its content`);
+ if (checkControls) {
+ assert.deepEqual(metrics.controlsOutside, [], `${label}: every visible control must remain inside the panel`);
+ const tabs = await page.locator('.agent-subpage-tabs').evaluateAll(nodes => nodes
+ .filter(node => getComputedStyle(node).display !== 'none')
+ .map(node => node.getBoundingClientRect().height));
+ assert.ok(tabs.every(height => height >= 40), `${label}: the tab bar must retain its full height`);
+ }
+ return metrics;
};
- 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 });
+
+ 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 listRect = list.getBoundingClientRect();
+ const lastRect = last?.getBoundingClientRect();
+ const lastReachable = !!lastRect && lastRect.top >= listRect.top - 1 && lastRect.bottom <= listRect.bottom + 1;
+ list.scrollTop = 0;
+ return {
+ clientHeight: list.clientHeight,
+ scrollHeight: list.scrollHeight,
+ overflowY: style.overflowY,
+ scrollTop,
+ lastReachable,
+ };
+ });
+ assert.equal(metrics.overflowY, 'auto', `${label}: the client list must own vertical scrolling`);
+ 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.lastReachable, true, `${label}: scrolling must reveal the final client`);
+ };
+
+ const assertDesktopLayout = async (label, { requireClientOverflow = true } = {}) => {
+ const [clientList, configuration] = await page.locator('.agent-client-list, .agent-config-panel')
+ .evaluateAll(nodes => nodes.map(node => {
+ const rect = node.getBoundingClientRect();
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+ }));
+ assert.ok(Math.abs(clientList.height - configuration.height) <= 1,
+ `${label}: the client list and configuration panel must stay equal in height`);
+ assert.ok(Math.abs(clientList.width * 3 - configuration.width) <= 2,
+ `${label}: the desktop workbench must retain its 1:3 width ratio`);
+ assert.ok(Math.abs(clientList.y - configuration.y) <= 1,
+ `${label}: the desktop panels must share a top edge`);
+ await assertNaturalPanel(label);
+ await assertClientListScroll(label, { requireOverflow: requireClientOverflow });
+ return { clientList, configuration };
+ };
+
+ const assertNarrowLayout = async label => {
+ const [clientList, configuration] = await page.locator('.agent-client-list, .agent-config-panel')
+ .evaluateAll(nodes => nodes.map(node => {
+ const rect = node.getBoundingClientRect();
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
+ }));
+ assert.ok(Math.abs(clientList.x - configuration.x) <= 1
+ && Math.abs(clientList.width - configuration.width) <= 1,
+ `${label}: narrow panels must stack at the same width`);
+ assert.ok(clientList.y + clientList.height <= configuration.y + 1,
+ `${label}: the client list must stay above the configuration panel`);
+ await assertNaturalPanel(label, { checkControls: false });
+ await assertClientListScroll(label);
+ const pageWidth = await page.evaluate(() => ({
+ clientWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ }));
+ assert.ok(pageWidth.scrollWidth <= pageWidth.clientWidth + 1,
+ `${label}: the page must not create horizontal scrolling`);
+ };
+
+ const assertShellScrollRegion = async (label, { requireOverflow = false, natural = false } = {}) => {
+ const metrics = await page.locator('.agent-config-panel').evaluate((panel, naturalPanel) => {
+ const region = panel.querySelector('.agent-config-scroll-region');
+ if (!region) throw new Error('Missing .agent-config-scroll-region');
+ const panelRect = panel.getBoundingClientRect();
+ const regionRect = region.getBoundingClientRect();
+ const regionStyle = getComputedStyle(region);
+ const panelStyle = getComputedStyle(panel);
+ const tablist = panel.querySelector('.agent-subpage-tabs');
+ const tabRect = tablist?.getBoundingClientRect() ?? null;
+ const tabHeights = tablist
+ ? Array.from(tablist.querySelectorAll('[role="tab"]')).map(tab => tab.getBoundingClientRect().height)
+ : [];
+ const visibleChildren = Array.from(region.children).filter(child => {
+ const style = getComputedStyle(child);
+ const rect = child.getBoundingClientRect();
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
+ });
+ region.scrollTop = region.scrollHeight;
+ const scrollTop = region.scrollTop;
+ const scrolledRegionRect = region.getBoundingClientRect();
+ const finalChildRect = visibleChildren.at(-1)?.getBoundingClientRect() ?? null;
+ const finalChildReachable = !finalChildRect
+ || (finalChildRect.bottom <= scrolledRegionRect.bottom + 1
+ && finalChildRect.bottom >= scrolledRegionRect.top - 1);
+ const unexpectedScrollOwners = Array.from(panel.querySelectorAll('*'))
+ .filter(node => node !== region)
+ .filter(node => {
+ const overflowY = getComputedStyle(node).overflowY;
+ return node.scrollHeight > node.clientHeight + 1 && (overflowY === 'auto' || overflowY === 'scroll');
+ })
+ .map(node => node.className || node.tagName.toLowerCase());
+ region.scrollTop = 0;
+ return {
+ naturalPanel,
+ panelClientHeight: panel.clientHeight,
+ panelScrollHeight: panel.scrollHeight,
+ panelClientWidth: panel.clientWidth,
+ panelScrollWidth: panel.scrollWidth,
+ panelOverflowY: panelStyle.overflowY,
+ regionClientHeight: region.clientHeight,
+ regionScrollHeight: region.scrollHeight,
+ regionClientWidth: region.clientWidth,
+ regionScrollWidth: region.scrollWidth,
+ regionOverflowY: regionStyle.overflowY,
+ regionRect: { top: regionRect.top, bottom: regionRect.bottom },
+ panelRect: { top: panelRect.top, bottom: panelRect.bottom },
+ tabRect: tabRect ? { top: tabRect.top, bottom: tabRect.bottom, height: tabRect.height } : null,
+ tabHeights,
+ tabInsideRegion: !!tablist && region.contains(tablist),
+ scrollTop,
+ finalChildReachable,
+ unexpectedScrollOwners,
+ };
+ }, natural);
+ assert.ok(metrics.regionClientHeight > 0, `${label}: the configuration scroll region must have usable height`);
+ assert.ok(metrics.regionScrollWidth <= metrics.regionClientWidth + 1,
+ `${label}: the configuration scroll region must not hide horizontal content`);
+ assert.equal(metrics.finalChildReachable, true,
+ `${label}: scrolling the configuration region must reveal its final content block`);
+ if (natural) {
+ assert.equal(metrics.regionOverflowY, 'visible',
+ `${label}: document-flow layouts must expand the configuration region naturally`);
+ } else {
+ assert.equal(metrics.regionOverflowY, 'auto',
+ `${label}: the configuration content must own vertical scrolling inside the fixed panel`);
+ assert.ok(metrics.panelScrollHeight <= metrics.panelClientHeight + 1,
+ `${label}: the outer configuration panel must not become a second vertical scroll owner`);
+ assert.ok(metrics.panelScrollWidth <= metrics.panelClientWidth + 1,
+ `${label}: the outer configuration panel must contain the scroll region horizontally`);
+ assert.ok(metrics.regionRect.top >= metrics.panelRect.top - 1
+ && metrics.regionRect.bottom <= metrics.panelRect.bottom + 1,
+ `${label}: the configuration scroll region must fit inside the outer panel`);
+ }
+ if (requireOverflow) {
+ assert.ok(metrics.regionScrollHeight > metrics.regionClientHeight + 1,
+ `${label}: the short viewport fixture must exercise vertical configuration overflow`);
+ assert.ok(metrics.scrollTop > 0,
+ `${label}: the configuration scroll region must accept vertical scrolling`);
+ }
+ if (metrics.tabRect) {
+ assert.equal(metrics.tabInsideRegion, false,
+ `${label}: tabs must stay fixed above, not inside, the scrolling content region`);
+ assert.ok(metrics.tabRect.height >= 40 && metrics.tabHeights.every(height => height >= 34),
+ `${label}: the tab bar and tab buttons must retain their full height`);
+ assert.ok(metrics.tabRect.top >= metrics.panelRect.top - 1
+ && metrics.tabRect.bottom <= metrics.panelRect.bottom + 1,
+ `${label}: the tab bar must remain fully inside the outer panel`);
+ assert.ok(natural || metrics.tabRect.bottom <= metrics.regionRect.top + 1,
+ `${label}: scrolling content must begin below the fixed tab bar`);
+ }
+ assert.deepEqual(metrics.unexpectedScrollOwners, [],
+ `${label}: no nested element other than the configuration region may unexpectedly own scrolling`);
+ return metrics;
+ };
+
+ const shellLayoutMetrics = () => page.locator('.agent-workbench').evaluate(workbench => {
+ const content = workbench.closest('.content');
+ const clientList = workbench.querySelector('.agent-client-list');
+ const configuration = workbench.querySelector('.agent-config-panel');
+ if (!content || !clientList || !configuration) throw new Error('Incomplete shell fixture');
+ const contentRect = content.getBoundingClientRect();
+ const workbenchRect = workbench.getBoundingClientRect();
+ const clientRect = clientList.getBoundingClientRect();
+ const configurationRect = configuration.getBoundingClientRect();
+ const contentStyle = getComputedStyle(content);
+ return {
+ content: { top: contentRect.top, bottom: contentRect.bottom, height: contentRect.height },
+ workbench: { top: workbenchRect.top, bottom: workbenchRect.bottom, height: workbenchRect.height },
+ client: { x: clientRect.x, y: clientRect.y, width: clientRect.width, height: clientRect.height, bottom: clientRect.bottom },
+ configuration: {
+ x: configurationRect.x,
+ y: configurationRect.y,
+ width: configurationRect.width,
+ height: configurationRect.height,
+ bottom: configurationRect.bottom,
+ },
+ paddingBottom: Number.parseFloat(contentStyle.paddingBottom),
+ bottomGap: contentRect.bottom - workbenchRect.bottom,
+ documentClientHeight: document.documentElement.clientHeight,
+ documentScrollHeight: document.documentElement.scrollHeight,
+ documentClientWidth: document.documentElement.clientWidth,
+ documentScrollWidth: document.documentElement.scrollWidth,
+ };
+ });
+
+ const assertFixedShellDesktop = async (label, { requireRightOverflow = false } = {}) => {
+ const metrics = await shellLayoutMetrics();
+ assert.ok(Math.abs(metrics.client.height - metrics.configuration.height) <= 1,
+ `${label}: fixed-height desktop panels must remain equal in height`);
+ assert.ok(Math.abs(metrics.client.width * 3 - metrics.configuration.width) <= 2,
+ `${label}: fixed-height desktop panels must retain the 1:3 width ratio`);
+ assert.ok(Math.abs(metrics.client.y - metrics.configuration.y) <= 1,
+ `${label}: fixed-height desktop panels must share their top edge`);
+ assert.ok(Math.abs(metrics.bottomGap - metrics.paddingBottom) <= 1,
+ `${label}: workbench bottom gap must equal the content edge padding`);
+ assert.ok(metrics.documentScrollHeight <= metrics.documentClientHeight + 1,
+ `${label}: fixed-height desktop layout must not create a far-right page scrollbar`);
+ assert.ok(metrics.documentScrollWidth <= metrics.documentClientWidth + 1,
+ `${label}: fixed-height desktop layout must not create horizontal page scrolling`);
+ await assertClientListScroll(label, { requireOverflow: requireRightOverflow });
+ await assertShellScrollRegion(label, { requireOverflow: requireRightOverflow });
+ return metrics;
+ };
+
+ const openSubpage = async name => {
+ const target = tab(name);
+ if (await target.count()) await target.click();
+ };
+
+ const textFlow = { full: {}, embedded: {} };
+ const naturalHeight = { full: {}, embedded: {} };
+ for (const viewport of desktopViewports) {
+ await page.setViewportSize(viewport);
+ for (const embedded of [false, true]) {
+ for (const client of clients) {
+ await open({ client, embedded });
+ await openSubpage('基础配置');
+ const label = `${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`;
+ const core = await assertDesktopLayout(label);
+ if (client === 'zcode' && viewport.height === 941) {
+ naturalHeight[embedded ? 'embedded' : 'full'][viewport.width] = core.configuration.height;
+ const status = page.locator('.agent-list-items button').filter({ hasText: 'Antigravity CLI' }).locator('small');
+ await status.evaluate(node => {
+ node.textContent = '当前设备已检测到本地智能体客户端,可以直接从此页面启动';
+ });
+ const flow = await status.evaluate(node => ({
+ width: node.getBoundingClientRect().width,
+ height: node.getBoundingClientRect().height,
+ fits: node.scrollWidth <= node.clientWidth + 1 && node.scrollHeight <= node.clientHeight + 1,
+ whiteSpace: getComputedStyle(node).whiteSpace,
+ }));
+ assert.equal(flow.fits, true, `${label}: client status text must wrap without clipping`);
+ assert.equal(flow.whiteSpace, 'normal', `${label}: client status text must use responsive wrapping`);
+ const afterWrap = await page.locator('.agent-client-list, .agent-config-panel')
+ .evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
+ assert.ok(Math.abs(afterWrap[0] - afterWrap[1]) <= 1,
+ `${label}: wrapped client text must not break equal panel heights`);
+ textFlow[embedded ? 'embedded' : 'full'][viewport.width] = flow;
+ }
+
+ await openSubpage('配置管理');
+ await assertDesktopLayout(`${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/management`);
+ }
+ }
+ }
+
+ for (const mode of ['full', 'embedded']) {
+ assert.ok(textFlow[mode][900].width < textFlow[mode][1280].width,
+ `${mode}: client text width must follow the proportional sidebar`);
+ assert.ok(textFlow[mode][900].height > textFlow[mode][1280].height,
+ `${mode}: client text must reflow as the proportional sidebar narrows`);
+ assert.ok(naturalHeight[mode][900] > naturalHeight[mode][1280],
+ `${mode}: the natural panel height must grow when its content reflows at a narrower width`);
+ }
+
+ // In the real application shell, the desktop workbench fills the same bounded content area as Usage records.
+ // Its distance from the window edge is the content padding, while each panel owns its own scrolling.
+ const fixedShellByHeight = new Map();
+ for (const height of [700, 900, 941, 1080]) {
+ const viewport = { width: 1280, height };
+ await page.setViewportSize(viewport);
+ await open({ client: 'claude-desktop', shell: true });
+ await openSubpage('基础配置');
+ const label = `${viewport.width}x${viewport.height}/shell/claude-desktop/core`;
+ const metrics = await assertFixedShellDesktop(label, { requireRightOverflow: height === 700 });
+ fixedShellByHeight.set(height, metrics);
+ }
+ const fixedShellEntries = [...fixedShellByHeight.entries()];
+ for (let index = 1; index < fixedShellEntries.length; index += 1) {
+ const [previousViewportHeight, previous] = fixedShellEntries[index - 1];
+ const [viewportHeight, current] = fixedShellEntries[index];
+ assert.ok(Math.abs(current.bottomGap - previous.bottomGap) <= 1,
+ `${viewportHeight}px shell: the window-edge gap must remain fixed while resizing vertically`);
+ assert.ok(Math.abs((current.workbench.height - previous.workbench.height)
+ - (viewportHeight - previousViewportHeight)) <= 2,
+ `${viewportHeight}px shell: workbench height must track the viewport-height delta`);
+ assert.ok(Math.abs(current.workbench.top - previous.workbench.top) <= 1,
+ `${viewportHeight}px shell: vertical resizing must not move the workbench top edge`);
+ }
+
+ // A 900px window still uses the bounded shell, but its usable workspace crosses the one-column breakpoint.
+ await page.setViewportSize({ width: 900, height: 941 });
+ await open({ client: 'claude-desktop', shell: true });
+ await openSubpage('基础配置');
+ const shellNarrowLabel = '900x941/shell/claude-desktop/core';
+ const shellNarrow = await shellLayoutMetrics();
+ assert.ok(Math.abs(shellNarrow.client.x - shellNarrow.configuration.x) <= 1
+ && Math.abs(shellNarrow.client.width - shellNarrow.configuration.width) <= 1,
+ `${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(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,
+ `${shellNarrowLabel}: the bounded one-column shell must not leak scrolling to the page`);
+ await assertClientListScroll(shellNarrowLabel);
+ await assertShellScrollRegion(shellNarrowLabel);
+
+ // At phone width the shell follows Usage records and returns to natural document scrolling.
+ await page.setViewportSize({ width: 640, height: 700 });
+ await open({ client: 'claude-desktop', shell: true });
+ await openSubpage('基础配置');
+ const shellPhoneLabel = '640x700/shell/claude-desktop/core';
+ const shellPhone = await shellLayoutMetrics();
+ assert.ok(Math.abs(shellPhone.client.x - shellPhone.configuration.x) <= 1
+ && Math.abs(shellPhone.client.width - shellPhone.configuration.width) <= 1,
+ `${shellPhoneLabel}: phone-width panels must remain stacked at the same width`);
+ assert.ok(shellPhone.client.bottom <= shellPhone.configuration.y + 1,
+ `${shellPhoneLabel}: phone-width client list must remain above configuration content`);
+ assert.ok(shellPhone.documentScrollWidth <= shellPhone.documentClientWidth + 1,
+ `${shellPhoneLabel}: natural document flow must not create horizontal scrolling`);
+ await assertClientListScroll(shellPhoneLabel);
+ await assertShellScrollRegion(shellPhoneLabel, { natural: true });
+
+ for (const viewport of [{ width: 640, height: 700 }, { width: 360, height: 941 }]) {
+ await page.setViewportSize(viewport);
+ for (const embedded of [false, true]) {
+ for (const client of clients) {
+ await open({ client, embedded });
+ await openSubpage('基础配置');
+ await assertNarrowLayout(`${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/core`);
+ await openSubpage('配置管理');
+ await assertNarrowLayout(`${viewport.width}x${viewport.height}/${embedded ? 'embedded' : 'full'}/${client}/management`);
+ }
+ }
+ }
+
+ // The potentially large Codex session collection is the one intentional right-panel scroll owner.
+ for (const viewport of [
+ { width: 1280, height: 700 },
+ { width: 1280, height: 900 },
+ { width: 1280, height: 941 },
+ { width: 1280, height: 1080 },
+ { width: 900, height: 941 },
+ ]) {
+ await page.setViewportSize(viewport);
+ await open({ client: 'codex' });
+ await tab('会话管理').click();
+ await page.locator('.codex-session-row').first().waitFor();
+ const label = `${viewport.width}x${viewport.height}/full/codex/sessions`;
+ await assertDesktopLayout(label, { requireClientOverflow: false });
+ const sessions = await page.locator('.codex-session-list').evaluate(list => {
+ const style = getComputedStyle(list);
+ const last = list.querySelector('.codex-session-row:last-child');
+ list.scrollTop = list.scrollHeight;
+ const scrollTop = list.scrollTop;
+ const listRect = list.getBoundingClientRect();
+ const lastRect = last?.getBoundingClientRect();
+ const lastReachable = !!lastRect && lastRect.top >= listRect.top - 1 && lastRect.bottom <= listRect.bottom + 1;
+ list.scrollTop = 0;
+ return {
+ height: listRect.height,
+ clientHeight: list.clientHeight,
+ scrollHeight: list.scrollHeight,
+ overflowY: style.overflowY,
+ scrollTop,
+ lastReachable,
+ };
+ });
+ assert.equal(sessions.overflowY, 'auto', `${label}: only the session list should own session-result scrolling`);
+ assert.ok(sessions.scrollHeight > sessions.clientHeight + 1, `${label}: the fixture session list should be bounded`);
+ assert.ok(sessions.height <= 482, `${label}: the session list must respect its bounded maximum height`);
+ assert.ok(sessions.scrollTop > 0, `${label}: the session list must accept scrolling`);
+ assert.equal(sessions.lastReachable, true, `${label}: scrolling must reveal the final loaded session`);
+ const rightPanelScrollOwners = await page.locator('.agent-config-panel').evaluate(panel => Array.from(panel.querySelectorAll('*'))
+ .filter(node => {
+ const overflowY = getComputedStyle(node).overflowY;
+ return node.scrollHeight > node.clientHeight + 1 && (overflowY === 'auto' || overflowY === 'scroll');
+ })
+ .map(node => node.className));
+ assert.deepEqual(rightPanelScrollOwners, ['codex-session-list'],
+ `${label}: no other right-panel region may hide vertically overflowing content`);
+ if (viewport.width === 1280 && viewport.height >= 900) {
+ const pageHeight = await page.evaluate(() => ({
+ clientHeight: document.documentElement.clientHeight,
+ scrollHeight: document.documentElement.scrollHeight,
+ }));
+ assert.ok(pageHeight.scrollHeight <= pageHeight.clientHeight + 1,
+ `${label}: the session page must not create a far-right page scrollbar at this desktop height`);
+ }
+ }
+
+ // The real bounded shell must keep a single right-panel scroll owner. The
+ // session collection flows through that region instead of nesting a second
+ // wheel/trackpad trap inside it.
+ for (const viewport of [
+ { width: 1280, height: 700 },
+ { width: 1280, height: 941 },
+ ]) {
+ await page.setViewportSize(viewport);
+ await open({ client: 'codex', shell: true });
+ await tab('会话管理').click();
+ await page.locator('.codex-session-row').first().waitFor();
+ const label = `${viewport.width}x${viewport.height}/shell/codex/sessions`;
+ await assertFixedShellDesktop(label, { requireRightOverflow: true });
+ const sessions = await page.locator('.agent-config-panel').evaluate(panel => {
+ const region = panel.querySelector('.agent-config-scroll-region');
+ const list = panel.querySelector('.codex-session-list');
+ const finalSession = list?.querySelector('.codex-session-row:last-child');
+ if (!region || !list || !finalSession) throw new Error('Incomplete bounded session fixture');
+ region.scrollTop = region.scrollHeight;
+ const regionRect = region.getBoundingClientRect();
+ const finalSessionRect = finalSession.getBoundingClientRect();
+ const metrics = {
+ listClientHeight: list.clientHeight,
+ listScrollHeight: list.scrollHeight,
+ listOverflowY: getComputedStyle(list).overflowY,
+ regionScrollTop: region.scrollTop,
+ finalSessionReachable: finalSessionRect.bottom <= regionRect.bottom + 1
+ && finalSessionRect.bottom >= regionRect.top - 1,
+ };
+ region.scrollTop = 0;
+ return metrics;
+ });
+ assert.equal(sessions.listOverflowY, 'visible',
+ `${label}: the session collection must flow through the shared configuration scroller`);
+ assert.ok(sessions.listScrollHeight <= sessions.listClientHeight + 1,
+ `${label}: the session collection must not create a nested scroll range`);
+ assert.ok(sessions.regionScrollTop > 0,
+ `${label}: the shared configuration region must accept session-page scrolling`);
+ assert.equal(sessions.finalSessionReachable, true,
+ `${label}: scrolling the shared region must reveal the final session`);
+ }
+
+ // Pending, failure and success feedback must not reintroduce clipping or fixed-height gaps.
+ for (const embedded of [false, true]) {
+ await page.setViewportSize({ width: 1280, height: 941 });
+ await open({ client: 'codex', embedded, extra: 'fail-apply' });
+ const label = `1280x941/${embedded ? 'embedded' : 'full'}/codex/dynamic-state`;
+ const controls = '.agent-save-bar, .agent-save-actions .primary-button, .agent-run-controls';
+ const original = await page.locator(controls).evaluateAll(nodes => nodes.map(node => {
+ const rect = node.getBoundingClientRect();
+ return [rect.x + scrollX, rect.y + scrollY, rect.width, rect.height];
+ }));
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 assertDesktopLayout(`${label}/pending`);
+ assert.deepEqual(await page.locator(controls).evaluateAll(nodes => nodes.map(node => {
+ const rect = node.getBoundingClientRect();
+ return [rect.x + scrollX, rect.y + scrollY, rect.width, rect.height];
+ })), original, `${label}: pending feedback must not shift the controls`);
+ const save = page.getByRole('button', { name: '更新配置', exact: true });
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');
+ const failure = page.locator('.app-notice-stack').getByRole('alert').filter({ hasText: /模拟配置写入失败/ });
+ await failure.waitFor();
+ await assertDesktopLayout(`${label}/failure`);
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');
+ await assertDesktopLayout(`${label}/success`);
}
+
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(); }
+ console.log('PASS: fixed-edge-gap shell sizing, equal 1:3 panels, reachable internal scrolling, natural mobile flow, and unclipped controls.');
+ } finally {
+ await browser.close();
+ }
})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/agent-management-ui.cjs b/tests/agent-management-ui.cjs
index 169c5ac..71d5c3e 100644
--- a/tests/agent-management-ui.cjs
+++ b/tests/agent-management-ui.cjs
@@ -25,6 +25,70 @@ const path = require('node:path');
const button = name => page.getByRole('button', { name, exact: true });
const calls = cmd => page.evaluate(cmd => window.fixtureCalls.filter(call => call.cmd === cmd), cmd);
const pending = () => page.getByText('待应用', { exact: true }).waitFor();
+ const assertPanelContentFullyVisible = async context => {
+ const layout = await page.locator('.agent-config-panel').evaluate(panel => {
+ const tolerance = 1.5;
+ const panelRect = panel.getBoundingClientRect();
+ const panelStyle = getComputedStyle(panel);
+ const isVisible = element => {
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.display !== 'none' && style.visibility !== 'hidden'
+ && rect.width > 0 && rect.height > 0;
+ };
+ const describe = element => {
+ const id = element.id ? `#${element.id}` : '';
+ const classes = [...element.classList].slice(0, 3).map(name => `.${name}`).join('');
+ const role = element.getAttribute('role');
+ const text = (element.getAttribute('aria-label') || element.textContent || '')
+ .replace(/\s+/g, ' ').trim().slice(0, 80);
+ return `${element.tagName.toLowerCase()}${id}${classes}${role ? `[role="${role}"]` : ''}${text ? ` "${text}"` : ''}`;
+ };
+ const outsidePanel = [];
+ const internallyClipped = [];
+ const activePanel = panel.querySelector('[role="tabpanel"]');
+ const candidates = new Set([
+ ...panel.querySelectorAll(':scope > *'),
+ ...(activePanel ? activePanel.querySelectorAll(':scope > *') : []),
+ ...panel.querySelectorAll('button, input, select, textarea, [role="tab"], [role="radio"], [role="switch"], [role="combobox"]'),
+ ]);
+
+ for (const element of candidates) {
+ if (!isVisible(element)) continue;
+ const rect = element.getBoundingClientRect();
+ if (rect.left < panelRect.left - tolerance || rect.right > panelRect.right + tolerance
+ || rect.top < panelRect.top - tolerance || rect.bottom > panelRect.bottom + tolerance) {
+ outsidePanel.push({
+ element: describe(element),
+ rect: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom },
+ });
+ }
+ if (element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1) {
+ internallyClipped.push({
+ element: describe(element),
+ client: { width: element.clientWidth, height: element.clientHeight },
+ scroll: { width: element.scrollWidth, height: element.scrollHeight },
+ });
+ }
+ }
+
+ return {
+ client: { width: panel.clientWidth, height: panel.clientHeight },
+ scroll: { width: panel.scrollWidth, height: panel.scrollHeight },
+ overflowX: panelStyle.overflowX,
+ overflowY: panelStyle.overflowY,
+ outsidePanel,
+ internallyClipped,
+ };
+ });
+ const clippedOverflowValues = new Set(['auto', 'scroll', 'hidden', 'clip']);
+ assert.ok(!clippedOverflowValues.has(layout.overflowX), `${context}: outer panel must not clip or scroll horizontally (${JSON.stringify(layout)})`);
+ assert.ok(!clippedOverflowValues.has(layout.overflowY), `${context}: outer panel must not clip or scroll vertically (${JSON.stringify(layout)})`);
+ assert.ok(layout.scroll.width <= layout.client.width + 1, `${context}: outer panel has horizontally unreachable content (${JSON.stringify(layout)})`);
+ assert.ok(layout.scroll.height <= layout.client.height + 1, `${context}: outer panel has vertically unreachable content (${JSON.stringify(layout)})`);
+ assert.deepEqual(layout.outsidePanel, [], `${context}: visible content extends outside the panel`);
+ assert.deepEqual(layout.internallyClipped, [], `${context}: visible controls or direct content are clipped`);
+ };
for (const mode of ['', 'embedded&']) {
await open(mode + 'defer-restart');
@@ -142,16 +206,17 @@ const path = require('node:path');
assert.equal((await calls('launch_agent')).length, 0);
}
- for (const width of [1280, 540]) for (const theme of ['light', 'dark']) for (const embedded of [false, true]) {
- await page.setViewportSize({ width, height: 900 });
+ for (const { width, height } of [{ width: 1280, height: 900 }, { width: 1280, height: 941 }, { width: 540, height: 700 }]) for (const theme of ['light', 'dark']) for (const embedded of [false, true]) {
+ await page.setViewportSize({ width, height });
await open(`theme=${theme}&locale=en${embedded ? '&embedded' : ''}`);
for (const subpage of ['Basic configuration', 'Configuration management']) {
await tab(subpage).click();
+ const context = `${width}x${height}/${theme}/${embedded ? 'compact' : 'full'}/${subpage}`;
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
- assert.equal(overflow, false, `${width}/${theme}/${embedded}/${subpage} overflow`);
- assert.ok(await page.locator('.agent-config-panel').evaluate(el => el.scrollHeight <= el.clientHeight + 1), 'panel content must not be clipped');
+ assert.equal(overflow, false, `${context}: document overflows horizontally`);
+ await assertPanelContentFullyVisible(context);
assert.ok(await tab(subpage).isVisible());
- await page.screenshot({ path: path.join(screenshots, `${width}-${theme}-${embedded ? 'compact' : 'full'}-${subpage.startsWith('Basic') ? 'core' : 'management'}.png`), fullPage: true });
+ await page.screenshot({ path: path.join(screenshots, `${width}x${height}-${theme}-${embedded ? 'compact' : 'full'}-${subpage.startsWith('Basic') ? 'core' : 'management'}.png`), fullPage: true });
}
}
assert.deepEqual(errors, []);
diff --git a/tests/fixtures/agent-backups.tsx b/tests/fixtures/agent-backups.tsx
index 368c837..2676317 100644
--- a/tests/fixtures/agent-backups.tsx
+++ b/tests/fixtures/agent-backups.tsx
@@ -118,6 +118,14 @@ mockIPC(async (cmd,args:any) => {
throw new Error('Unhandled fixture command: '+cmd);
});
let root=createRoot(document.getElementById('root')!);
-const render=()=>root.render(
{document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/>);
+const render=()=>{
+ const agents=
{document.documentElement.dataset.fixtureApplied=String(++appliedCount);}}/>;
+ root.render({params.has('shell')?(
+
+ ):agents});
+};
(window as any).fixtureRemount=(nextEmbedded=embedded)=>{embedded=nextEmbedded;root.unmount();root=createRoot(document.getElementById('root')!);render();};
render();