feat: add workbench left sidebar collapse
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user