Merge pull request #163 from pikasTech/fix/mobile-cloud-workbench-reachability

fix: improve mobile cloud workbench reachability
This commit is contained in:
Lyon
2026-05-23 01:17:34 +08:00
committed by GitHub
6 changed files with 501 additions and 15 deletions
+12 -3
View File
@@ -1,14 +1,23 @@
#!/usr/bin/env node
import { fileURLToPath } from "node:url";
import { parseSmokeArgs, printSmokeHelp, runDevCloudWorkbenchSmoke } from "./src/dev-cloud-workbench-smoke-lib.mjs";
import {
parseSmokeArgs,
printSmokeHelp,
runDevCloudWorkbenchMobileSmoke,
runDevCloudWorkbenchSmoke
} from "./src/dev-cloud-workbench-smoke-lib.mjs";
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
const args = parseSmokeArgs(process.argv.slice(2));
const report = args.help ? printSmokeHelp() : await runDevCloudWorkbenchSmoke(process.argv.slice(2));
const report = args.help
? printSmokeHelp()
: args.mobile
? await runDevCloudWorkbenchMobileSmoke()
: await runDevCloudWorkbenchSmoke(process.argv.slice(2));
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
process.exitCode = report.status === "pass" || report.status === "usage" ? 0 : 2;
process.exitCode = report.status === "pass" || report.status === "usage" || report.status === "skip" ? 0 : 2;
} catch (error) {
process.stderr.write(`[dev-cloud-workbench-smoke] ${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
@@ -1,4 +1,5 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -157,6 +158,8 @@ export function parseSmokeArgs(argv) {
args.mode = "static";
} else if (arg === "--live") {
args.mode = "live";
} else if (arg === "--mobile") {
args.mobile = true;
} else if (arg === "--url") {
index += 1;
if (!argv[index]) throw new Error("--url requires a value");
@@ -243,6 +246,16 @@ function runStaticSmoke() {
evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"]
});
addCheck(checks, blockers, "mobile-workbench-layout-contract", hasMobileWorkbenchLayoutContract(files), "390x844 mobile layout keeps the default workbench reachable and moves the resource tree into a controlled drawer.", {
blocker: "runtime_blocker",
evidence: [
"syncMobileExplorer collapses resource tree at <=860px",
"mobile rail spans full height",
"center workspace and right sidebar share the content column",
"resource explorer opens as a full-height drawer"
]
});
addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", {
blocker: "safety_blocker",
evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
@@ -584,6 +597,26 @@ function hasScrollLockContract(styles) {
);
}
function hasMobileWorkbenchLayoutContract({ html, app, styles }) {
return (
/id=["']resource-explorer["']/u.test(html) &&
/aria-controls=["']resource-explorer["']/u.test(html) &&
/aria-expanded=["']true["']/u.test(html) &&
/function\s+syncMobileExplorer\s*\(/u.test(app) &&
/window\.matchMedia\(["']\(max-width:\s*860px\)["']\)/u.test(app) &&
/setExplorerCollapsed\(true\)/u.test(app) &&
/setExplorerCollapsed\(false\)/u.test(app) &&
/function\s+collapseExplorerAfterMobileAction\s*\(/u.test(app) &&
/aria-expanded/u.test(functionBody(app, "setExplorerCollapsed")) &&
/\.explorer-collapsed\s+\.explorer\s*\{[^\}]*pointer-events:\s*none;/su.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.activity-rail\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;/u.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer\s*\{[\s\S]*?grid-row:\s*1\s*\/\s*3;[\s\S]*?z-index:\s*4;/u.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.center-workspace\s*\{[\s\S]*?grid-row:\s*1;/u.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.right-sidebar\s*\{[\s\S]*?grid-row:\s*2;/u.test(styles) &&
/@media\s*\(max-width:\s*520px\)[\s\S]*?\.command-bar\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto\s+auto;/u.test(styles)
);
}
function hasSameOriginReadOnlyBoundary(app) {
const rpcCalls = [...app.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]).sort();
return (
@@ -879,6 +912,291 @@ async function inspectLiveDom(url) {
}
}
export async function runDevCloudWorkbenchMobileSmoke() {
let chromium;
try {
({ chromium } = await import("playwright"));
} catch (error) {
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "static-mobile-browser",
viewport: { width: 390, height: 844 },
evidenceLevel: "SOURCE",
devLive: false,
summary: `Mobile browser smoke skipped because Playwright is unavailable: ${error.message}`,
checks: [],
blockers: [],
safety: staticSafety()
};
}
const server = await startStaticWebServer();
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 1,
isMobile: true
});
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
const closed = await inspectMobileWorkbench(page, { explorerOpen: false });
await page.click("#explorer-toggle");
const open = await inspectMobileWorkbench(page, { explorerOpen: true });
await page.click('[data-side-tab-jump="wiring"]');
const afterQuickAction = await inspectMobileWorkbench(page, { explorerOpen: false });
const checks = [
{
id: "mobile-default-workbench",
status: closed.defaultWorkbenchReachable ? "pass" : "blocked",
summary: "390x844 default route remains the user workbench with Gate/help as secondary entries.",
observations: {
title: closed.title,
workspaceHidden: closed.workspaceHidden,
gateHidden: closed.gateHidden,
helpHidden: closed.helpHidden,
explorerCollapsed: closed.explorerCollapsed,
visibleText: closed.visibleText
}
},
{
id: "mobile-default-hit-test",
status: closed.primaryControlsReachable ? "pass" : "blocked",
summary: "Primary workbench controls are center-click reachable with the resource tree closed.",
observations: {
reachable: closed.reachable,
blocked: closed.blocked
}
},
{
id: "mobile-resource-drawer-hit-test",
status: open.resourceControlsReachable ? "pass" : "blocked",
summary: "Opened resource drawer keeps quick actions and resource rows center-click reachable.",
observations: {
drawer: open.drawer,
reachable: open.reachable,
blocked: open.blocked
}
},
{
id: "mobile-resource-action-collapse",
status: afterQuickAction.resourceActionCollapsed ? "pass" : "blocked",
summary: "Mobile resource quick actions collapse the drawer so the target workspace panel remains reachable.",
observations: {
explorerCollapsed: afterQuickAction.explorerCollapsed,
wiringSelected: afterQuickAction.wiringSelected,
tabHit: afterQuickAction.reachable.find((target) => target.label === "接线")
}
}
];
const blockers = checks
.filter((check) => check.status !== "pass")
.map((check) => ({
type: "runtime_blocker",
scope: check.id,
status: "open",
summary: check.summary
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "static-mobile-browser",
viewport: { width: 390, height: 844 },
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: "Static local browser smoke only; deployment still requires post-deploy live smoke.",
checks,
blockers,
safety: staticSafety()
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "static-mobile-browser",
viewport: { width: 390, height: 844 },
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: `Mobile browser smoke failed: ${error.message}`,
checks: [],
blockers: [
{
type: "runtime_blocker",
scope: "mobile-browser-smoke",
status: "open",
summary: error.message
}
],
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
await server.close();
}
}
async function startStaticWebServer() {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate") {
pathname = "/index.html";
}
const filePath = path.resolve(webRoot, pathname.replace(/^\/+/, ""));
if (!filePath.startsWith(webRoot)) {
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
response.end("forbidden");
return;
}
try {
const body = await fs.promises.readFile(filePath);
response.writeHead(200, { "content-type": contentTypeFor(filePath) });
response.end(body);
} catch {
response.writeHead(404, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ ok: false, error: "not_found" }));
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
url: `http://127.0.0.1:${address.port}/`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
};
}
function contentTypeFor(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
return "application/octet-stream";
}
async function inspectMobileWorkbench(page, { explorerOpen }) {
return page.evaluate(async ({ explorerOpen }) => {
function visibleTextFor(selector) {
const element = document.querySelector(selector);
return element?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
}
function boxFor(selector) {
const element = document.querySelector(selector);
if (!element) return null;
const box = element.getBoundingClientRect();
return {
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
width: box.width,
height: box.height,
display: getComputedStyle(element).display
};
}
function ownsHit(element, hit) {
return hit === element || element.contains(hit);
}
function inspectTarget(element, label = element.id || element.textContent?.trim() || element.tagName) {
const box = element.getBoundingClientRect();
const cx = box.left + box.width / 2;
const cy = box.top + box.height / 2;
const hit = document.elementFromPoint(cx, cy);
const hitLabel = hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none";
return {
label: label.replace(/\s+/gu, " ").trim(),
ok: box.width > 0 && box.height > 0 && cx >= 0 && cx <= window.innerWidth && cy >= 0 && cy <= window.innerHeight && ownsHit(element, hit),
center: { x: cx, y: cy },
box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height },
hit: hitLabel
};
}
function inspectSelector(selector, label) {
const element = document.querySelector(selector);
if (!element) {
return { label, ok: false, missing: true };
}
return inspectTarget(element, label);
}
async function inspectScrollableTargets(selector) {
const targets = [...document.querySelectorAll(selector)];
const results = [];
for (const target of targets) {
target.scrollIntoView({ block: "center", inline: "nearest" });
await new Promise((resolve) => requestAnimationFrame(resolve));
results.push(inspectTarget(target));
}
return results;
}
const defaultTargets = [
inspectSelector('[data-route="workspace"]', "工作台"),
inspectSelector('[data-route="gate"]', "内部复核"),
inspectSelector('[data-route="help"]', "使用说明"),
inspectSelector("#explorer-toggle", "资源树"),
inspectSelector("#command-input", "Agent 输入"),
inspectSelector("#command-send", "发送"),
inspectSelector("#command-clear", "清空"),
inspectSelector("#tab-control", "控制"),
inspectSelector("#tab-wiring", "接线"),
inspectSelector("#tab-records", "可信记录")
];
const drawerTargets = explorerOpen
? [
...[...document.querySelectorAll(".quick-actions button")].map((element) => inspectTarget(element)),
...(await inspectScrollableTargets("#resource-tree summary, #resource-tree .tree-row"))
]
: [];
const reachable = explorerOpen ? drawerTargets : defaultTargets;
const blocked = reachable.filter((target) => !target.ok);
const text = document.body.textContent ?? "";
const workspace = document.querySelector('[data-view="workspace"]');
const gate = document.querySelector('[data-view="gate"]');
const help = document.querySelector('[data-view="help"]');
const shell = document.querySelector("[data-app-shell]");
const drawer = boxFor("#resource-explorer");
return {
title: document.title,
workspaceHidden: workspace ? workspace.hidden : null,
gateHidden: gate ? gate.hidden : null,
helpHidden: help ? help.hidden : null,
explorerCollapsed: shell?.classList.contains("explorer-collapsed") ?? null,
visibleText: {
bodyHasWorkbench: text.includes("用户工作台") && text.includes("Agent 对话"),
gateIsSecondaryLabel: text.includes("内部复核"),
helpIsSecondaryLabel: text.includes("使用说明"),
visibleWorkspaceText: visibleTextFor(".center-workspace").slice(0, 160)
},
drawer,
defaultWorkbenchReachable:
document.title === "HWLAB 云工作台" &&
workspace?.hidden === false &&
gate?.hidden === true &&
help?.hidden === true &&
text.includes("用户工作台") &&
text.includes("Agent 对话") &&
text.includes("内部复核") &&
text.includes("使用说明"),
primaryControlsReachable: defaultTargets.every((target) => target.ok),
resourceControlsReachable: drawerTargets.length >= 8 && drawerTargets.every((target) => target.ok),
resourceActionCollapsed:
!explorerOpen &&
shell?.classList.contains("explorer-collapsed") === true &&
document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true",
wiringSelected: document.querySelector("#tab-wiring")?.getAttribute("aria-selected") === "true",
reachable,
blocked
};
}, { explorerOpen });
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
@@ -889,6 +1207,7 @@ export function printSmokeHelp() {
command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --live --url http://74.48.78.17:16666/",
notes: [
"Static mode reads repository files and emits SOURCE-level evidence only.",
"--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.",
"Live mode is read-only and reports blocked/skip when optional browser DOM evidence is unavailable."
]
};
+32 -3
View File
@@ -98,6 +98,7 @@ const state = {
initRoutes();
initExplorerToggle();
syncMobileExplorer();
initSideTabs();
initQuickActions();
initCommandBar();
@@ -154,11 +155,29 @@ function showView(route) {
function initExplorerToggle() {
el.explorerToggle.addEventListener("click", () => {
const collapsed = !el.shell.classList.contains("explorer-collapsed");
el.shell.classList.toggle("explorer-collapsed", collapsed);
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
setExplorerCollapsed(collapsed);
});
}
function setExplorerCollapsed(collapsed) {
el.shell.classList.toggle("explorer-collapsed", collapsed);
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
el.explorerToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
}
function syncMobileExplorer() {
const media = window.matchMedia("(max-width: 860px)");
const apply = () => {
if (media.matches) {
setExplorerCollapsed(true);
} else {
setExplorerCollapsed(false);
}
};
apply();
media.addEventListener("change", apply);
}
async function loadHelpSurface() {
el.helpStatus.textContent = "加载中";
el.helpStatus.className = "state-tag tone-dry-run";
@@ -191,7 +210,10 @@ function initSideTabs() {
tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab));
}
for (const jump of document.querySelectorAll("[data-side-tab-jump]")) {
jump.addEventListener("click", () => selectSideTab(jump.dataset.sideTabJump));
jump.addEventListener("click", () => {
selectSideTab(jump.dataset.sideTabJump);
collapseExplorerAfterMobileAction();
});
}
}
@@ -199,10 +221,17 @@ function initQuickActions() {
for (const button of document.querySelectorAll("[data-focus-command]")) {
button.addEventListener("click", () => {
el.commandInput.focus();
collapseExplorerAfterMobileAction();
});
}
}
function collapseExplorerAfterMobileAction() {
if (window.matchMedia("(max-width: 860px)").matches) {
setExplorerCollapsed(true);
}
}
function selectSideTab(tabId) {
for (const tab of document.querySelectorAll("[data-side-tab]")) {
const active = tab.dataset.sideTab === tabId;
+2 -2
View File
@@ -14,10 +14,10 @@
<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" id="explorer-toggle" type="button" title="展开或收起资源树" aria-label="展开或收起资源树">资源树</button>
<button class="rail-button" id="explorer-toggle" type="button" title="展开或收起资源树" aria-label="展开或收起资源树" aria-controls="resource-explorer" aria-expanded="true">资源树</button>
</aside>
<aside class="explorer" aria-label="资源浏览与硬件资源树">
<aside class="explorer" id="resource-explorer" aria-label="资源浏览与硬件资源树">
<header class="explorer-head">
<div>
<p class="eyebrow">HWLAB Cloud</p>
+29 -1
View File
@@ -3,7 +3,10 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs";
import { runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs";
import {
runDevCloudWorkbenchMobileSmoke,
runDevCloudWorkbenchStaticSmoke
} from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs";
import { gateSummary } from "../gate-summary.mjs";
import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs";
import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs";
@@ -54,12 +57,23 @@ const requiredTrustedRecordTerms = Object.freeze([
]);
const workbenchSmoke = runDevCloudWorkbenchStaticSmoke();
const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract");
const mobileLayoutSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "mobile-workbench-layout-contract");
const mobileWorkbenchSmoke = await runDevCloudWorkbenchMobileSmoke();
assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2));
assert.equal(workbenchSmoke.evidenceLevel, "SOURCE");
assert.equal(workbenchSmoke.devLive, false);
assert.equal(helpSmokeCheck?.status, "pass", "PR #114 help Markdown route must be present and ready");
assert.equal(mobileLayoutSmokeCheck?.status, "pass", "mobile 390x844 workbench layout contract must be present");
assert.equal(workbenchSmoke.help.status, "pass", "smoke report must not leave #114 help contract pending");
assert.notEqual(mobileWorkbenchSmoke.status, "blocked", JSON.stringify(mobileWorkbenchSmoke.blockers, null, 2));
assert.equal(mobileWorkbenchSmoke.evidenceLevel, "SOURCE");
assert.equal(mobileWorkbenchSmoke.devLive, false);
if (mobileWorkbenchSmoke.status === "pass") {
assert.equal(mobileWorkbenchSmoke.viewport.width, 390);
assert.equal(mobileWorkbenchSmoke.viewport.height, 844);
assert.equal(mobileWorkbenchSmoke.checks.every((check) => check.status === "pass"), true);
}
assert.match(html, /HWLAB 云工作台/);
for (const chineseWorkbenchText of [
@@ -150,6 +164,7 @@ assert.match(html, /src="\/app\.mjs"/);
for (const workbenchElement of [
"activity-rail",
"explorer",
"resource-explorer",
"resource-tree",
"conversation-list",
"agent-chat-status",
@@ -165,6 +180,8 @@ for (const workbenchElement of [
]) {
assert.match(html, new RegExp(workbenchElement));
}
assert.match(html, /aria-controls="resource-explorer"/);
assert.match(html, /aria-expanded="true"/);
for (const viewId of ["workspace", "gate"]) {
assert.match(html, new RegExp(`data-view="${viewId}"`));
}
@@ -286,6 +303,17 @@ assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s);
assert.match(styles, /\.workbench-shell\s*{[^}]*height:\s*100(?:d)?vh;[^}]*overflow:\s*hidden;/s);
assert.match(styles, /\.view\s*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.(?:resource-tree|compact-list|conversation-list|trace-list|task-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(app, /function syncMobileExplorer/);
assert.match(app, /window\.matchMedia\("\(max-width: 860px\)"\)/);
assert.match(app, /setExplorerCollapsed\(true\)/);
assert.match(app, /function collapseExplorerAfterMobileAction/);
assert.match(functionBody(app, "setExplorerCollapsed"), /aria-expanded/);
assert.match(styles, /\.explorer-collapsed \.explorer\s*{[^}]*pointer-events:\s*none;/s);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.activity-rail\s*{[\s\S]*?grid-row:\s*1 \/ 3;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.explorer\s*{[\s\S]*?grid-row:\s*1 \/ 3;[\s\S]*?z-index:\s*4;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.center-workspace\s*{[\s\S]*?grid-row:\s*1;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.right-sidebar\s*{[\s\S]*?grid-row:\s*2;/);
assert.match(styles, /@media \(max-width: 520px\)[\s\S]*?\.command-bar\s*{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;/);
assert.match(app, /fetchJson\("\/v1"\)/);
assert.match(app, /fetchJson\("\/health\/live"\)/);
assert.match(app, /callRpc\("system\.health"\)/);
+107 -6
View File
@@ -147,6 +147,10 @@ ul {
display: none;
}
.explorer-collapsed .explorer {
pointer-events: none;
}
.explorer-head,
.topbar,
.hardware-status,
@@ -1129,34 +1133,89 @@ tbody tr:last-child td {
@media (max-width: 860px) {
.workbench-shell,
.explorer-collapsed {
grid-template-columns: 76px minmax(0, 1fr);
grid-template-rows: clamp(150px, 26dvh, 240px) minmax(0, 1fr) clamp(180px, 32dvh, 280px);
grid-template-columns: 68px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(214px, 30dvh, 280px);
}
.activity-rail {
grid-column: 1;
grid-row: 1 / 3;
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(12px, 1fr) minmax(42px, auto);
overflow: hidden;
}
.rail-button {
min-height: 42px;
}
.explorer {
grid-column: 2;
grid-row: 1 / 3;
z-index: 4;
max-height: 100%;
overflow: auto;
box-shadow: -1px 0 0 var(--line), 12px 0 24px rgba(0, 0, 0, 0.24);
}
.center-workspace,
.right-sidebar {
grid-column: 1 / 3;
grid-column: 2;
}
.center-workspace {
grid-row: 2;
grid-row: 1;
}
.right-sidebar {
grid-row: 3;
grid-row: 2;
}
.explorer-collapsed .explorer {
display: none;
}
.topbar {
min-height: 0;
gap: 8px;
padding: 10px;
}
.topbar-main h2 {
font-size: 16px;
}
.probe-card {
padding: 8px 10px;
}
.workbench-view,
.gate-view,
.help-view,
.right-sidebar {
padding: 8px;
}
.conversation-column {
grid-template-rows: minmax(210px, 1fr) minmax(150px, 0.45fr);
gap: 8px;
}
.command-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: stretch;
padding: 8px;
}
.input-shell {
padding: 0 8px;
}
.command-button {
min-width: 48px;
padding: 0 9px;
}
.topbar,
.command-bar,
.gate-split {
grid-template-columns: 1fr;
}
@@ -1173,3 +1232,45 @@ tbody tr:last-child td {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.workbench-shell,
.explorer-collapsed {
grid-template-columns: 64px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) clamp(200px, 28dvh, 250px);
}
.activity-rail {
padding: 7px;
}
.rail-button {
padding: 6px 3px;
font-size: 11px;
}
.topbar {
display: block;
}
.topbar .probe-card {
margin-top: 8px;
}
.command-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.prompt-mark {
display: none;
}
.input-shell {
grid-template-columns: minmax(0, 1fr);
}
.command-button {
min-width: 45px;
padding: 0 8px;
}
}