Merge pull request #385 from pikasTech/feat/278-sidebar-collapse

feat: add 16666 workbench left sidebar collapse
This commit is contained in:
Lyon
2026-05-24 02:49:01 +08:00
committed by GitHub
7 changed files with 462 additions and 14 deletions
+12 -1
View File
@@ -727,6 +727,7 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
"layout-narrow-desktop-default",
"layout-mobile-default",
"layout-feedback-352-resource-explorer-removed",
"layout-left-sidebar-collapse",
"layout-gate-desktop",
"layout-gate-narrow-desktop",
"layout-gate-mobile"
@@ -791,9 +792,19 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(mobileDefault.wiring.metadataInDetails, true);
assert.equal(mobileDefault.noHorizontalOverflow.right, true);
const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations;
assert.equal(leftCollapse.status, "pass");
assert.equal(leftCollapse.coverage.desktop.widthReclaimed, true);
assert.equal(leftCollapse.coverage["narrow-desktop"].widthReclaimed, true);
assert.equal(leftCollapse.coverage.mobile.widthReclaimed, true);
assert.equal(leftCollapse.coverage.desktop.collapsed.keyTargetsReachable, true);
assert.equal(leftCollapse.coverage.mobile.collapsed.keyTargetsReachable, true);
assert.equal(leftCollapse.coverage.desktop.restoredCollapsed.collapsed, true);
assert.equal(leftCollapse.coverage.mobile.expanded.routeButtonsVisible, true);
const compact = compactLayoutSmokeCliOutput(report);
assert.equal(compact.status, "pass");
assert.equal(compact.artifacts.screenshotCount, 12);
assert.equal(compact.artifacts.screenshotCount, 21);
assert.equal(compact.failures.length, 0);
assert.equal(compact.blockers.length, 0);
assert.equal(JSON.stringify(compact).includes('"checks"'), false);
+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",
+8 -8
View File
@@ -2225,14 +2225,14 @@ async function validateDevCloudWorkbenchLayoutReport(report, label) {
if (report.status !== "skip") {
for (const requiredCheck of [
"layout-desktop-expanded",
"layout-desktop-collapsed",
"layout-desktop-restored",
"layout-narrow-desktop-expanded",
"layout-narrow-desktop-collapsed",
"layout-narrow-desktop-restored",
"layout-mobile-collapsed",
"layout-mobile-drawer",
"layout-desktop-default",
"layout-narrow-desktop-default",
"layout-mobile-default",
"layout-feedback-352-resource-explorer-removed",
"layout-left-sidebar-collapse",
"layout-right-sidebar-resize-desktop",
"layout-right-sidebar-resize-narrow-desktop",
"layout-right-sidebar-resize-mobile",
"layout-gate-desktop",
"layout-gate-narrow-desktop",
"layout-gate-mobile"
+25
View File
@@ -95,6 +95,7 @@ const el = {
loginShell: byId("login-shell"),
shell: query("[data-app-shell]"),
logoutButton: byId("logout-button"),
leftSidebarToggle: byId("left-sidebar-toggle"),
rightSidebarResize: byId("right-sidebar-resize"),
routePath: byId("route-path"),
liveStatus: byId("live-status"),
@@ -164,6 +165,7 @@ const state = {
pending: false
},
layout: {
leftSidebarCollapsed: false,
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
rightDrag: null
}
@@ -171,6 +173,7 @@ const state = {
initRoutes();
initLayoutSizing();
initLeftSidebarToggle();
initRightSidebarResize();
initSideTabs();
initHardwareTabs();
@@ -221,6 +224,7 @@ function initRoutes() {
function initLayoutSizing() {
const persisted = readPersistedLayout();
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false });
setRightSidebarWidth(rightSidebarWidth, { persist: false });
}
@@ -231,6 +235,7 @@ function readPersistedLayout() {
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
return {
leftSidebarCollapsed: payload.leftSidebarCollapsed === true,
rightSidebarWidth: Number(payload.rightSidebarWidth)
};
} catch {
@@ -244,6 +249,7 @@ function persistLayout() {
LAYOUT_STORAGE_KEY,
JSON.stringify({
version: 1,
leftSidebarCollapsed: state.layout.leftSidebarCollapsed,
rightSidebarWidth: state.layout.rightSidebarWidth
})
);
@@ -252,6 +258,25 @@ function persistLayout() {
}
}
function initLeftSidebarToggle() {
el.leftSidebarToggle.addEventListener("click", () => {
setLeftSidebarCollapsed(!state.layout.leftSidebarCollapsed);
});
}
function setLeftSidebarCollapsed(collapsed, options = {}) {
state.layout.leftSidebarCollapsed = collapsed === true;
el.shell.classList.toggle("is-left-sidebar-collapsed", state.layout.leftSidebarCollapsed);
el.leftSidebarToggle.setAttribute("aria-expanded", state.layout.leftSidebarCollapsed ? "false" : "true");
el.leftSidebarToggle.setAttribute(
"aria-label",
state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"
);
el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航";
el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起";
if (options.persist !== false) persistLayout();
}
function setRightSidebarWidth(width, options = {}) {
const clamped = clampRightSidebarWidth(width);
state.layout.rightSidebarWidth = clamped;
+10 -1
View File
@@ -31,11 +31,20 @@
</section>
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md" hidden aria-hidden="true">
<aside class="activity-rail" aria-label="工作台活动栏">
<aside class="activity-rail" id="activity-rail" aria-label="工作台活动栏">
<button class="rail-button active" type="button" data-route="workspace" title="工作台" aria-label="工作台">工作台</button>
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
<button class="rail-button" type="button" data-route="help" title="使用说明" aria-label="使用说明">使用说明</button>
<span class="rail-spacer" aria-hidden="true"></span>
<button
class="rail-button sidebar-toggle"
id="left-sidebar-toggle"
type="button"
aria-controls="activity-rail"
aria-expanded="true"
aria-label="折叠左侧导航"
title="折叠左侧导航"
>收起</button>
</aside>
<section class="center-workspace">
+24
View File
@@ -269,6 +269,11 @@ assert.equal(
"pass",
"#352 resource explorer removal must remain covered by desktop and 390x844 layout smoke"
);
assert.equal(
layoutSmoke.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.status,
"pass",
"#278 left activity rail collapse/expand must remain covered by desktop and 390x844 layout smoke"
);
assert.equal(
layoutSmoke.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status,
"skip",
@@ -279,6 +284,7 @@ for (const layoutCheckId of [
"layout-narrow-desktop-default",
"layout-mobile-default",
"layout-feedback-352-resource-explorer-removed",
"layout-left-sidebar-collapse",
"layout-right-sidebar-resize-desktop",
"layout-right-sidebar-resize-narrow-desktop",
"layout-right-sidebar-resize-mobile",
@@ -410,6 +416,7 @@ assert.match(html, /href="\/styles\.css"/);
assert.match(html, /src="\/app\.mjs"/);
for (const workbenchElement of [
"activity-rail",
"left-sidebar-toggle",
"conversation-list",
"agent-chat-status",
"code-agent-summary",
@@ -459,6 +466,12 @@ for (const removedUiText of [
assert.doesNotMatch(`${html}\n${app}\n${styles}`, new RegExp(escapeRegExp(removedUiText)), `removed resource explorer copy returned: ${removedUiText}`);
}
assert.match(html, /id="hardware-sidebar"/);
assert.match(html, /id="activity-rail"/);
assert.match(html, /id="left-sidebar-toggle"/);
assert.match(html, /aria-label="折叠左侧导航"/);
assert.match(html, /title="折叠左侧导航"/);
assert.match(html, /aria-controls="activity-rail"/);
assert.match(html, /aria-expanded="true"/);
assert.match(html, /id="right-sidebar-resize"/);
assert.match(html, /aria-label="拖拽调整右侧硬件状态栏宽度"/);
assert.match(html, /aria-valuemin="560"/);
@@ -466,6 +479,13 @@ assert.match(html, /aria-valuemax="760"/);
assert.match(html, /aria-valuenow="620"/);
assert.doesNotMatch(app, /setExplorerCollapsed|initExplorerResize|clampExplorerWidth|syncMobileExplorer|collapseExplorerAfterMobileAction/);
assert.match(app, /LAYOUT_STORAGE_KEY\s*=\s*"hwlab\.workbench\.layout\.v1"/);
assert.match(app, /leftSidebarCollapsed:\s*payload\.leftSidebarCollapsed === true/);
assert.match(app, /leftSidebarCollapsed:\s*state\.layout\.leftSidebarCollapsed/);
assert.match(app, /function initLeftSidebarToggle/);
assert.match(app, /function setLeftSidebarCollapsed/);
assert.match(app, /is-left-sidebar-collapsed/);
assert.match(app, /展开左侧导航/);
assert.match(app, /折叠左侧导航/);
assert.match(app, /function initRightSidebarResize/);
assert.match(app, /function clampRightSidebarWidth/);
assert.match(app, /function rightSidebarResizeBounds/);
@@ -482,6 +502,10 @@ assert.match(styles, /--right-sidebar-min-width:\s*560px/);
assert.match(styles, /--right-sidebar-max-width:\s*760px/);
assert.match(styles, /--right-width:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/);
assert.match(styles, /--right-width-expanded:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/);
assert.match(styles, /--rail-collapsed-width:\s*44px/);
assert.match(styles, /\.workbench-shell\.is-left-sidebar-collapsed\s*{[^}]*--rail-width:\s*var\(--rail-collapsed-width\);/s);
assert.match(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;/s);
assert.match(styles, /\.sidebar-toggle\[aria-expanded="false"\]\s*{/);
assert.match(styles, /\.right-sidebar-resize\s*{[^}]*cursor:\s*col-resize;/s);
assert.match(styles, /@media \(max-width: 1240px\)[\s\S]*?\.right-sidebar-resize\s*{[\s\S]*?display:\s*none;/);
for (const viewId of ["workspace", "gate"]) {
+42 -2
View File
@@ -23,6 +23,7 @@
--right-sidebar-max-width: 760px;
--right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--rail-collapsed-width: 44px;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--body: "Aptos", "Segoe UI", system-ui, sans-serif;
}
@@ -236,7 +237,7 @@ body > [data-app-shell][hidden] {
.activity-rail {
display: grid;
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(0, 1fr);
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(0, 1fr) auto;
gap: 6px;
padding: 8px;
background: var(--rail);
@@ -278,6 +279,41 @@ body > [data-app-shell][hidden] {
box-shadow: inset 3px 0 0 var(--accent);
}
.sidebar-toggle {
min-height: 34px;
border-color: var(--line);
background: var(--surface);
color: var(--text);
}
.sidebar-toggle[aria-expanded="false"] {
border-color: var(--accent);
background: var(--surface-hot);
}
.workbench-shell.is-left-sidebar-collapsed {
--rail-width: var(--rail-collapsed-width);
}
.workbench-shell.is-left-sidebar-collapsed .activity-rail {
grid-template-rows: minmax(0, 1fr) auto;
padding: 7px 5px;
overflow: hidden;
}
.workbench-shell.is-left-sidebar-collapsed .activity-rail .rail-button:not(.sidebar-toggle),
.workbench-shell.is-left-sidebar-collapsed .rail-spacer {
display: none;
}
.workbench-shell.is-left-sidebar-collapsed .sidebar-toggle {
min-width: 0;
padding: 4px 2px;
font-size: 11px;
line-height: 1.1;
overflow-wrap: anywhere;
}
.workbench-shell.is-resizing {
cursor: col-resize;
user-select: none;
@@ -1847,7 +1883,7 @@ tbody tr:last-child td {
.activity-rail {
grid-column: 1;
grid-row: 1 / 3;
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(12px, 1fr);
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(12px, 1fr) auto;
overflow: hidden;
}
@@ -1856,6 +1892,10 @@ tbody tr:last-child td {
line-height: 1.15;
}
.sidebar-toggle {
min-height: 36px;
}
.right-sidebar-resize {
display: none;
}