feat: add workbench left sidebar collapse

This commit is contained in:
Code Queue Review
2026-05-23 18:45:46 +00:00
parent a2b014b25b
commit dcd2b71932
7 changed files with 462 additions and 14 deletions
+341 -2
View File
@@ -268,6 +268,7 @@ const workbenchMarkers = Object.freeze([
"data-app-shell",
"workbench-shell",
"activity-rail",
"left-sidebar-toggle",
"center-workspace",
"conversation-list",
"agent-chat-status",
@@ -1130,6 +1131,12 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
summary: "#352 default workbench has no aside#resource-explorer, #explorer-resize, resource tree/capability copy, or reclaimed left panel across desktop and mobile.",
observations: summarizeResourceExplorerRemovalCoverage(viewportResults)
},
{
id: "layout-left-sidebar-collapse",
status: Object.values(viewportResults).every((result) => result.leftCollapse?.pass) ? "pass" : "blocked",
summary: "#278 left activity rail collapses and expands on desktop and mobile, persists in localStorage, and gives Code Agent plus M3 panels the reclaimed space without restoring the removed resource explorer.",
observations: summarizeLeftSidebarCollapseCoverage(viewportResults)
},
{
id: "layout-right-sidebar-resize-desktop",
status: desktop.rightResize.pass ? "pass" : "blocked",
@@ -1265,6 +1272,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
screenshotDir: artifactRoot,
screenshots: Object.values(viewportResults).flatMap((result) => [
...(result.artifacts?.screenshots ?? []),
...(result.leftCollapse?.artifacts?.screenshots ?? []),
...(result.gate?.currentRoute?.artifacts?.screenshots ?? [])
]),
reportPath: args.reportPath ?? null
@@ -1429,7 +1437,7 @@ function layoutSourceContract() {
"docs/reference/cloud-workbench.md",
"docs/reference/code-agent-chat-readiness.md"
],
summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, #352 resource-explorer removal, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance."
summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, #278 left navigation collapse/expand, #352 resource-explorer removal, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance."
};
}
@@ -2276,14 +2284,30 @@ function hasResourceExplorerRemovalContract({ html, app, styles }) {
return (
removedSelectorsAbsent &&
removedTextAbsent &&
/id=["']activity-rail["']/u.test(html) &&
/id=["']left-sidebar-toggle["']/u.test(html) &&
/aria-label=["']折叠左侧导航["']/u.test(html) &&
/title=["']折叠左侧导航["']/u.test(html) &&
/aria-controls=["']activity-rail["']/u.test(html) &&
/aria-expanded=["']true["']/u.test(html) &&
/LAYOUT_STORAGE_KEY\s*=\s*["']hwlab\.workbench\.layout\.v1["']/u.test(app) &&
/leftSidebarCollapsed:\s*payload\.leftSidebarCollapsed === true/u.test(app) &&
/leftSidebarCollapsed:\s*state\.layout\.leftSidebarCollapsed/u.test(app) &&
/function\s+initLeftSidebarToggle\s*\(/u.test(app) &&
/function\s+setLeftSidebarCollapsed\s*\(/u.test(app) &&
/is-left-sidebar-collapsed/u.test(app) &&
/展开左侧导航/u.test(app) &&
/折叠左侧导航/u.test(app) &&
/setPointerCapture/u.test(app) &&
/localStorage\?\.setItem\(\s*LAYOUT_STORAGE_KEY/u.test(app) &&
/event\.key === "Home"/u.test(app) &&
/event\.key === "End"/u.test(app) &&
/ArrowLeft/u.test(app) &&
/ArrowRight/u.test(app) &&
/--rail-collapsed-width:\s*44px/u.test(styles) &&
/\.workbench-shell\s*\{[^}]*grid-template-columns:\s*var\(--rail-width\)\s+minmax\(420px,\s*1fr\)\s+var\(--right-width-expanded\);/su.test(styles) &&
/\.workbench-shell\.is-left-sidebar-collapsed\s*\{[^}]*--rail-width:\s*var\(--rail-collapsed-width\);/su.test(styles) &&
/\.workbench-shell\.is-left-sidebar-collapsed \.activity-rail \.rail-button:not\(\.sidebar-toggle\),\s*\n\.workbench-shell\.is-left-sidebar-collapsed \.rail-spacer\s*\{[^}]*display:\s*none;/su.test(styles) &&
/@media\s*\(max-width:\s*1240px\)[\s\S]*?\.workbench-shell\s*\{[\s\S]*?grid-template-columns:\s*var\(--rail-width\)\s+minmax\(0,\s*1fr\);/u.test(styles) &&
/id=["']hardware-sidebar["']/u.test(html) &&
/id=["']right-sidebar-resize["']/u.test(html) &&
@@ -6233,11 +6257,12 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
compareTo: null
});
artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, defaultLayout.mode));
const leftCollapse = await inspectLeftSidebarCollapse(page, { viewport, artifactRoot: options.artifactRoot });
const rightResize = viewport.width > 1240
? await inspectRightSidebarResizeDesktop(page, { viewport })
: await inspectRightSidebarResizeDisabled(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { default: defaultLayout, rightResize, gate, artifacts };
return { default: defaultLayout, leftCollapse, rightResize, gate, artifacts };
} finally {
await page.close();
}
@@ -6361,6 +6386,250 @@ async function dragRightSidebarResizeHandle(page, targetWidth) {
await page.mouse.up();
}
async function inspectLeftSidebarCollapse(page, { viewport, artifactRoot }) {
const before = await leftSidebarCollapseMetrics(page);
const beforeScreenshot = await captureElementScreenshot(
page,
"[data-app-shell]",
artifactRoot,
viewport,
"left-sidebar-expanded-before"
);
await page.locator("#left-sidebar-toggle").click();
await page.waitForTimeout(100);
const collapsed = await leftSidebarCollapseMetrics(page);
const collapsedScreenshot = await captureElementScreenshot(
page,
"[data-app-shell]",
artifactRoot,
viewport,
"left-sidebar-collapsed"
);
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 });
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const restoredCollapsed = await leftSidebarCollapseMetrics(page);
await page.locator("#left-sidebar-toggle").click();
await page.waitForTimeout(100);
const expanded = await leftSidebarCollapseMetrics(page);
const expandedScreenshot = await captureElementScreenshot(
page,
"[data-app-shell]",
artifactRoot,
viewport,
"left-sidebar-expanded-after"
);
const widthReclaimed = collapsed.centerWidth >= before.centerWidth + Math.min(12, Math.max(0, before.railWidth - collapsed.railWidth - 2));
const pass =
before.toggle.ariaExpanded === "true" &&
before.toggle.ariaLabel === "折叠左侧导航" &&
before.toggle.title === "折叠左侧导航" &&
before.routeButtonsVisible &&
collapsed.toggle.ariaExpanded === "false" &&
collapsed.toggle.ariaLabel === "展开左侧导航" &&
collapsed.toggle.title === "展开左侧导航" &&
collapsed.railWidth < before.railWidth - 12 &&
widthReclaimed &&
collapsed.routeButtonsVisible === false &&
collapsed.keyTargetsReachable &&
collapsed.resourceExplorerRemoved &&
collapsed.rootScrollLocked &&
collapsed.noHorizontalOverflow &&
restoredCollapsed.collapsed === true &&
restoredCollapsed.storage?.leftSidebarCollapsed === true &&
restoredCollapsed.keyTargetsReachable &&
expanded.toggle.ariaExpanded === "true" &&
expanded.toggle.ariaLabel === "折叠左侧导航" &&
expanded.railWidth >= before.railWidth - 2 &&
Math.abs(expanded.centerWidth - before.centerWidth) <= 3 &&
expanded.routeButtonsVisible &&
expanded.storage?.leftSidebarCollapsed === false &&
expanded.keyTargetsReachable &&
expanded.resourceExplorerRemoved &&
expanded.rootScrollLocked &&
expanded.noHorizontalOverflow;
return {
viewport,
pass,
widthReclaimed,
before,
collapsed,
restoredCollapsed,
expanded,
artifacts: {
screenshots: [beforeScreenshot, collapsedScreenshot, expandedScreenshot].filter(Boolean)
},
failures: pass ? [] : [
{
selector: "#left-sidebar-toggle",
failureType: "left-sidebar-collapse-regression",
viewport,
summary: "#278 left sidebar collapse/expand did not reclaim space, persist state, or preserve key workbench controls."
}
]
};
}
async function leftSidebarCollapseMetrics(page) {
return page.evaluate(async () => {
const shell = document.querySelector("[data-app-shell]");
const rail = document.querySelector("#activity-rail");
const center = document.querySelector(".center-workspace");
const right = document.querySelector("#hardware-sidebar");
const toggle = document.querySelector("#left-sidebar-toggle");
const routeButtons = [...document.querySelectorAll(".activity-rail [data-route]")];
const ownsHit = (element, hit) => hit === element || element.contains(hit);
const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
const nearestScrollableAncestor = (element) => {
let current = element.parentElement;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY);
if (scrollable) return current;
current = current.parentElement;
}
return null;
};
const scrollIntoNearestContainer = async (element) => {
const container = nearestScrollableAncestor(element);
if (!container) {
element.scrollIntoView({ block: "center", inline: "nearest" });
await new Promise((resolve) => requestAnimationFrame(resolve));
return null;
}
const elementBox = element.getBoundingClientRect();
const containerBox = container.getBoundingClientRect();
const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2));
container.scrollTop += elementBox.top - containerBox.top - inset;
await new Promise((resolve) => requestAnimationFrame(resolve));
return container;
};
const clipBoxForElement = (element) => {
let visible = {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight
};
let current = element.parentElement;
while (current && current !== document.documentElement) {
const style = getComputedStyle(current);
if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) {
const box = current.getBoundingClientRect();
visible = {
left: Math.max(visible.left, box.left),
top: Math.max(visible.top, box.top),
right: Math.min(visible.right, box.right),
bottom: Math.min(visible.bottom, box.bottom)
};
}
current = current.parentElement;
}
return visible;
};
const inspectTarget = async (selector) => {
const element = document.querySelector(selector);
if (!element) return { selector, ok: false, missing: true };
const scrollContainer = await scrollIntoNearestContainer(element);
const box = element.getBoundingClientRect();
const clipBox = clipBoxForElement(element);
const visibleBox = {
left: Math.max(box.left, clipBox.left),
top: Math.max(box.top, clipBox.top),
right: Math.min(box.right, clipBox.right),
bottom: Math.min(box.bottom, clipBox.bottom)
};
const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left);
const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top);
const x = visibleBox.left + visibleWidth / 2;
const y = visibleBox.top + visibleHeight / 2;
const stack = document.elementsFromPoint(x, y);
const style = getComputedStyle(element);
return {
selector,
ok:
style.display !== "none" &&
style.visibility !== "hidden" &&
box.width > 0 &&
box.height > 0 &&
visibleWidth > 0 &&
visibleHeight > 0 &&
x >= 0 &&
x <= window.innerWidth &&
y >= 0 &&
y <= window.innerHeight &&
stack.some((candidate) => ownsHit(element, candidate)),
hit: hitLabel(stack[0] ?? null),
visibleBox,
scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null
};
};
const keyTargets = [];
for (const selector of [
"#left-sidebar-toggle",
"#command-input",
"#command-send",
"#m3-write-do",
"#m3-read-di",
"#tab-control",
"#tab-wiring",
"#tab-records",
"[data-hardware-tab='overview']",
"[data-hardware-tab='gateways']",
"[data-hardware-tab='box1']",
"[data-hardware-tab='box2']",
"[data-hardware-tab='patch']"
]) {
keyTargets.push(await inspectTarget(selector));
}
let storage = null;
try {
storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null");
} catch {
storage = "parse_failed";
}
const railBox = rail?.getBoundingClientRect();
const centerBox = center?.getBoundingClientRect();
const rightBox = right?.getBoundingClientRect();
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
collapsed: shell?.classList.contains("is-left-sidebar-collapsed") === true,
storage,
shellColumns: shell ? getComputedStyle(shell).gridTemplateColumns : "",
railWidth: railBox?.width ?? 0,
centerWidth: centerBox?.width ?? 0,
rightWidth: rightBox?.width ?? 0,
routeButtonsVisible: routeButtons.some((button) => getComputedStyle(button).display !== "none" && button.getBoundingClientRect().width > 0 && button.getBoundingClientRect().height > 0),
toggle: {
text: toggle?.textContent?.trim() ?? "",
ariaExpanded: toggle?.getAttribute("aria-expanded") ?? "",
ariaLabel: toggle?.getAttribute("aria-label") ?? "",
title: toggle?.getAttribute("title") ?? "",
ariaControls: toggle?.getAttribute("aria-controls") ?? ""
},
keyTargets,
keyTargetsReachable: keyTargets.every((target) => target.ok),
resourceExplorerRemoved:
document.querySelector("#resource-explorer") === null &&
document.querySelector("#explorer-resize") === null &&
document.querySelector("#resource-tree") === null &&
!document.body.textContent.includes("资源浏览与硬件资源树") &&
!document.body.textContent.includes("只读工作区") &&
!document.body.textContent.includes("常用能力"),
noHorizontalOverflow:
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2 &&
(!shell || shell.scrollWidth <= shell.clientWidth + 2) &&
(!right || right.scrollWidth <= right.clientWidth + 2),
rootScrollLocked:
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2
};
});
}
async function rightSidebarResizeMetrics(page) {
return page.evaluate(async () => {
const shell = document.querySelector("[data-app-shell]");
@@ -7482,6 +7751,76 @@ function summarizeResourceExplorerRemovalCoverage(viewportResults) {
};
}
function summarizeLeftSidebarCollapseCoverage(viewportResults) {
const coverageEntries = Object.entries(viewportResults).map(([id, result]) => {
const observation = result.leftCollapse;
return [
id,
{
viewport: observation?.viewport ?? null,
covered: observation?.pass === true,
widthReclaimed: observation?.widthReclaimed === true,
before: observation?.before
? {
railWidth: observation.before.railWidth,
centerWidth: observation.before.centerWidth,
toggle: observation.before.toggle,
routeButtonsVisible: observation.before.routeButtonsVisible
}
: null,
collapsed: observation?.collapsed
? {
railWidth: observation.collapsed.railWidth,
centerWidth: observation.collapsed.centerWidth,
toggle: observation.collapsed.toggle,
routeButtonsVisible: observation.collapsed.routeButtonsVisible,
keyTargetsReachable: observation.collapsed.keyTargetsReachable,
resourceExplorerRemoved: observation.collapsed.resourceExplorerRemoved,
rootScrollLocked: observation.collapsed.rootScrollLocked,
noHorizontalOverflow: observation.collapsed.noHorizontalOverflow
}
: null,
restoredCollapsed: observation?.restoredCollapsed
? {
collapsed: observation.restoredCollapsed.collapsed,
storage: observation.restoredCollapsed.storage,
keyTargetsReachable: observation.restoredCollapsed.keyTargetsReachable
}
: null,
expanded: observation?.expanded
? {
railWidth: observation.expanded.railWidth,
centerWidth: observation.expanded.centerWidth,
toggle: observation.expanded.toggle,
routeButtonsVisible: observation.expanded.routeButtonsVisible,
storage: observation.expanded.storage,
keyTargetsReachable: observation.expanded.keyTargetsReachable,
resourceExplorerRemoved: observation.expanded.resourceExplorerRemoved,
rootScrollLocked: observation.expanded.rootScrollLocked,
noHorizontalOverflow: observation.expanded.noHorizontalOverflow
}
: null
}
];
});
const coverage = Object.fromEntries(coverageEntries);
const failures = coverageEntries
.filter(([, item]) => !item.covered)
.map(([id, item]) => ({
checkId: "layout-left-sidebar-collapse",
viewport: item.viewport,
selector: "#left-sidebar-toggle",
failureType: "left-sidebar-collapse-regression",
summary: `#278 left activity rail collapse guard failed in ${id} viewport.`,
observation: item
}));
return {
status: failures.length === 0 ? "pass" : "blocked",
coverage,
failures
};
}
function summarizeCoverageGap(viewportResults, issueKey) {
const fieldName = {
issue288: "issue288SingleTableReady",