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();