feat: add controlled workbench sidebar resize

Merge PR #326: controlled left workbench resize with layout smoke coverage.
This commit is contained in:
Lyon
2026-05-23 18:26:23 +08:00
committed by GitHub
7 changed files with 638 additions and 3 deletions
+19
View File
@@ -40,6 +40,17 @@ The default workbench follows the #99 VS Code-style structure:
Unavailable or future controls must be disabled with blocker context. The
frontend must not imply generic direct hardware control.
The left resource explorer is a controlled resizable column on desktop-class
viewports. Its width defaults to `292px`, clamps inside a bounded range
(`220px` to `420px` before viewport-specific clamping), and is changed through
the `#explorer-resize` separator. The separator has a Chinese accessible name,
supports pointer drag plus Arrow/Page/Home/End keyboard adjustment, and stores
only the clamped width in versioned `localStorage` key
`hwlab.workbench.layout.v1`. Collapsing the resource tree hides the column but
keeps the saved width so expanding restores it. At `<=860px`, the resize handle
is removed from the focus/hit-target flow and the mobile drawer/collapse flow
owns the layout.
The `panel-wiring` / `hwlab-patch-panel` view must use a two-column long table
instead of an eight-column wide table. The first table row/header names the two
connected devices, for example `res_boxsimu_1` and `res_boxsimu_2`; following
@@ -121,6 +132,12 @@ runner 入口。它使用 Playwright 覆盖 `1366x768`、`1024x768` 和 `390x844
点击与 `document.elementsFromPoint()`;不得使用 `force: true` 或纯 DOM
dispatch 绕过用户命中。
Layout smoke also covers #278 resize behavior. Desktop and narrow desktop runs
drag `#explorer-resize`, exercise Arrow/Home/End keyboard changes, assert
min/max/clamp and versioned storage restore, then collapse and expand the
resource tree to prove width state is retained. The mobile run asserts the
handle is not displayed or focusable and that no horizontal overflow appears.
常用命令:
```sh
@@ -171,6 +188,8 @@ blockers/failures`viewport`、`selector`、`failureType`、summary 和 artifa
Playwright layout and overlap guardrail.
- [pikasTech/HWLAB#276](https://github.com/pikasTech/HWLAB/issues/276):
patch-panel wiring panel two-column long-table feedback.
- [pikasTech/HWLAB#278](https://github.com/pikasTech/HWLAB/issues/278):
resource explorer collapse and controlled resize feedback.
- [code-agent-chat-readiness.md](code-agent-chat-readiness.md): Code Agent
chat 同源 readiness 和 provider credential blocker 边界。
- [docs/cloud-web-workbench.md](../cloud-web-workbench.md): detailed frontend
@@ -517,6 +517,9 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
"layout-narrow-desktop-restored",
"layout-mobile-collapsed",
"layout-mobile-drawer",
"layout-explorer-resize-desktop",
"layout-explorer-resize-narrow-desktop",
"layout-explorer-resize-mobile",
"layout-gate-desktop",
"layout-gate-narrow-desktop",
"layout-gate-mobile"
@@ -549,6 +552,7 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
assert.equal(desktopCollapsed.wiring.headers.length, 2);
assert.deepEqual(desktopCollapsed.wiring.legacyHeaderHits, []);
assert.equal(desktopCollapsed.wiring.horizontalScroll.panelScrollWidth <= desktopCollapsed.wiring.horizontalScroll.panelClientWidth + 2, true);
assert.equal(desktopCollapsed.resizeHandle.ok, false);
const mobileCollapsed = report.checks.find((check) => check.id === "layout-mobile-collapsed")?.observations;
assert.equal(mobileCollapsed.explorerCollapsed, true);
@@ -556,6 +560,7 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
assert.equal(mobileCollapsed.keyTargetsReachable, true);
assert.equal(mobileCollapsed.wiring.longTableOk, true);
assert.equal(mobileCollapsed.wiring.noHorizontalScroll, true);
assert.equal(mobileCollapsed.resizeHandle.ok, false);
const desktopExpanded = report.checks.find((check) => check.id === "layout-desktop-expanded")?.observations;
assert.equal(Object.hasOwn(desktopExpanded.boxes.shell, "text"), false);
@@ -563,6 +568,29 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
assert.equal(desktopExpanded.failures.length, 0);
assert.equal(desktopExpanded.semanticOverlapChecks.every((check) => !check.overlaps), true);
assert.equal(desktopExpanded.overflowChecks.every((check) => check.ok), true);
assert.equal(desktopExpanded.resizeHandle.ok, true);
const desktopResize = report.checks.find((check) => check.id === "layout-explorer-resize-desktop")?.observations;
assert.equal(desktopResize.pass, true);
assert.equal(desktopResize.before.handle.ariaLabel, "拖拽调整左侧资源树宽度");
assert.equal(desktopResize.before.handle.role, "separator");
assert.equal(Math.abs(desktopResize.afterDrag.explorerWidth - desktopResize.before.explorerWidth) >= 24, true);
assert.equal(desktopResize.afterEnd.explorerWidth, desktopResize.afterEnd.bounds.max);
assert.equal(desktopResize.afterHome.explorerWidth, desktopResize.afterHome.bounds.min);
assert.equal(desktopResize.collapsed.explorerCollapsed, true);
assert.equal(desktopResize.collapsed.handleTabIndex, -1);
assert.equal(desktopResize.restored.storage.version, 1);
assert.equal(desktopResize.restoredFromStorage.keyTargetsReachable, true);
const narrowResize = report.checks.find((check) => check.id === "layout-explorer-resize-narrow-desktop")?.observations;
assert.equal(narrowResize.pass, true);
assert.equal(narrowResize.afterEnd.explorerWidth <= narrowResize.afterEnd.bounds.max, true);
assert.equal(narrowResize.restoredFromStorage.noHorizontalOverflow, true);
const mobileResize = report.checks.find((check) => check.id === "layout-explorer-resize-mobile")?.observations;
assert.equal(mobileResize.pass, true);
assert.equal(mobileResize.handleDisplay, "none");
assert.equal(mobileResize.handleTabIndex, -1);
const compact = compactLayoutSmokeCliOutput(report);
assert.equal(compact.status, "pass");
+333 -2
View File
@@ -1019,6 +1019,27 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.",
observations: mobile.drawer
},
{
id: "layout-explorer-resize-desktop",
status: desktop.resize?.pass ? "pass" : "blocked",
viewport: desktop.resize?.viewport ?? desktop.expanded.viewport,
summary: "Desktop left resource tree resize supports pointer drag, keyboard bounds, clamping, localStorage v1 restore, and collapse/expand width preservation.",
observations: desktop.resize
},
{
id: "layout-explorer-resize-narrow-desktop",
status: narrowDesktop.resize?.pass ? "pass" : "blocked",
viewport: narrowDesktop.resize?.viewport ?? narrowDesktop.expanded.viewport,
summary: "Narrow desktop left resource tree resize clamps against the stacked layout so M3 controls and Code Agent input remain reachable.",
observations: narrowDesktop.resize
},
{
id: "layout-explorer-resize-mobile",
status: mobile.resize?.pass ? "pass" : "blocked",
viewport: mobile.resize?.viewport ?? mobile.collapsed.viewport,
summary: "Mobile layout disables the drag handle as a hit target and keeps the drawer/collapse flow reachable without horizontal overflow.",
observations: mobile.resize
},
{
id: "layout-gate-desktop",
status: desktop.gate.currentRoute.pass ? "pass" : "blocked",
@@ -2086,12 +2107,32 @@ function hasSidebarCollapseContract({ html, app, styles }) {
/aria-label=["']收起左侧资源树["']/u.test(html) &&
/title=["']收起左侧资源树,给工作区更多宽度["']/u.test(html) &&
/>收起资源树<\/button>/u.test(html) &&
/id=["']explorer-resize["']/u.test(html) &&
/role=["']separator["']/u.test(html) &&
/aria-label=["']拖拽调整左侧资源树宽度["']/u.test(html) &&
/aria-valuemin=["']220["']/u.test(html) &&
/aria-valuemax=["']420["']/u.test(html) &&
/aria-valuenow=["']292["']/u.test(html) &&
/LAYOUT_STORAGE_KEY\s*=\s*["']hwlab\.workbench\.layout\.v1["']/u.test(app) &&
/function\s+initExplorerResize\s*\(/u.test(app) &&
/function\s+clampExplorerWidth\s*\(/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) &&
/setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/u.test(setCollapsedBody) &&
/textContent = collapsed \? "展开资源树" : "收起资源树"/u.test(setCollapsedBody) &&
/title = collapsed \? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度"/u.test(setCollapsedBody) &&
/--explorer-width:\s*292px/u.test(styles) &&
/--explorer-min-width:\s*220px/u.test(styles) &&
/--explorer-max-width:\s*420px/u.test(styles) &&
/--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/u.test(styles) &&
/--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/u.test(styles) &&
/\.explorer-resize\s*\{[^}]*cursor:\s*col-resize;/su.test(styles) &&
/\.explorer-collapsed\s+\.explorer-resize\s*\{[^}]*display:\s*none;/su.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer-resize\s*\{[\s\S]*?display:\s*none;/u.test(styles) &&
/\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) &&
/\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles)
);
@@ -4861,8 +4902,9 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
keyTargetsReachable: recollapsed.keyTargetsReachable
};
drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable;
const resize = await inspectExplorerResizeMobile(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { collapsed, drawer, gate, artifacts };
return { collapsed, drawer, resize, gate, artifacts };
}
const expanded = await inspectLayoutState(page, {
@@ -4886,8 +4928,9 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
viewport,
compareTo: expanded
});
const resize = await inspectExplorerResizeDesktop(page, { viewport });
const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options);
return { expanded, collapsed, restored, gate, artifacts };
return { expanded, collapsed, restored, resize, gate, artifacts };
} finally {
await page.close();
}
@@ -4913,6 +4956,292 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {})
}
}
async function inspectExplorerResizeDesktop(page, { viewport }) {
const before = await page.evaluate(() => {
const shell = document.querySelector("[data-app-shell]");
const explorer = document.querySelector("#resource-explorer");
const center = document.querySelector(".center-workspace");
const right = document.querySelector(".right-sidebar");
const handle = document.querySelector("#explorer-resize");
return {
explorerWidth: explorer?.getBoundingClientRect().width ?? 0,
centerWidth: center?.getBoundingClientRect().width ?? 0,
rightWidth: right?.getBoundingClientRect().width ?? 0,
cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--explorer-width")),
storage: localStorage.getItem("hwlab.workbench.layout.v1"),
handle: {
ariaLabel: handle?.getAttribute("aria-label") ?? "",
title: handle?.getAttribute("title") ?? "",
role: handle?.getAttribute("role") ?? "",
tabIndex: handle?.tabIndex ?? null,
valueMin: handle?.getAttribute("aria-valuemin") ?? "",
valueMax: handle?.getAttribute("aria-valuemax") ?? "",
valueNow: handle?.getAttribute("aria-valuenow") ?? "",
valueText: handle?.getAttribute("aria-valuetext") ?? ""
}
};
});
const min = Number(before.handle.valueMin);
const max = Number(before.handle.valueMax);
const growTarget = Math.min(max, before.explorerWidth + 72);
const shrinkTarget = Math.max(min, before.explorerWidth - 48);
const dragTarget = growTarget - before.explorerWidth >= 32 ? growTarget : shrinkTarget;
await dragExplorerResizeHandle(page, dragTarget);
const afterDrag = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("ArrowLeft");
const afterArrowLeft = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("End");
const afterEnd = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("Home");
const afterHome = await explorerResizeMetrics(page);
await page.locator("#explorer-resize").press("ArrowRight");
const afterArrowRight = await explorerResizeMetrics(page);
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const collapsed = await explorerResizeMetrics(page);
await page.locator("#explorer-toggle").click();
await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 });
const restored = await explorerResizeMetrics(page);
await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 });
await page.locator("#explorer-toggle").waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const restoredFromStorage = await explorerResizeMetrics(page);
const expectedArrow = Math.min(afterHome.bounds.max, afterHome.bounds.min + 16);
const pass =
before.handle.role === "separator" &&
before.handle.ariaLabel === "拖拽调整左侧资源树宽度" &&
before.handle.title.includes("左右方向键") &&
before.handle.tabIndex === 0 &&
Number.isFinite(min) &&
Number.isFinite(max) &&
max >= min &&
Math.abs(afterDrag.explorerWidth - before.explorerWidth) >= 24 &&
afterDrag.explorerWidth >= min &&
afterDrag.explorerWidth <= max &&
afterArrowLeft.explorerWidth <= afterDrag.explorerWidth - 12 &&
Math.abs(afterEnd.explorerWidth - afterEnd.bounds.max) <= 2 &&
Math.abs(afterHome.explorerWidth - afterHome.bounds.min) <= 2 &&
Math.abs(afterArrowRight.explorerWidth - expectedArrow) <= 2 &&
collapsed.explorerCollapsed === true &&
collapsed.explorerWidth <= 1 &&
collapsed.handleTabIndex === -1 &&
Math.abs(restored.explorerWidth - afterArrowRight.explorerWidth) <= 2 &&
restored.storage?.version === 1 &&
restored.storage?.explorerWidth === afterArrowRight.explorerWidth &&
Math.abs(restoredFromStorage.explorerWidth - afterArrowRight.explorerWidth) <= 2 &&
restoredFromStorage.keyTargetsReachable &&
restoredFromStorage.rootScrollLocked &&
restoredFromStorage.noHorizontalOverflow;
return {
viewport,
pass,
targetWidth: dragTarget,
before,
afterDrag,
afterArrowLeft,
afterEnd,
afterHome,
afterArrowRight,
collapsed,
restored,
restoredFromStorage
};
}
async function dragExplorerResizeHandle(page, targetWidth) {
const start = await page.evaluate(() => {
const explorer = document.querySelector("#resource-explorer");
const handle = document.querySelector("#explorer-resize");
const explorerBox = explorer.getBoundingClientRect();
const handleBox = handle.getBoundingClientRect();
return {
startX: handleBox.left + handleBox.width / 2,
startY: handleBox.top + Math.min(80, handleBox.height / 2),
currentWidth: explorerBox.width
};
});
const targetX = start.startX + targetWidth - start.currentWidth;
await page.mouse.move(start.startX, start.startY);
await page.mouse.down();
await page.mouse.move(targetX, start.startY, { steps: 8 });
await page.mouse.up();
}
async function explorerResizeMetrics(page) {
return page.evaluate(async () => {
const shell = document.querySelector("[data-app-shell]");
const explorer = document.querySelector("#resource-explorer");
const center = document.querySelector(".center-workspace");
const right = document.querySelector(".right-sidebar");
const handle = document.querySelector("#explorer-resize");
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 targetSelectors = [
"#command-input",
"#command-send",
"#m3-write-do",
"#m3-read-di",
"[data-hardware-tab='overview']",
"[data-hardware-tab='box1']",
"[data-hardware-tab='box2']",
"[data-hardware-tab='patch']"
];
const keyTargets = [];
for (const selector of targetSelectors) {
const element = document.querySelector(selector);
if (!element) {
keyTargets.push({ selector, ok: false, missing: true });
continue;
}
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);
keyTargets.push({
selector,
ok:
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),
center: { x, y },
visibleBox,
scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null
});
}
let storage = null;
try {
storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null");
} catch {
storage = "parse_failed";
}
return {
explorerCollapsed: shell?.classList.contains("explorer-collapsed") === true,
explorerWidth: explorer?.getBoundingClientRect().width ?? 0,
centerWidth: center?.getBoundingClientRect().width ?? 0,
rightWidth: right?.getBoundingClientRect().width ?? 0,
cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--explorer-width")),
bounds: {
min: Number(handle?.getAttribute("aria-valuemin")),
max: Number(handle?.getAttribute("aria-valuemax"))
},
handleTabIndex: handle?.tabIndex ?? null,
handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "",
handleAriaValueNow: Number(handle?.getAttribute("aria-valuenow")),
handleAriaValueText: handle?.getAttribute("aria-valuetext") ?? "",
storage,
keyTargets,
keyTargetsReachable: keyTargets.every((target) => target.ok),
noHorizontalOverflow:
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.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 inspectExplorerResizeMobile(page, { viewport }) {
const observation = await page.evaluate(() => {
const handle = document.querySelector("#explorer-resize");
const box = handle?.getBoundingClientRect();
const style = handle ? getComputedStyle(handle) : null;
const shell = document.querySelector("[data-app-shell]");
const right = document.querySelector(".right-sidebar");
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
explorerCollapsed: shell?.classList.contains("explorer-collapsed") === true,
handlePresent: Boolean(handle),
handleDisplay: style?.display ?? "missing",
handleWidth: box?.width ?? 0,
handleTabIndex: handle?.tabIndex ?? null,
handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "",
noHorizontalOverflow:
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2 &&
(!right || right.scrollWidth <= right.clientWidth + 2)
};
});
return {
viewport,
pass:
observation.explorerCollapsed === true &&
observation.handlePresent &&
observation.handleDisplay === "none" &&
observation.handleTabIndex === -1 &&
observation.handleAriaDisabled === "true" &&
observation.noHorizontalOverflow,
...observation
};
}
async function inspectLayoutState(page, { mode, viewport, compareTo }) {
return page.evaluate(async ({ mode, viewport, compareTo }) => {
const failures = [];
@@ -5173,6 +5502,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
for (const [selector, label] of selectorLabels) {
keyTargets.push(await inspectHitTarget(selector, label));
}
const resizeHandle = await inspectHitTarget("#explorer-resize", "左侧资源树宽度调整手柄");
const drawerTargets = [];
for (const [selector, label] of drawerSelectorLabels) {
drawerTargets.push(await inspectFirstHit(selector, label));
@@ -5554,6 +5884,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) {
keyTargetsReachable,
keyTargetsRequirement,
blockedKeyTargets,
resizeHandle,
drawerReachable,
blockedDrawerTargets,
sidePanelScroll,
+161
View File
@@ -55,6 +55,10 @@ const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined"
const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli", "codex-readonly-runner"]);
const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-readonly-runner"]);
const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1";
const EXPLORER_DEFAULT_WIDTH = 292;
const EXPLORER_MIN_WIDTH = 220;
const EXPLORER_MAX_WIDTH = 420;
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
let rpcSequence = 0;
@@ -62,6 +66,7 @@ let rpcSequence = 0;
const el = {
shell: query("[data-app-shell]"),
explorerToggle: byId("explorer-toggle"),
explorerResize: byId("explorer-resize"),
explorerStatus: byId("explorer-status"),
treeCount: byId("tree-count"),
resourceTree: byId("resource-tree"),
@@ -119,12 +124,18 @@ const state = {
hardwareTab: "overview",
operation: null,
pending: false
},
layout: {
explorerWidth: EXPLORER_DEFAULT_WIDTH,
drag: null
}
};
initRoutes();
initLayoutSizing();
initExplorerToggle();
syncMobileExplorer();
initExplorerResize();
initSideTabs();
initHardwareTabs();
initQuickActions();
@@ -169,6 +180,93 @@ function initRoutes() {
showView(routeFromLocation());
}
function initLayoutSizing() {
const persisted = readPersistedLayout();
const width = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH);
setExplorerWidth(width, { persist: false });
}
function readPersistedLayout() {
try {
const raw = window.localStorage?.getItem(LAYOUT_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
return {
explorerWidth: Number(payload.explorerWidth)
};
} catch {
return null;
}
}
function persistLayout() {
try {
window.localStorage?.setItem(
LAYOUT_STORAGE_KEY,
JSON.stringify({
version: 1,
explorerWidth: state.layout.explorerWidth
})
);
} catch {
// Browser storage can be unavailable; layout still works for the session.
}
}
function setExplorerWidth(width, options = {}) {
const clamped = clampExplorerWidth(width);
state.layout.explorerWidth = clamped;
el.shell.style.setProperty("--explorer-width", `${clamped}px`);
syncExplorerResizeA11y();
if (options.persist !== false) persistLayout();
return clamped;
}
function clampExplorerWidth(value) {
const numeric = Number(value);
const width = Number.isFinite(numeric) ? numeric : EXPLORER_DEFAULT_WIDTH;
const bounds = explorerResizeBounds();
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
}
function explorerResizeBounds() {
const styles = getComputedStyle(el.shell);
const cssMin = readCssPixel(styles.getPropertyValue("--explorer-min-width"), EXPLORER_MIN_WIDTH);
const cssMax = readCssPixel(styles.getPropertyValue("--explorer-max-width"), EXPLORER_MAX_WIDTH);
if (window.matchMedia("(max-width: 860px)").matches) {
return { min: EXPLORER_MIN_WIDTH, max: EXPLORER_MAX_WIDTH };
}
if (window.matchMedia("(max-width: 1240px)").matches) {
const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 86);
const dynamicMax = window.innerWidth - railWidth - 420 - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 92);
const rightWidth = Math.min(Math.max(window.innerWidth * 0.45, 560), 740);
const dynamicMax = window.innerWidth - railWidth - 360 - rightWidth - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
function boundedResizeRange(min, max, dynamicMax) {
const safeMin = Math.max(0, Math.round(min));
const safeMax = Math.max(safeMin, Math.round(Math.min(max, Number.isFinite(dynamicMax) ? dynamicMax : max)));
return { min: safeMin, max: safeMax };
}
function readCssPixel(value, fallback) {
const parsed = Number.parseFloat(String(value ?? "").trim());
return Number.isFinite(parsed) ? parsed : fallback;
}
function syncExplorerResizeA11y() {
const bounds = explorerResizeBounds();
el.explorerResize.setAttribute("aria-valuemin", String(bounds.min));
el.explorerResize.setAttribute("aria-valuemax", String(bounds.max));
el.explorerResize.setAttribute("aria-valuenow", String(state.layout.explorerWidth));
el.explorerResize.setAttribute("aria-valuetext", `左侧资源树宽度 ${state.layout.explorerWidth} 像素`);
}
function routeFromLocation() {
const hashRoute = window.location.hash.replace(/^#\/?/, "");
if (viewIds.has(hashRoute)) return hashRoute;
@@ -199,13 +297,75 @@ function initExplorerToggle() {
});
}
function initExplorerResize() {
window.addEventListener("resize", () => {
if (canResizeExplorer()) setExplorerWidth(state.layout.explorerWidth, { persist: false });
syncExplorerResizeA11y();
});
el.explorerResize.addEventListener("pointerdown", (event) => {
if (!canResizeExplorer()) return;
event.preventDefault();
el.explorerResize.setPointerCapture?.(event.pointerId);
state.layout.drag = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: state.layout.explorerWidth
};
el.shell.classList.add("is-resizing");
});
el.explorerResize.addEventListener("pointermove", (event) => {
const drag = state.layout.drag;
if (!drag || drag.pointerId !== event.pointerId) return;
setExplorerWidth(drag.startWidth + event.clientX - drag.startX, { persist: false });
});
el.explorerResize.addEventListener("pointerup", finishExplorerResize);
el.explorerResize.addEventListener("pointercancel", finishExplorerResize);
el.explorerResize.addEventListener("keydown", (event) => {
if (!canResizeExplorer()) return;
const keySteps = {
ArrowLeft: -16,
ArrowRight: 16,
PageDown: -48,
PageUp: 48
};
if (Object.hasOwn(keySteps, event.key)) {
event.preventDefault();
setExplorerWidth(state.layout.explorerWidth + keySteps[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setExplorerWidth(explorerResizeBounds().min);
} else if (event.key === "End") {
event.preventDefault();
setExplorerWidth(explorerResizeBounds().max);
}
});
}
function finishExplorerResize(event) {
const drag = state.layout.drag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.layout.drag = null;
el.shell.classList.remove("is-resizing");
persistLayout();
}
function canResizeExplorer() {
return !window.matchMedia("(max-width: 860px)").matches && !el.shell.classList.contains("explorer-collapsed");
}
function setExplorerCollapsed(collapsed) {
if (!collapsed) {
setExplorerWidth(state.layout.explorerWidth, { persist: false });
}
el.shell.classList.toggle("explorer-collapsed", collapsed);
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
el.explorerToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
el.explorerToggle.setAttribute("aria-label", collapsed ? "展开左侧资源树" : "收起左侧资源树");
el.explorerToggle.title = collapsed ? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度";
el.explorerToggle.textContent = collapsed ? "展开资源树" : "收起资源树";
const resizeDisabled = collapsed || window.matchMedia("(max-width: 860px)").matches;
el.explorerResize.tabIndex = resizeDisabled ? -1 : 0;
el.explorerResize.setAttribute("aria-disabled", resizeDisabled ? "true" : "false");
}
function syncMobileExplorer() {
@@ -215,6 +375,7 @@ function syncMobileExplorer() {
setExplorerCollapsed(true);
} else {
setExplorerCollapsed(false);
setExplorerWidth(state.layout.explorerWidth, { persist: false });
}
};
apply();
+13
View File
@@ -44,6 +44,19 @@
</div>
<div id="explorer-capabilities" class="compact-list"></div>
</section>
<div
class="resize-handle explorer-resize"
id="explorer-resize"
role="separator"
tabindex="0"
aria-label="拖拽调整左侧资源树宽度"
aria-controls="resource-explorer"
aria-orientation="vertical"
aria-valuemin="220"
aria-valuemax="420"
aria-valuenow="292"
title="拖拽调整左侧资源树宽度;用左右方向键微调,Home/End 到最小或最大。"
></div>
</aside>
<section class="center-workspace">
+21
View File
@@ -271,6 +271,7 @@ assert.match(html, /src="\/app\.mjs"/);
for (const workbenchElement of [
"activity-rail",
"explorer",
"explorer-resize",
"resource-explorer",
"resource-tree",
"conversation-list",
@@ -293,11 +294,31 @@ assert.match(html, /aria-expanded="true"/);
assert.match(html, /aria-pressed="false"/);
assert.match(html, /aria-label="收起左侧资源树"/);
assert.match(html, />收起资源树<\/button>/);
assert.match(html, /id="explorer-resize"/);
assert.match(html, /role="separator"/);
assert.match(html, /aria-label="拖拽调整左侧资源树宽度"/);
assert.match(html, /aria-valuemin="220"/);
assert.match(html, /aria-valuemax="420"/);
assert.match(html, /aria-valuenow="292"/);
assert.match(app, /setAttribute\("aria-label", collapsed \? "展开左侧资源树" : "收起左侧资源树"\)/);
assert.match(app, /textContent = collapsed \? "展开资源树" : "收起资源树"/);
assert.match(app, /LAYOUT_STORAGE_KEY\s*=\s*"hwlab\.workbench\.layout\.v1"/);
assert.match(app, /function initExplorerResize/);
assert.match(app, /function clampExplorerWidth/);
assert.match(app, /setPointerCapture/);
assert.match(app, /localStorage\?\.setItem\(\s*LAYOUT_STORAGE_KEY/);
assert.match(app, /ArrowLeft/);
assert.match(app, /ArrowRight/);
assert.match(app, /event\.key === "Home"/);
assert.match(app, /event\.key === "End"/);
assert.match(styles, /--explorer-width:\s*292px/);
assert.match(styles, /--explorer-min-width:\s*220px/);
assert.match(styles, /--explorer-max-width:\s*420px/);
assert.match(styles, /--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/);
assert.match(styles, /--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/);
assert.match(styles, /\.explorer-resize\s*{[^}]*cursor:\s*col-resize;/s);
assert.match(styles, /\.explorer-collapsed \.explorer-resize\s*{[^}]*display:\s*none;/s);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.explorer-resize\s*{[\s\S]*?display:\s*none;/);
assert.match(styles, /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/s);
for (const viewId of ["workspace", "gate"]) {
assert.match(html, new RegExp(`data-view="${viewId}"`));
+63 -1
View File
@@ -19,6 +19,8 @@
--info: #7ea6c9;
--rail-width: 92px;
--explorer-width: 292px;
--explorer-min-width: 220px;
--explorer-max-width: 420px;
--right-width: clamp(560px, 38vw, 740px);
--right-width-expanded: clamp(560px, 45vw, 740px);
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
@@ -140,10 +142,11 @@ ul {
}
.explorer {
position: relative;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 8px;
padding: 10px;
padding: 10px 14px 10px 10px;
background: rgba(22, 25, 22, 0.98);
border-right: 1px solid var(--line);
overflow: hidden;
@@ -154,6 +157,52 @@ ul {
grid-template-columns: var(--rail-width) 0 minmax(420px, 1fr) var(--right-width);
}
.workbench-shell.is-resizing {
cursor: col-resize;
user-select: none;
}
.resize-handle {
position: absolute;
z-index: 5;
background: transparent;
touch-action: none;
}
.resize-handle::before {
content: "";
position: absolute;
background: var(--line-strong);
opacity: 0.56;
}
.explorer-resize {
top: 0;
right: 0;
width: 12px;
height: 100%;
cursor: col-resize;
}
.explorer-resize::before {
top: 10px;
bottom: 10px;
left: 5px;
width: 2px;
}
.resize-handle:hover::before,
.resize-handle:focus-visible::before,
.workbench-shell.is-resizing .resize-handle::before {
background: var(--accent);
opacity: 1;
}
.resize-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.explorer-collapsed .explorer {
padding: 0;
border-right: 0;
@@ -168,6 +217,10 @@ ul {
pointer-events: none;
}
.explorer-collapsed .explorer-resize {
display: none;
}
.explorer-head,
.topbar,
.hardware-status,
@@ -1346,6 +1399,8 @@ tbody tr:last-child td {
.workbench-shell {
--rail-width: 86px;
--explorer-width: 260px;
--explorer-min-width: 210px;
--explorer-max-width: 320px;
grid-template-columns: var(--rail-width) var(--explorer-width) minmax(420px, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(220px, 36dvh, 360px);
}
@@ -1368,6 +1423,8 @@ tbody tr:last-child td {
.workbench-shell,
.explorer-collapsed {
--rail-width: 68px;
--explorer-min-width: 0px;
--explorer-max-width: 0px;
grid-template-columns: var(--rail-width) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(214px, 30dvh, 280px);
}
@@ -1389,10 +1446,15 @@ tbody tr:last-child td {
grid-row: 1 / 3;
z-index: 4;
max-height: 100%;
padding: 10px;
overflow: auto;
box-shadow: -1px 0 0 var(--line), 12px 0 24px rgba(0, 0, 0, 0.24);
}
.explorer-resize {
display: none;
}
.center-workspace,
.right-sidebar {
grid-column: 2;