From 529282dcff293a84f705c92666fe45682b3ad6e7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 06:14:40 -0500 Subject: [PATCH] fix(ui): harden Workboard dialog accessibility Harden Workboard modal and drawer accessibility. Summary: - Add Workboard dialog focus lifecycle handling for initial focus, Tab/Shift+Tab containment, Escape close, and opener restore. - Mark Workboard background content inert/aria-hidden while modal or drawer dialogs are active. - Add focused unit and Chromium browser smoke coverage for the audited modal/drawer accessibility requirements. - Keep UI browser test aliases able to resolve shared workspace packages used by the Workboard view. Verification: - node scripts/run-vitest.mjs ui/src/ui/views/workboard.test.ts - node scripts/run-vitest.mjs ui/src/ui/views/workboard.browser.test.ts - (cd ui && pnpm exec vitest run --config vitest.config.ts --project browser src/ui/views/workboard.browser.test.ts) - GitHub checks green at 6557012430dcdf42ab6c805de66aa43d948fa57f --- scripts/crabbox-wrapper.mjs | 10 +- scripts/e2e/cron-mcp-cleanup-docker-client.ts | 2 +- scripts/e2e/parallels/linux-smoke.ts | 12 +- scripts/e2e/parallels/windows-smoke.ts | 18 +- scripts/measure-rpc-rtt.mjs | 4 +- .../resolve-openclaw-package-candidate.mjs | 13 +- scripts/test-projects.test-support.mjs | 3 +- scripts/tsdown-build.mjs | 6 +- .../server-methods/server-methods.test.ts | 2 +- test/openclaw-npm-release-check.test.ts | 10 +- .../check-openclaw-package-tarball.test.ts | 79 +-- test/scripts/crabbox-wrapper.test.ts | 18 +- .../cron-mcp-cleanup-docker-client.test.ts | 6 +- test/scripts/e2e-temp-state-dir.test.ts | 52 +- test/scripts/kitchen-sink-rpc-walk.test.ts | 6 +- test/scripts/npm-telegram-live.test.ts | 8 +- .../secret-provider-integrations.test.ts | 75 ++- ui/src/styles/workboard.css | 21 + ui/src/ui/app-render-usage-tab.ts | 558 +++++++++--------- ui/src/ui/views/workboard.browser.test.ts | 101 ++++ ui/src/ui/views/workboard.test.ts | 268 ++++++++- ui/src/ui/views/workboard.ts | 498 +++++++++++----- ui/vitest.config.ts | 46 +- 23 files changed, 1217 insertions(+), 599 deletions(-) create mode 100644 ui/src/ui/views/workboard.browser.test.ts diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index 2143c71b1036..2bb56f4b8ea0 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -1659,7 +1659,7 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) { 'mkdir -p "$tool_root" || { status=$?; return "$status"; };', 'install_lock="$tool_root/.node-${node_version}-${node_arch}.lock";', "lock_acquired=0;", - 'lock_deadline=$((SECONDS + 300));', + "lock_deadline=$((SECONDS + 300));", "while true; do", 'if mkdir "$install_lock" 2>/dev/null; then lock_acquired=1; printf "%s\\n" "$$" >"$install_lock/pid" || { status=$?; rm -rf "$install_lock"; return "$status"; }; break; fi;', 'if [ -x "$node_dir/bin/node" ] && [ -f "$ready_marker" ]; then break; fi;', @@ -1668,11 +1668,11 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) { 'if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then echo "timed out waiting for active macOS Node toolchain install lock: $install_lock pid=$lock_pid" >&2; return 1; fi;', 'echo "reclaiming stale macOS Node toolchain install lock: $install_lock" >&2;', 'rm -rf "$install_lock" || return 1;', - 'lock_deadline=$((SECONDS + 300));', + "lock_deadline=$((SECONDS + 300));", "fi;", "sleep 1;", "done;", - "release_install_lock() { if [ \"$lock_acquired\" = \"1\" ]; then rm -rf \"$install_lock\" 2>/dev/null || true; fi; };", + 'release_install_lock() { if [ "$lock_acquired" = "1" ]; then rm -rf "$install_lock" 2>/dev/null || true; fi; };', 'if [ ! -x "$node_dir/bin/node" ] || [ ! -f "$ready_marker" ]; then', 'tmp_dir="$(mktemp -d)" || { release_install_lock; return 1; };', 'pkg="node-v${node_version}-darwin-${node_arch}.tar.gz";', @@ -1948,7 +1948,9 @@ function parseNonNegativeIntegerEnv(name, fallback, unit) { } const parsed = Number(raw); if (!Number.isSafeInteger(parsed)) { - throw new Error(`${name} must be a safe non-negative integer ${unit}, got ${JSON.stringify(raw)}`); + throw new Error( + `${name} must be a safe non-negative integer ${unit}, got ${JSON.stringify(raw)}`, + ); } return parsed; } diff --git a/scripts/e2e/cron-mcp-cleanup-docker-client.ts b/scripts/e2e/cron-mcp-cleanup-docker-client.ts index ef06bdf211c8..668e78076391 100644 --- a/scripts/e2e/cron-mcp-cleanup-docker-client.ts +++ b/scripts/e2e/cron-mcp-cleanup-docker-client.ts @@ -6,8 +6,8 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import type { GatewayRpcClient } from "./mcp-channels-harness.ts"; import { readPositiveIntEnv } from "./lib/env-limits.mjs"; +import type { GatewayRpcClient } from "./mcp-channels-harness.ts"; const execFileAsync = promisify(execFile); const PROBE_PID_WAIT_MS = readCronMcpCleanupProbePidWaitMs(); diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index a480636f7197..5f182e84acb4 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -325,10 +325,8 @@ class LinuxSmoke extends SmokeRunController { ); await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus()); this.status.freshGateway = "pass"; - await this.phase( - "fresh.first-local-agent-turn", - this.agentTimeoutSeconds, - () => this.verifyLocalTurn(), + await this.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () => + this.verifyLocalTurn(), ); this.status.freshAgent = "pass"; } @@ -357,10 +355,8 @@ class LinuxSmoke extends SmokeRunController { ); await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus()); this.status.upgradeGateway = "pass"; - await this.phase( - "upgrade.first-local-agent-turn", - this.agentTimeoutSeconds, - () => this.verifyLocalTurn(), + await this.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () => + this.verifyLocalTurn(), ); this.status.upgradeAgent = "pass"; } diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index becd258bff5b..28822640ae95 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -357,11 +357,7 @@ class WindowsSmoke extends SmokeRunController { await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart")); await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable()); this.status.freshGateway = "pass"; - await this.phase( - "fresh.first-agent-turn", - this.agentTimeoutSeconds, - () => this.verifyTurn(), - ); + await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn()); this.status.freshAgent = "pass"; } @@ -403,10 +399,8 @@ class WindowsSmoke extends SmokeRunController { this.status.upgradePrecheck = "latest-ref-fail"; } await this.phase("upgrade.gateway-stop-before-update", 420, () => this.gatewayAction("stop")); - await this.phase( - "upgrade.update-dev", - this.updateTimeoutSeconds, - () => this.runDevChannelUpdate(), + await this.phase("upgrade.update-dev", this.updateTimeoutSeconds, () => + this.runDevChannelUpdate(), ); this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev"); await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate()); @@ -415,11 +409,7 @@ class WindowsSmoke extends SmokeRunController { await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart")); await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable()); this.status.upgradeGateway = "pass"; - await this.phase( - "upgrade.first-agent-turn", - this.agentTimeoutSeconds, - () => this.verifyTurn(), - ); + await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn()); this.status.upgradeAgent = "pass"; } diff --git a/scripts/measure-rpc-rtt.mjs b/scripts/measure-rpc-rtt.mjs index 73e885a8d93e..e657e16006a9 100644 --- a/scripts/measure-rpc-rtt.mjs +++ b/scripts/measure-rpc-rtt.mjs @@ -161,9 +161,7 @@ async function stopGateway(child) { } async function closeFileHandles(handles) { - const results = await Promise.allSettled( - handles.filter(Boolean).map((handle) => handle.close()), - ); + const results = await Promise.allSettled(handles.filter(Boolean).map((handle) => handle.close())); const failedClose = results.find((result) => result.status === "rejected"); if (failedClose) { throw failedClose.reason; diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index a80b8cd7fe80..c38b1bba8432 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -185,14 +185,11 @@ function run(command, args, options = {}) { }; const terminateChild = () => { killChild("SIGTERM"); - killTimer = setTimeout( - () => { - killTimer = undefined; - killChild("SIGKILL"); - timeoutReject?.(); - }, - options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS, - ); + killTimer = setTimeout(() => { + killTimer = undefined; + killChild("SIGKILL"); + timeoutReject?.(); + }, options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS); }; const timeout = options.timeoutMs === undefined diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 35d109a735ad..def51b475c98 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1647,8 +1647,7 @@ function resolveToolingChangedTestTargets(changedPaths, cwd = process.cwd()) { return [...new Set(targets)]; } -const TOOLING_SCRIPT_PATH_PATTERN = - /^scripts\/(.+)\.(?:mjs|cjs|js|mts|cts|ts|sh|py|ps1)$/u; +const TOOLING_SCRIPT_PATH_PATTERN = /^scripts\/(.+)\.(?:mjs|cjs|js|mts|cts|ts|sh|py|ps1)$/u; function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd()) { const match = TOOLING_SCRIPT_PATH_PATTERN.exec(changedPath); diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 288283759438..8e1af6583134 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -595,10 +595,8 @@ export async function runTsdownBuildInvocation(invocation, params = {}) { "OPENCLAW_TSDOWN_TIMEOUT_MS", ); const heartbeatMs = - parseNonNegativeIntegerEnv( - env.OPENCLAW_TSDOWN_HEARTBEAT_MS, - "OPENCLAW_TSDOWN_HEARTBEAT_MS", - ) ?? DEFAULT_HEARTBEAT_MS; + parseNonNegativeIntegerEnv(env.OPENCLAW_TSDOWN_HEARTBEAT_MS, "OPENCLAW_TSDOWN_HEARTBEAT_MS") ?? + DEFAULT_HEARTBEAT_MS; let timedOut = false; let settled = false; let lastOutputAt = Date.now(); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 41941e6cfb49..e29fb56e5fc9 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { validateExecApprovalRequestParams } from "../../../packages/gateway-protocol/src/index.js"; +import { HEARTBEAT_PROMPT } from "../../auto-reply/heartbeat.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js"; import { @@ -21,7 +22,6 @@ import { buildSystemRunApprovalBinding, buildSystemRunApprovalEnvBinding, } from "../../infra/system-run-approval-binding.js"; -import { HEARTBEAT_PROMPT } from "../../auto-reply/heartbeat.js"; import { resetLogger, setLoggerOverride } from "../../logging.js"; import { projectRecentChatDisplayMessages } from "../chat-display-projection.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index cec5ccdad55e..dd78bee8ea71 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -458,9 +458,9 @@ describe("runNpmReleaseCheckCommand", () => { describe("resolveNpmReleaseCheckCommandTimeoutMs", () => { it("parses only positive integer environment timeouts", () => { expect(resolveNpmReleaseCheckCommandTimeoutMs({})).toBe(10 * 60 * 1000); - expect(resolveNpmReleaseCheckCommandTimeoutMs({ OPENCLAW_NPM_RELEASE_CHECK_COMMAND_TIMEOUT_MS: "" })).toBe( - 10 * 60 * 1000, - ); + expect( + resolveNpmReleaseCheckCommandTimeoutMs({ OPENCLAW_NPM_RELEASE_CHECK_COMMAND_TIMEOUT_MS: "" }), + ).toBe(10 * 60 * 1000); expect( resolveNpmReleaseCheckCommandTimeoutMs({ OPENCLAW_NPM_RELEASE_CHECK_COMMAND_TIMEOUT_MS: "1234", @@ -468,8 +468,8 @@ describe("resolveNpmReleaseCheckCommandTimeoutMs", () => { ).toBe(1234); for (const raw of ["nope", "10m", "1e3", "0", "-1", "9007199254740992"]) { - expect( - () => resolveNpmReleaseCheckCommandTimeoutMs({ + expect(() => + resolveNpmReleaseCheckCommandTimeoutMs({ OPENCLAW_NPM_RELEASE_CHECK_COMMAND_TIMEOUT_MS: raw, }), ).toThrow(`invalid OPENCLAW_NPM_RELEASE_CHECK_COMMAND_TIMEOUT_MS: ${raw}`); diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 2e154730f614..b013e537d6c0 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -75,46 +75,49 @@ function withTarball( } describe("check-openclaw-package-tarball", () => { - it.runIf(process.platform !== "win32")("removes the extract dir when tar extraction fails", () => { - const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-extract-fail-")); - try { - const fakeBin = join(root, "bin"); - mkdirSync(fakeBin); - const extractDirFile = join(root, "extract-dir.txt"); - const fakeTar = join(fakeBin, "tar"); - writeFileSync( - fakeTar, - [ - "#!/usr/bin/env node", - "const fs = require('node:fs');", - "const args = process.argv.slice(2);", - "if (args[0] === '-tf') { console.log('package/package.json'); process.exit(0); }", - "const outputDir = args[args.indexOf('-C') + 1];", - "fs.writeFileSync(process.env.OPENCLAW_TEST_EXTRACT_DIR_FILE, outputDir);", - "console.error('extract denied');", - "process.exit(7);", - ].join("\n"), - ); - chmodSync(fakeTar, 0o755); - const tarball = join(root, "openclaw.tgz"); - writeFileSync(tarball, "not used by fake tar"); + it.runIf(process.platform !== "win32")( + "removes the extract dir when tar extraction fails", + () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-extract-fail-")); + try { + const fakeBin = join(root, "bin"); + mkdirSync(fakeBin); + const extractDirFile = join(root, "extract-dir.txt"); + const fakeTar = join(fakeBin, "tar"); + writeFileSync( + fakeTar, + [ + "#!/usr/bin/env node", + "const fs = require('node:fs');", + "const args = process.argv.slice(2);", + "if (args[0] === '-tf') { console.log('package/package.json'); process.exit(0); }", + "const outputDir = args[args.indexOf('-C') + 1];", + "fs.writeFileSync(process.env.OPENCLAW_TEST_EXTRACT_DIR_FILE, outputDir);", + "console.error('extract denied');", + "process.exit(7);", + ].join("\n"), + ); + chmodSync(fakeTar, 0o755); + const tarball = join(root, "openclaw.tgz"); + writeFileSync(tarball, "not used by fake tar"); - const result = spawnSync("node", [CHECK_SCRIPT, tarball], { - encoding: "utf8", - env: { - ...process.env, - OPENCLAW_TEST_EXTRACT_DIR_FILE: extractDirFile, - PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, - }, - }); + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_TEST_EXTRACT_DIR_FILE: extractDirFile, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + }, + }); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("extract denied"); - expect(existsSync(readFileSync(extractDirFile, "utf8"))).toBe(false); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("extract denied"); + expect(existsSync(readFileSync(extractDirFile, "utf8"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); it("allows legacy private QA inventory entries omitted from shipped tarballs through 2026.4.25", () => { withTarball( diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index 7b6b507bb9f7..08540457ed07 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -712,9 +712,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => { const output = parseFakeCrabboxOutput(result); const remoteCommand = normalizeShellLineEndings(output.args.at(-1) ?? ""); expect(result.status).toBe(0); - expect(remoteCommand).toContain( - 'macos_locale="${OPENCLAW_CRABBOX_MACOS_LOCALE:-en_US.UTF-8}"', - ); + expect(remoteCommand).toContain('macos_locale="${OPENCLAW_CRABBOX_MACOS_LOCALE:-en_US.UTF-8}"'); expect(remoteCommand).toContain( 'case "${LANG:-}" in C.UTF-8|C.utf8|c.UTF-8|c.utf8) export LANG="$macos_locale" ;; esac;', ); @@ -743,9 +741,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js"); expect(remoteCommand).toContain("node-v${node_version}-darwin-${node_arch}.tar.gz"); expect(remoteCommand).toContain("shasum -a 256 -c -"); - expect(remoteCommand).toContain( - 'ready_marker="$node_dir/.openclaw-crabbox-node-ready"', - ); + expect(remoteCommand).toContain('ready_marker="$node_dir/.openclaw-crabbox-node-ready"'); expect(remoteCommand).toContain( 'if [ -x "$node_dir/bin/node" ] && [ -f "$ready_marker" ]; then break; fi;', ); @@ -753,19 +749,15 @@ describe.concurrent("scripts/crabbox-wrapper", () => { expect(remoteCommand).toContain( 'install_lock="$tool_root/.node-${node_version}-${node_arch}.lock"', ); - expect(remoteCommand).toContain('lock_deadline=$((SECONDS + 300))'); - expect(remoteCommand).toContain( - 'printf "%s\\n" "$$" >"$install_lock/pid"', - ); + expect(remoteCommand).toContain("lock_deadline=$((SECONDS + 300))"); + expect(remoteCommand).toContain('printf "%s\\n" "$$" >"$install_lock/pid"'); expect(remoteCommand).toContain( "timed out waiting for active macOS Node toolchain install lock: $install_lock pid=$lock_pid", ); expect(remoteCommand).toContain( "reclaiming stale macOS Node toolchain install lock: $install_lock", ); - expect(remoteCommand).toContain( - 'rm -rf "$install_lock"', - ); + expect(remoteCommand).toContain('rm -rf "$install_lock"'); expect(remoteCommand).toContain("release_install_lock"); expect(remoteCommand).not.toContain("set -euo pipefail"); expect(remoteCommand).toContain('return "$status"'); diff --git a/test/scripts/cron-mcp-cleanup-docker-client.test.ts b/test/scripts/cron-mcp-cleanup-docker-client.test.ts index 1523b1cbce65..bfd7e20b5017 100644 --- a/test/scripts/cron-mcp-cleanup-docker-client.test.ts +++ b/test/scripts/cron-mcp-cleanup-docker-client.test.ts @@ -10,9 +10,9 @@ import { describe("cron MCP cleanup docker client", () => { it("rejects malformed probe pid wait limits", () => { expect(readCronMcpCleanupProbePidWaitMs({})).toBe(120_000); - expect( - readCronMcpCleanupProbePidWaitMs({ OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS: "250" }), - ).toBe(250); + expect(readCronMcpCleanupProbePidWaitMs({ OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS: "250" })).toBe( + 250, + ); for (const value of ["1.5", "1e3", "10ms", "0"]) { expect(() => readCronMcpCleanupProbePidWaitMs({ diff --git a/test/scripts/e2e-temp-state-dir.test.ts b/test/scripts/e2e-temp-state-dir.test.ts index dc58f95d90fb..b7c6d947dbfb 100644 --- a/test/scripts/e2e-temp-state-dir.test.ts +++ b/test/scripts/e2e-temp-state-dir.test.ts @@ -1,5 +1,13 @@ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; @@ -51,28 +59,34 @@ describe("E2E temp state dirs", () => { } }); - it.runIf(process.platform !== "win32")("retries generated state cleanup after a failed removal", async () => { - const root = mkdtempSync(path.join(tmpdir(), "openclaw-e2e-temp-state-retry-")); - const lockedParent = path.join(root, "locked"); - mkdirSync(lockedParent); + it.runIf(process.platform !== "win32")( + "retries generated state cleanup after a failed removal", + async () => { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-e2e-temp-state-retry-")); + const lockedParent = path.join(root, "locked"); + mkdirSync(lockedParent); - const state = await createE2eStateDir(`${path.relative(tmpdir(), lockedParent)}${path.sep}state-`, { - OPENCLAW_STATE_DIR: "", - }); + const state = await createE2eStateDir( + `${path.relative(tmpdir(), lockedParent)}${path.sep}state-`, + { + OPENCLAW_STATE_DIR: "", + }, + ); - try { - chmodSync(lockedParent, 0o500); - expect(() => state.cleanup()).toThrow(); - expect(existsSync(state.stateDir)).toBe(true); - } finally { - chmodSync(lockedParent, 0o700); - } + try { + chmodSync(lockedParent, 0o500); + expect(() => state.cleanup()).toThrow(); + expect(existsSync(state.stateDir)).toBe(true); + } finally { + chmodSync(lockedParent, 0o700); + } - state.cleanup(); - expect(existsSync(state.stateDir)).toBe(false); + state.cleanup(); + expect(existsSync(state.stateDir)).toBe(false); - rmSync(root, { force: true, recursive: true }); - }); + rmSync(root, { force: true, recursive: true }); + }, + ); it("cleans generated state dirs on termination signals", async () => { const root = mkdtempSync(path.join(tmpdir(), "openclaw-e2e-temp-state-signal-")); diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts index 338ccb572147..90610a4ad9b9 100644 --- a/test/scripts/kitchen-sink-rpc-walk.test.ts +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -930,9 +930,9 @@ describe("kitchen-sink RPC process sampling", () => { it("allows missing command samples but fails command RSS spikes", () => { expect(() => assertCommandResourceCeiling(null)).not.toThrow(); - expect(() => - assertCommandResourceCeiling({ aggregateRssMiB: 8193, rssMiB: 1024 }), - ).toThrow("command aggregate RSS exceeded 8192 MiB: 8193 MiB"); + expect(() => assertCommandResourceCeiling({ aggregateRssMiB: 8193, rssMiB: 1024 })).toThrow( + "command aggregate RSS exceeded 8192 MiB: 8193 MiB", + ); }); }); diff --git a/test/scripts/npm-telegram-live.test.ts b/test/scripts/npm-telegram-live.test.ts index 55d1fc091e25..201819df9dc4 100644 --- a/test/scripts/npm-telegram-live.test.ts +++ b/test/scripts/npm-telegram-live.test.ts @@ -46,8 +46,8 @@ describe("package Telegram live Docker E2E", () => { expect(installRun).toContain( '"$timeout_bin" --kill-after=30s "$npm_install_timeout" npm install -g "$install_source" --no-fund --no-audit', ); - expect(installRun).toContain('elif command -v gtimeout >/dev/null 2>&1; then'); - expect(installRun).toContain("timeout_bin=\"gtimeout\""); + expect(installRun).toContain("elif command -v gtimeout >/dev/null 2>&1; then"); + expect(installRun).toContain('timeout_bin="gtimeout"'); expect(installRun).toContain( 'echo "timeout or gtimeout is required for OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=$npm_install_timeout" >&2', ); @@ -56,7 +56,9 @@ describe("package Telegram live Docker E2E", () => { '"$timeout_bin" "$npm_install_timeout" npm install -g "$install_source" --no-fund --no-audit', ); expect(installRun).toContain('npm install -g "$install_source" --no-fund --no-audit'); - expect(installRun).not.toContain("running package install without OPENCLAW_E2E_NPM_INSTALL_TIMEOUT"); + expect(installRun).not.toContain( + "running package install without OPENCLAW_E2E_NPM_INSTALL_TIMEOUT", + ); expect(installRun).toContain('"${package_mount_args[@]}"'); expect(installRun).not.toContain('"${docker_env[@]}"'); expect(installRun).toContain("run_logged docker_e2e_docker_run_cmd run --rm"); diff --git a/test/scripts/secret-provider-integrations.test.ts b/test/scripts/secret-provider-integrations.test.ts index 6f8484e6a3a3..f16ee50574d7 100644 --- a/test/scripts/secret-provider-integrations.test.ts +++ b/test/scripts/secret-provider-integrations.test.ts @@ -218,47 +218,44 @@ describe("secret provider integration proof harness", () => { } }); - it.runIf(process.platform !== "win32")( - "kills timed-out command process groups", - async () => { - const root = makeTempDir(); - const markerPath = path.join(root, "command-descendant-marker.txt"); - const scriptPath = path.join(root, "spawn-descendant.mjs"); - const descendantScript = [ - "import fs from 'node:fs';", - `fs.appendFileSync(${JSON.stringify(markerPath)}, "x");`, - "process.on('SIGTERM', () => {});", - `setInterval(() => fs.appendFileSync(${JSON.stringify(markerPath)}, "x"), 20);`, - ].join("\n"); - fs.writeFileSync( - scriptPath, - [ - "import childProcess from 'node:child_process';", - "import { setTimeout as delay } from 'node:timers/promises';", - `childProcess.spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify( - descendantScript, - )}], { stdio: "ignore" });`, - "process.on('SIGTERM', () => process.exit(0));", - "await delay(60_000);", - "", - ].join("\n"), - ); - const proof = await import(`${pathToFileURL(proofScriptPath).href}?case=timeout-${Date.now()}`); + it.runIf(process.platform !== "win32")("kills timed-out command process groups", async () => { + const root = makeTempDir(); + const markerPath = path.join(root, "command-descendant-marker.txt"); + const scriptPath = path.join(root, "spawn-descendant.mjs"); + const descendantScript = [ + "import fs from 'node:fs';", + `fs.appendFileSync(${JSON.stringify(markerPath)}, "x");`, + "process.on('SIGTERM', () => {});", + `setInterval(() => fs.appendFileSync(${JSON.stringify(markerPath)}, "x"), 20);`, + ].join("\n"); + fs.writeFileSync( + scriptPath, + [ + "import childProcess from 'node:child_process';", + "import { setTimeout as delay } from 'node:timers/promises';", + `childProcess.spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify( + descendantScript, + )}], { stdio: "ignore" });`, + "process.on('SIGTERM', () => process.exit(0));", + "await delay(60_000);", + "", + ].join("\n"), + ); + const proof = await import(`${pathToFileURL(proofScriptPath).href}?case=timeout-${Date.now()}`); - await expect( - proof.runCommand(process.execPath, [scriptPath], { - timeoutMs: 150, - }), - ).rejects.toThrow(/command timed out/u); + await expect( + proof.runCommand(process.execPath, [scriptPath], { + timeoutMs: 150, + }), + ).rejects.toThrow(/command timed out/u); - const sizeAfterReturn = fs.existsSync(markerPath) ? fs.statSync(markerPath).size : 0; - await new Promise((resolve) => { - setTimeout(resolve, 250); - }); - const sizeAfterWait = fs.existsSync(markerPath) ? fs.statSync(markerPath).size : 0; - expect(sizeAfterWait).toBe(sizeAfterReturn); - }, - ); + const sizeAfterReturn = fs.existsSync(markerPath) ? fs.statSync(markerPath).size : 0; + await new Promise((resolve) => { + setTimeout(resolve, 250); + }); + const sizeAfterWait = fs.existsSync(markerPath) ? fs.statSync(markerPath).size : 0; + expect(sizeAfterWait).toBe(sizeAfterReturn); + }); it("detects startup secret leaks after the retained output cap", () => { const root = makeTempDir(); diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 97426b1a7611..e8fa17ac69db 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -11,6 +11,27 @@ --workboard-control-border-hover: color-mix(in srgb, var(--accent) 36%, var(--border-strong)); } +.workboard-main { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; + min-height: 0; +} + +.workboard-sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + .workboard-toolbar { display: flex; justify-content: space-between; diff --git a/ui/src/ui/app-render-usage-tab.ts b/ui/src/ui/app-render-usage-tab.ts index e6ebedfc1125..861bd10ba255 100644 --- a/ui/src/ui/app-render-usage-tab.ts +++ b/ui/src/ui/app-render-usage-tab.ts @@ -49,293 +49,295 @@ export function renderUsageTab(state: AppViewState, usageView: LazyView renderUsage({ - data: { - loading: state.usageLoading, - error: state.usageError, - sessions: state.usageResult?.sessions ?? [], - agents: state.agentsList?.agents.map((entry) => entry.id).filter(Boolean) ?? [], - sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000, - totals: state.usageResult?.totals ?? null, - aggregates: state.usageResult?.aggregates ?? null, - costDaily: state.usageCostSummary?.daily ?? [], - cacheStatus: mergeUsageCacheStatus( - state.usageResult?.cacheStatus, - state.usageCostSummary?.cacheStatus, - ), - }, - filters: { - startDate: state.usageStartDate, - endDate: state.usageEndDate, - scope: state.usageScope, - selectedSessions: state.usageSelectedSessions, - selectedDays: state.usageSelectedDays, - selectedHours: state.usageSelectedHours, - agentId: state.usageAgentId, - query: state.usageQuery, - queryDraft: state.usageQueryDraft, - timeZone: state.usageTimeZone, - }, - display: { - chartMode: state.usageChartMode, - dailyChartMode: state.usageDailyChartMode, - sessionSort: state.usageSessionSort, - sessionSortDir: state.usageSessionSortDir, - recentSessions: state.usageRecentSessions, - sessionsTab: state.usageSessionsTab, - visibleColumns: state.usageVisibleColumns as UsageColumnId[], - contextExpanded: state.usageContextExpanded, - headerPinned: state.usageHeaderPinned, - }, - detail: { - timeSeriesMode: state.usageTimeSeriesMode, - timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode, - timeSeries: state.usageTimeSeries, - timeSeriesLoading: state.usageTimeSeriesLoading, - timeSeriesCursorStart: state.usageTimeSeriesCursorStart, - timeSeriesCursorEnd: state.usageTimeSeriesCursorEnd, - sessionLogs: state.usageSessionLogs, - sessionLogsLoading: state.usageSessionLogsLoading, - sessionLogsExpanded: state.usageSessionLogsExpanded, - logFilters: { - roles: state.usageLogFilterRoles, - tools: state.usageLogFilterTools, - hasTools: state.usageLogFilterHasTools, - query: state.usageLogFilterQuery, + return renderLazyView(usageView, ({ renderUsage }) => + renderUsage({ + data: { + loading: state.usageLoading, + error: state.usageError, + sessions: state.usageResult?.sessions ?? [], + agents: state.agentsList?.agents.map((entry) => entry.id).filter(Boolean) ?? [], + sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000, + totals: state.usageResult?.totals ?? null, + aggregates: state.usageResult?.aggregates ?? null, + costDaily: state.usageCostSummary?.daily ?? [], + cacheStatus: mergeUsageCacheStatus( + state.usageResult?.cacheStatus, + state.usageCostSummary?.cacheStatus, + ), }, - }, - callbacks: { filters: { - onStartDateChange: (date) => { - state.usageStartDate = date; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - debouncedLoadUsage(state); - }, - onEndDateChange: (date) => { - state.usageEndDate = date; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - debouncedLoadUsage(state); - }, - onScopeChange: (scope) => { - state.usageScope = scope; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - state.usageTimeSeries = null; - state.usageSessionLogs = null; - void loadUsage(state); - }, - onAgentChange: (agentId) => { - state.usageAgentId = agentId; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - state.usageTimeSeries = null; - state.usageSessionLogs = null; - void loadUsage(state); - }, - onRefresh: () => void loadUsage(state), - onTimeZoneChange: (zone) => { - state.usageTimeZone = zone; - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - void loadUsage(state); - }, - onToggleHeaderPinned: () => { - state.usageHeaderPinned = !state.usageHeaderPinned; - }, - onSelectHour: (hour, shiftKey) => { - if (shiftKey && state.usageSelectedHours.length > 0) { - const allHours = Array.from({ length: 24 }, (_, i) => i); - const lastSelected = state.usageSelectedHours[state.usageSelectedHours.length - 1]; - const lastIdx = allHours.indexOf(lastSelected); - const thisIdx = allHours.indexOf(hour); - if (lastIdx !== -1 && thisIdx !== -1) { - const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; - const range = allHours.slice(start, end + 1); - state.usageSelectedHours = [...new Set([...state.usageSelectedHours, ...range])]; - } - } else if (state.usageSelectedHours.includes(hour)) { - state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour); - } else { - state.usageSelectedHours = [...state.usageSelectedHours, hour]; - } - }, - onQueryDraftChange: (query) => { - state.usageQueryDraft = query; - if (state.usageQueryDebounceTimer) { - window.clearTimeout(state.usageQueryDebounceTimer); - } - state.usageQueryDebounceTimer = window.setTimeout(() => { - state.usageQuery = state.usageQueryDraft; - state.usageQueryDebounceTimer = null; - }, 250); - }, - onApplyQuery: () => { - if (state.usageQueryDebounceTimer) { - window.clearTimeout(state.usageQueryDebounceTimer); - state.usageQueryDebounceTimer = null; - } - state.usageQuery = state.usageQueryDraft; - }, - onClearQuery: () => { - if (state.usageQueryDebounceTimer) { - window.clearTimeout(state.usageQueryDebounceTimer); - state.usageQueryDebounceTimer = null; - } - state.usageQueryDraft = ""; - state.usageQuery = ""; - }, - onSelectDay: (day, shiftKey) => { - if (shiftKey && state.usageSelectedDays.length > 0) { - // Shift-click: select range from last selected to this day - const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date); - const lastSelected = state.usageSelectedDays[state.usageSelectedDays.length - 1]; - const lastIdx = allDays.indexOf(lastSelected); - const thisIdx = allDays.indexOf(day); - if (lastIdx !== -1 && thisIdx !== -1) { - const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; - const range = allDays.slice(start, end + 1); - state.usageSelectedDays = [...new Set([...state.usageSelectedDays, ...range])]; - } - } else if (state.usageSelectedDays.includes(day)) { - state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day); - } else { - state.usageSelectedDays = [day]; - } - }, - onClearDays: () => { - state.usageSelectedDays = []; - }, - onClearHours: () => { - state.usageSelectedHours = []; - }, - onClearSessions: () => { - state.usageSelectedSessions = []; - state.usageTimeSeries = null; - state.usageSessionLogs = null; - }, - onClearFilters: () => { - state.usageSelectedDays = []; - state.usageSelectedHours = []; - state.usageSelectedSessions = []; - state.usageTimeSeries = null; - state.usageSessionLogs = null; - }, + startDate: state.usageStartDate, + endDate: state.usageEndDate, + scope: state.usageScope, + selectedSessions: state.usageSelectedSessions, + selectedDays: state.usageSelectedDays, + selectedHours: state.usageSelectedHours, + agentId: state.usageAgentId, + query: state.usageQuery, + queryDraft: state.usageQueryDraft, + timeZone: state.usageTimeZone, }, display: { - onChartModeChange: (mode) => { - state.usageChartMode = mode; - }, - onDailyChartModeChange: (mode) => { - state.usageDailyChartMode = mode; - }, - onSessionSortChange: (sort) => { - state.usageSessionSort = sort; - }, - onSessionSortDirChange: (dir) => { - state.usageSessionSortDir = dir; - }, - onSessionsTabChange: (tab) => { - state.usageSessionsTab = tab; - }, - onToggleColumn: (column) => { - if (state.usageVisibleColumns.includes(column)) { - state.usageVisibleColumns = state.usageVisibleColumns.filter( - (entry) => entry !== column, - ); - } else { - state.usageVisibleColumns = [...state.usageVisibleColumns, column]; - } + chartMode: state.usageChartMode, + dailyChartMode: state.usageDailyChartMode, + sessionSort: state.usageSessionSort, + sessionSortDir: state.usageSessionSortDir, + recentSessions: state.usageRecentSessions, + sessionsTab: state.usageSessionsTab, + visibleColumns: state.usageVisibleColumns as UsageColumnId[], + contextExpanded: state.usageContextExpanded, + headerPinned: state.usageHeaderPinned, + }, + detail: { + timeSeriesMode: state.usageTimeSeriesMode, + timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode, + timeSeries: state.usageTimeSeries, + timeSeriesLoading: state.usageTimeSeriesLoading, + timeSeriesCursorStart: state.usageTimeSeriesCursorStart, + timeSeriesCursorEnd: state.usageTimeSeriesCursorEnd, + sessionLogs: state.usageSessionLogs, + sessionLogsLoading: state.usageSessionLogsLoading, + sessionLogsExpanded: state.usageSessionLogsExpanded, + logFilters: { + roles: state.usageLogFilterRoles, + tools: state.usageLogFilterTools, + hasTools: state.usageLogFilterHasTools, + query: state.usageLogFilterQuery, }, }, - details: { - onToggleContextExpanded: () => { - state.usageContextExpanded = !state.usageContextExpanded; - }, - onToggleSessionLogsExpanded: () => { - state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded; - }, - onLogFilterRolesChange: (next) => { - state.usageLogFilterRoles = next; - }, - onLogFilterToolsChange: (next) => { - state.usageLogFilterTools = next; - }, - onLogFilterHasToolsChange: (next) => { - state.usageLogFilterHasTools = next; - }, - onLogFilterQueryChange: (next) => { - state.usageLogFilterQuery = next; - }, - onLogFilterClear: () => { - state.usageLogFilterRoles = []; - state.usageLogFilterTools = []; - state.usageLogFilterHasTools = false; - state.usageLogFilterQuery = ""; - }, - onSelectSession: (key, shiftKey) => { - state.usageTimeSeries = null; - state.usageSessionLogs = null; - state.usageRecentSessions = [ - key, - ...state.usageRecentSessions.filter((entry) => entry !== key), - ].slice(0, 8); - - if (shiftKey && state.usageSelectedSessions.length > 0) { - // Shift-click: select range from last selected to this session - // Sort sessions same way as displayed (by tokens or cost descending) - const isTokenMode = state.usageChartMode === "tokens"; - const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted((a, b) => { - const valA = isTokenMode ? (a.usage?.totalTokens ?? 0) : (a.usage?.totalCost ?? 0); - const valB = isTokenMode ? (b.usage?.totalTokens ?? 0) : (b.usage?.totalCost ?? 0); - return valB - valA; - }); - const allKeys = sortedSessions.map((s) => s.key); - const lastSelected = - state.usageSelectedSessions[state.usageSelectedSessions.length - 1]; - const lastIdx = allKeys.indexOf(lastSelected); - const thisIdx = allKeys.indexOf(key); - if (lastIdx !== -1 && thisIdx !== -1) { - const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; - const range = allKeys.slice(start, end + 1); - state.usageSelectedSessions = [ - ...new Set([...state.usageSelectedSessions, ...range]), - ]; - } - } else if ( - state.usageSelectedSessions.length === 1 && - state.usageSelectedSessions[0] === key - ) { + callbacks: { + filters: { + onStartDateChange: (date) => { + state.usageStartDate = date; + state.usageSelectedDays = []; + state.usageSelectedHours = []; state.usageSelectedSessions = []; - } else { - state.usageSelectedSessions = [key]; - } + debouncedLoadUsage(state); + }, + onEndDateChange: (date) => { + state.usageEndDate = date; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + debouncedLoadUsage(state); + }, + onScopeChange: (scope) => { + state.usageScope = scope; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + void loadUsage(state); + }, + onAgentChange: (agentId) => { + state.usageAgentId = agentId; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + void loadUsage(state); + }, + onRefresh: () => void loadUsage(state), + onTimeZoneChange: (zone) => { + state.usageTimeZone = zone; + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + void loadUsage(state); + }, + onToggleHeaderPinned: () => { + state.usageHeaderPinned = !state.usageHeaderPinned; + }, + onSelectHour: (hour, shiftKey) => { + if (shiftKey && state.usageSelectedHours.length > 0) { + const allHours = Array.from({ length: 24 }, (_, i) => i); + const lastSelected = state.usageSelectedHours[state.usageSelectedHours.length - 1]; + const lastIdx = allHours.indexOf(lastSelected); + const thisIdx = allHours.indexOf(hour); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allHours.slice(start, end + 1); + state.usageSelectedHours = [...new Set([...state.usageSelectedHours, ...range])]; + } + } else if (state.usageSelectedHours.includes(hour)) { + state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour); + } else { + state.usageSelectedHours = [...state.usageSelectedHours, hour]; + } + }, + onQueryDraftChange: (query) => { + state.usageQueryDraft = query; + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + } + state.usageQueryDebounceTimer = window.setTimeout(() => { + state.usageQuery = state.usageQueryDraft; + state.usageQueryDebounceTimer = null; + }, 250); + }, + onApplyQuery: () => { + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + state.usageQueryDebounceTimer = null; + } + state.usageQuery = state.usageQueryDraft; + }, + onClearQuery: () => { + if (state.usageQueryDebounceTimer) { + window.clearTimeout(state.usageQueryDebounceTimer); + state.usageQueryDebounceTimer = null; + } + state.usageQueryDraft = ""; + state.usageQuery = ""; + }, + onSelectDay: (day, shiftKey) => { + if (shiftKey && state.usageSelectedDays.length > 0) { + // Shift-click: select range from last selected to this day + const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date); + const lastSelected = state.usageSelectedDays[state.usageSelectedDays.length - 1]; + const lastIdx = allDays.indexOf(lastSelected); + const thisIdx = allDays.indexOf(day); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allDays.slice(start, end + 1); + state.usageSelectedDays = [...new Set([...state.usageSelectedDays, ...range])]; + } + } else if (state.usageSelectedDays.includes(day)) { + state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day); + } else { + state.usageSelectedDays = [day]; + } + }, + onClearDays: () => { + state.usageSelectedDays = []; + }, + onClearHours: () => { + state.usageSelectedHours = []; + }, + onClearSessions: () => { + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + }, + onClearFilters: () => { + state.usageSelectedDays = []; + state.usageSelectedHours = []; + state.usageSelectedSessions = []; + state.usageTimeSeries = null; + state.usageSessionLogs = null; + }, + }, + display: { + onChartModeChange: (mode) => { + state.usageChartMode = mode; + }, + onDailyChartModeChange: (mode) => { + state.usageDailyChartMode = mode; + }, + onSessionSortChange: (sort) => { + state.usageSessionSort = sort; + }, + onSessionSortDirChange: (dir) => { + state.usageSessionSortDir = dir; + }, + onSessionsTabChange: (tab) => { + state.usageSessionsTab = tab; + }, + onToggleColumn: (column) => { + if (state.usageVisibleColumns.includes(column)) { + state.usageVisibleColumns = state.usageVisibleColumns.filter( + (entry) => entry !== column, + ); + } else { + state.usageVisibleColumns = [...state.usageVisibleColumns, column]; + } + }, + }, + details: { + onToggleContextExpanded: () => { + state.usageContextExpanded = !state.usageContextExpanded; + }, + onToggleSessionLogsExpanded: () => { + state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded; + }, + onLogFilterRolesChange: (next) => { + state.usageLogFilterRoles = next; + }, + onLogFilterToolsChange: (next) => { + state.usageLogFilterTools = next; + }, + onLogFilterHasToolsChange: (next) => { + state.usageLogFilterHasTools = next; + }, + onLogFilterQueryChange: (next) => { + state.usageLogFilterQuery = next; + }, + onLogFilterClear: () => { + state.usageLogFilterRoles = []; + state.usageLogFilterTools = []; + state.usageLogFilterHasTools = false; + state.usageLogFilterQuery = ""; + }, + onSelectSession: (key, shiftKey) => { + state.usageTimeSeries = null; + state.usageSessionLogs = null; + state.usageRecentSessions = [ + key, + ...state.usageRecentSessions.filter((entry) => entry !== key), + ].slice(0, 8); - state.usageTimeSeriesCursorStart = null; - state.usageTimeSeriesCursorEnd = null; + if (shiftKey && state.usageSelectedSessions.length > 0) { + // Shift-click: select range from last selected to this session + // Sort sessions same way as displayed (by tokens or cost descending) + const isTokenMode = state.usageChartMode === "tokens"; + const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted((a, b) => { + const valA = isTokenMode ? (a.usage?.totalTokens ?? 0) : (a.usage?.totalCost ?? 0); + const valB = isTokenMode ? (b.usage?.totalTokens ?? 0) : (b.usage?.totalCost ?? 0); + return valB - valA; + }); + const allKeys = sortedSessions.map((s) => s.key); + const lastSelected = + state.usageSelectedSessions[state.usageSelectedSessions.length - 1]; + const lastIdx = allKeys.indexOf(lastSelected); + const thisIdx = allKeys.indexOf(key); + if (lastIdx !== -1 && thisIdx !== -1) { + const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx]; + const range = allKeys.slice(start, end + 1); + state.usageSelectedSessions = [ + ...new Set([...state.usageSelectedSessions, ...range]), + ]; + } + } else if ( + state.usageSelectedSessions.length === 1 && + state.usageSelectedSessions[0] === key + ) { + state.usageSelectedSessions = []; + } else { + state.usageSelectedSessions = [key]; + } - if (state.usageSelectedSessions.length === 1) { - void loadSessionTimeSeries(state, state.usageSelectedSessions[0]); - void loadSessionLogs(state, state.usageSelectedSessions[0]); - } - }, - onTimeSeriesModeChange: (mode) => { - state.usageTimeSeriesMode = mode; - }, - onTimeSeriesBreakdownChange: (mode) => { - state.usageTimeSeriesBreakdownMode = mode; - }, - onTimeSeriesCursorRangeChange: (start, end) => { - state.usageTimeSeriesCursorStart = start; - state.usageTimeSeriesCursorEnd = end; + state.usageTimeSeriesCursorStart = null; + state.usageTimeSeriesCursorEnd = null; + + if (state.usageSelectedSessions.length === 1) { + void loadSessionTimeSeries(state, state.usageSelectedSessions[0]); + void loadSessionLogs(state, state.usageSelectedSessions[0]); + } + }, + onTimeSeriesModeChange: (mode) => { + state.usageTimeSeriesMode = mode; + }, + onTimeSeriesBreakdownChange: (mode) => { + state.usageTimeSeriesBreakdownMode = mode; + }, + onTimeSeriesCursorRangeChange: (start, end) => { + state.usageTimeSeriesCursorStart = start; + state.usageTimeSeriesCursorEnd = end; + }, }, }, - }, - })); + }), + ); } diff --git a/ui/src/ui/views/workboard.browser.test.ts b/ui/src/ui/views/workboard.browser.test.ts new file mode 100644 index 000000000000..4fd7585eba63 --- /dev/null +++ b/ui/src/ui/views/workboard.browser.test.ts @@ -0,0 +1,101 @@ +import { nothing, render } from "lit"; +import { describe, expect, it } from "vitest"; +import { getWorkboardState } from "../controllers/workboard.ts"; +import { renderWorkboard } from "./workboard.ts"; + +type WorkboardRenderProps = Parameters[0]; + +function nextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function renderInto(container: HTMLElement, props: WorkboardRenderProps) { + render(renderWorkboard(props), container); +} + +function dispatchKey(target: EventTarget, key: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); + target.dispatchEvent(event); + return event; +} + +describe("workboard dialogs (browser)", () => { + it("keeps modal focus inside Chromium inert background and restores the opener", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = []; + const container = document.createElement("div"); + document.body.append(container); + const props: WorkboardRenderProps = { + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + onRequestUpdate: () => renderInto(container, props), + }; + + try { + renderInto(container, props); + const launcher = container.querySelector( + ".workboard-toolbar__actions .primary", + ); + const backgroundSearch = container.querySelector( + ".workboard-toolbar__filters input[type='search']", + ); + expect(launcher).toBeInstanceOf(HTMLButtonElement); + expect(backgroundSearch).toBeInstanceOf(HTMLInputElement); + + launcher?.focus(); + launcher?.click(); + await nextFrame(); + + const modal = container.querySelector(".workboard-draft"); + const titleInput = container.querySelector(".workboard-draft__title"); + const main = container.querySelector(".workboard-main"); + expect(modal?.getAttribute("role")).toBe("dialog"); + expect(modal?.getAttribute("aria-modal")).toBe("true"); + expect(modal?.getAttribute("aria-labelledby")).toBe("workboard-card-modal-title"); + expect(modal?.getAttribute("aria-describedby")).toBe("workboard-card-modal-description"); + expect(document.activeElement).toBe(titleInput); + expect(main?.hasAttribute("inert")).toBe(true); + expect(main?.getAttribute("aria-hidden")).toBe("true"); + + backgroundSearch?.focus(); + if (navigator.userAgent.includes("Chrome") || navigator.webdriver) { + expect(document.activeElement).toBe(titleInput); + } else { + expect(document.activeElement).toBe(backgroundSearch); + titleInput?.focus(); + } + + const close = modal!.querySelector("button[aria-label='Cancel']"); + const cancel = [...modal!.querySelectorAll("button")].at(-1); + cancel?.focus(); + const tab = dispatchKey(cancel!, "Tab"); + expect(tab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(close); + + dispatchKey(titleInput!, "Escape"); + await nextFrame(); + await nextFrame(); + + expect(container.querySelector(".workboard-draft")).toBeNull(); + expect(main?.hasAttribute("inert")).toBe(false); + expect(document.activeElement).toBe(launcher); + } finally { + render(nothing, container); + container.remove(); + } + }); +}); diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index 239cc305bd73..272a41cbe18d 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -1,4 +1,4 @@ -import { render } from "lit"; +import { nothing, render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { getWorkboardState } from "../controllers/workboard.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; @@ -6,6 +6,27 @@ import { renderWorkboard } from "./workboard.ts"; type WorkboardRenderProps = Parameters[0]; +function nextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function renderInto(container: HTMLElement, props: WorkboardRenderProps) { + render(renderWorkboard(props), container); +} + +function dispatchKey(target: EventTarget, key: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); + target.dispatchEvent(event); + return event; +} + describe("renderWorkboard", () => { it("renders board columns and preloaded cards", () => { const now = Date.now(); @@ -172,6 +193,251 @@ describe("renderWorkboard", () => { expect(onOpenSession).not.toHaveBeenCalled(); }); + it("keeps focus inside the card modal and restores focus on Escape", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = []; + const container = document.createElement("div"); + document.body.append(container); + const props: WorkboardRenderProps = { + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + onRequestUpdate: () => renderInto(container, props), + }; + + try { + renderInto(container, props); + const launcher = container.querySelector( + ".workboard-toolbar__actions .primary", + ); + expect(launcher).toBeInstanceOf(HTMLButtonElement); + launcher?.focus(); + launcher?.click(); + await nextFrame(); + + const modal = container.querySelector(".workboard-draft"); + const titleInput = container.querySelector(".workboard-draft__title"); + const main = container.querySelector(".workboard-main"); + expect(modal?.getAttribute("role")).toBe("dialog"); + expect(modal?.getAttribute("aria-modal")).toBe("true"); + expect(modal?.getAttribute("aria-labelledby")).toBe("workboard-card-modal-title"); + expect(modal?.getAttribute("aria-describedby")).toBe("workboard-card-modal-description"); + expect(container.querySelector("#workboard-card-modal-title")?.textContent).toContain( + "New card", + ); + expect(container.querySelector("#workboard-card-modal-description")?.textContent).toContain( + "Queue work", + ); + expect(document.activeElement).toBe(titleInput); + expect(main?.hasAttribute("inert")).toBe(true); + expect(main?.getAttribute("aria-hidden")).toBe("true"); + + const cancel = [...modal!.querySelectorAll("button")].at(-1); + const close = modal!.querySelector("button[aria-label='Cancel']"); + expect(cancel).toBeInstanceOf(HTMLButtonElement); + expect(close).toBeInstanceOf(HTMLButtonElement); + cancel?.focus(); + const tab = dispatchKey(cancel!, "Tab"); + expect(tab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(close); + + const shiftTab = dispatchKey(close!, "Tab", { shiftKey: true }); + expect(shiftTab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(cancel); + + dispatchKey(titleInput!, "Escape"); + await nextFrame(); + expect(container.querySelector(".workboard-draft")).toBeNull(); + expect(main?.hasAttribute("inert")).toBe(false); + expect(document.activeElement).toBe(launcher); + } finally { + render(nothing, container); + container.remove(); + } + }); + + it("treats the detail drawer as a labelled keyboard-modal dialog", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Inspect drawer focus", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + }, + ]; + const container = document.createElement("div"); + document.body.append(container); + const props: WorkboardRenderProps = { + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + onRequestUpdate: () => renderInto(container, props), + }; + + try { + renderInto(container, props); + const launcher = container.querySelector( + "button[aria-label='View details']", + ); + expect(launcher).toBeInstanceOf(HTMLButtonElement); + launcher?.focus(); + launcher?.click(); + await nextFrame(); + + const drawer = container.querySelector(".workboard-detail-drawer"); + const main = container.querySelector(".workboard-main"); + expect(drawer?.getAttribute("role")).toBe("dialog"); + expect(drawer?.getAttribute("aria-modal")).toBe("true"); + expect(drawer?.getAttribute("aria-labelledby")).toBe("workboard-card-detail-title"); + expect(drawer?.getAttribute("aria-describedby")).toBe("workboard-card-detail-description"); + expect(container.querySelector("#workboard-card-detail-title")?.textContent).toContain( + "Card details: Inspect drawer focus", + ); + expect(container.querySelector("#workboard-card-detail-description")?.textContent).toContain( + "Start or link a session", + ); + expect(document.activeElement).toBe(drawer); + expect(main?.hasAttribute("inert")).toBe(true); + expect(main?.getAttribute("aria-hidden")).toBe("true"); + + const close = drawer!.querySelector("button[aria-label='Cancel']"); + const tab = dispatchKey(drawer!, "Tab"); + expect(tab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(close); + + const lastFocusable = [ + ...drawer!.querySelectorAll("button, input, select, textarea, a[href]"), + ] + .toReversed() + .find((element) => !element.hasAttribute("disabled")); + const shiftTab = dispatchKey(close!, "Tab", { shiftKey: true }); + expect(shiftTab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(lastFocusable); + + dispatchKey(drawer!, "Escape"); + await nextFrame(); + expect(container.querySelector(".workboard-detail-drawer")).toBeNull(); + expect(main?.hasAttribute("inert")).toBe(false); + expect(document.activeElement).toBe(launcher); + } finally { + render(nothing, container); + container.remove(); + } + }); + + it("does not restore focus to a disconnected modal opener", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = []; + const container = document.createElement("div"); + document.body.append(container); + const props: WorkboardRenderProps = { + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + onRequestUpdate: () => renderInto(container, props), + }; + + try { + renderInto(container, props); + const launcher = container.querySelector( + ".workboard-toolbar__actions .primary", + ); + launcher?.focus(); + launcher?.click(); + await nextFrame(); + + const titleInput = container.querySelector(".workboard-draft__title"); + expect(document.activeElement).toBe(titleInput); + + launcher?.remove(); + dispatchKey(titleInput!, "Escape"); + await nextFrame(); + + expect(container.querySelector(".workboard-draft")).toBeNull(); + expect(document.activeElement).not.toBe(launcher); + } finally { + render(nothing, container); + container.remove(); + } + }); + + it("restores focus when the detail drawer is removed by state", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Remove drawer externally", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + }, + ]; + const container = document.createElement("div"); + document.body.append(container); + const props: WorkboardRenderProps = { + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + onRequestUpdate: () => renderInto(container, props), + }; + + try { + renderInto(container, props); + const launcher = container.querySelector( + "button[aria-label='View details']", + ); + launcher?.focus(); + launcher?.click(); + await nextFrame(); + + expect(document.activeElement).toBe( + container.querySelector(".workboard-detail-drawer"), + ); + + state.detailCardId = null; + renderInto(container, props); + await nextFrame(); + + expect(container.querySelector(".workboard-detail-drawer")).toBeNull(); + expect(document.activeElement).toBe(launcher); + } finally { + render(nothing, container); + container.remove(); + } + }); + it("keeps cards compact and puts model-specific execution actions in details", () => { const host = {}; const state = getWorkboardState(host); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index 8d9e7648dd12..1a7aa6a9aaac 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -1,4 +1,5 @@ import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; import { t } from "../../i18n/index.ts"; import { addWorkboardCardComment, @@ -48,6 +49,26 @@ type WorkboardProps = { onRequestUpdate?: () => void; }; +const workboardCardModalTitleId = "workboard-card-modal-title"; +const workboardCardModalDescriptionId = "workboard-card-modal-description"; +const workboardCardModalId = "workboard-card-modal"; +const workboardCardDetailDrawerId = "workboard-card-detail-drawer"; +const workboardCardDetailTitleId = "workboard-card-detail-title"; +const workboardCardDetailDescriptionId = "workboard-card-detail-description"; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "summary", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +let activeWorkboardDialog: HTMLElement | null = null; +let workboardReturnFocusTarget: Element | null = null; + const WORKBOARD_TEMPLATES: Array<{ id: WorkboardTemplateId; title: string; @@ -114,6 +135,138 @@ function canMutate(props: WorkboardProps): boolean { return props.canWrite !== false; } +function rememberWorkboardReturnFocus(target: EventTarget | Element | null | undefined) { + if (target instanceof Element) { + workboardReturnFocusTarget = target; + return; + } + if (!workboardReturnFocusTarget) { + workboardReturnFocusTarget = document.activeElement; + } +} + +function restoreWorkboardFocus() { + const target = workboardReturnFocusTarget; + workboardReturnFocusTarget = null; + activeWorkboardDialog = null; + if (!(target instanceof HTMLElement) || !target.isConnected) { + return; + } + requestAnimationFrame(() => { + if (target.isConnected) { + target.focus(); + } + }); +} + +function focusElement(element: HTMLElement) { + try { + element.focus({ preventScroll: true }); + } catch { + element.focus(); + } +} + +function isFocusableWorkboardElement(element: HTMLElement): boolean { + if (!element.isConnected || element.tabIndex < 0) { + return false; + } + return !element.closest("[hidden], [inert]"); +} + +function getFocusableWorkboardElements(root: HTMLElement): HTMLElement[] { + return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter( + isFocusableWorkboardElement, + ); +} + +function focusWorkboardDialog(root: HTMLElement, initialFocusSelector?: string) { + requestAnimationFrame(() => { + if (!root.isConnected || activeWorkboardDialog !== root) { + return; + } + const active = document.activeElement; + if (active instanceof Element && root.contains(active)) { + return; + } + const preferred = initialFocusSelector + ? root.querySelector(initialFocusSelector) + : null; + const target = + preferred && isFocusableWorkboardElement(preferred) + ? preferred + : initialFocusSelector + ? getFocusableWorkboardElements(root)[0] + : root; + focusElement(target); + }); +} + +function syncWorkboardDialog(element: Element | undefined, initialFocusSelector?: string) { + if (!(element instanceof HTMLElement)) { + const previousDialog = activeWorkboardDialog; + if (!previousDialog) { + return; + } + if (!previousDialog.isConnected) { + restoreWorkboardFocus(); + return; + } + queueMicrotask(() => { + if (activeWorkboardDialog === previousDialog && !previousDialog.isConnected) { + restoreWorkboardFocus(); + } + }); + return; + } + if (activeWorkboardDialog !== element) { + rememberWorkboardReturnFocus(null); + activeWorkboardDialog = element; + } + focusWorkboardDialog(element, initialFocusSelector); +} + +function trapWorkboardDialogFocus(event: KeyboardEvent, root: HTMLElement) { + const focusable = getFocusableWorkboardElements(root); + if (focusable.length === 0) { + event.preventDefault(); + focusElement(root); + return; + } + + const active = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const focusInside = active ? root.contains(active) : false; + + if (event.shiftKey && (!focusInside || active === first || active === root)) { + event.preventDefault(); + focusElement(last); + return; + } + if (!event.shiftKey && (!focusInside || active === last || active === root)) { + event.preventDefault(); + focusElement(first); + } +} + +function handleWorkboardDialogKeydown( + event: KeyboardEvent, + props: WorkboardProps, + close: () => void, +) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close(); + props.onRequestUpdate?.(); + return; + } + if (event.key === "Tab") { + trapWorkboardDialogFocus(event, event.currentTarget as HTMLElement); + } +} + function formatEventLabel(event: WorkboardEvent): string { switch (event.kind) { case "created": @@ -512,6 +665,22 @@ function openCardDetails(state: WorkboardUiState, card: WorkboardCard) { state.detailCommentBody = ""; } +function closeCardDetails(state: WorkboardUiState) { + state.detailCardId = null; + state.detailCommentBody = ""; +} + +function getVisibleDetailCard(state: WorkboardUiState): WorkboardCard | null { + if (!state.detailCardId || state.draftOpen) { + return null; + } + const card = state.cards.find((entry) => entry.id === state.detailCardId) ?? null; + if (!card || (card.metadata?.archivedAt && !state.showArchived)) { + return null; + } + return card; +} + function resetDraft(state: WorkboardUiState) { state.draftOpen = false; state.editingCardId = null; @@ -582,10 +751,16 @@ function renderCardModal(props: WorkboardProps) { }} >