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 6557012430
This commit is contained in:
Val Alexander
2026-06-03 06:14:40 -05:00
committed by GitHub
parent b1fccd0605
commit 529282dcff
23 changed files with 1217 additions and 599 deletions

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -325,10 +325,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
);
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<LinuxOptions> {
);
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";
}

View File

@@ -357,11 +357,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
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<WindowsOptions> {
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<WindowsOptions> {
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";
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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();

View File

@@ -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";

View File

@@ -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}`);

View File

@@ -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(

View File

@@ -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"');

View File

@@ -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({

View File

@@ -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-"));

View File

@@ -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",
);
});
});

View File

@@ -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");

View File

@@ -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();

View File

@@ -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;

View File

@@ -49,293 +49,295 @@ export function renderUsageTab(state: AppViewState, usageView: LazyView<UsageVie
return nothing;
}
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,
),
},
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;
},
},
},
},
}));
}),
);
}

View File

@@ -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<typeof renderWorkboard>[0];
function nextFrame() {
return new Promise<void>((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<HTMLButtonElement>(
".workboard-toolbar__actions .primary",
);
const backgroundSearch = container.querySelector<HTMLInputElement>(
".workboard-toolbar__filters input[type='search']",
);
expect(launcher).toBeInstanceOf(HTMLButtonElement);
expect(backgroundSearch).toBeInstanceOf(HTMLInputElement);
launcher?.focus();
launcher?.click();
await nextFrame();
const modal = container.querySelector<HTMLElement>(".workboard-draft");
const titleInput = container.querySelector<HTMLInputElement>(".workboard-draft__title");
const main = container.querySelector<HTMLElement>(".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<HTMLButtonElement>("button[aria-label='Cancel']");
const cancel = [...modal!.querySelectorAll<HTMLButtonElement>("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();
}
});
});

View File

@@ -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<typeof renderWorkboard>[0];
function nextFrame() {
return new Promise<void>((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<HTMLButtonElement>(
".workboard-toolbar__actions .primary",
);
expect(launcher).toBeInstanceOf(HTMLButtonElement);
launcher?.focus();
launcher?.click();
await nextFrame();
const modal = container.querySelector<HTMLElement>(".workboard-draft");
const titleInput = container.querySelector<HTMLInputElement>(".workboard-draft__title");
const main = container.querySelector<HTMLElement>(".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<HTMLButtonElement>("button")].at(-1);
const close = modal!.querySelector<HTMLButtonElement>("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<HTMLButtonElement>(
"button[aria-label='View details']",
);
expect(launcher).toBeInstanceOf(HTMLButtonElement);
launcher?.focus();
launcher?.click();
await nextFrame();
const drawer = container.querySelector<HTMLElement>(".workboard-detail-drawer");
const main = container.querySelector<HTMLElement>(".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<HTMLButtonElement>("button[aria-label='Cancel']");
const tab = dispatchKey(drawer!, "Tab");
expect(tab.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(close);
const lastFocusable = [
...drawer!.querySelectorAll<HTMLElement>("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<HTMLButtonElement>(
".workboard-toolbar__actions .primary",
);
launcher?.focus();
launcher?.click();
await nextFrame();
const titleInput = container.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(
"button[aria-label='View details']",
);
launcher?.focus();
launcher?.click();
await nextFrame();
expect(document.activeElement).toBe(
container.querySelector<HTMLElement>(".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);

View File

@@ -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<HTMLElement>(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<HTMLElement>(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) {
}}
>
<form
id=${workboardCardModalId}
class="workboard-draft"
role="dialog"
aria-modal="true"
aria-labelledby="workboard-card-modal-title"
aria-labelledby=${workboardCardModalTitleId}
aria-describedby=${workboardCardModalDescriptionId}
tabindex="-1"
${ref((element) => syncWorkboardDialog(element, "[data-workboard-autofocus='true']"))}
@keydown=${(event: KeyboardEvent) =>
handleWorkboardDialogKeydown(event, props, () => resetDraft(state))}
@submit=${(event: SubmitEvent) => {
event.preventDefault();
if (state.loading || draftCommentBusy) {
@@ -600,15 +775,18 @@ function renderCardModal(props: WorkboardProps) {
>
<div class="workboard-modal__header">
<div>
<h2 id="workboard-card-modal-title">
<h2 id=${workboardCardModalTitleId}>
${editing ? t("workboard.editCard") : t("workboard.newCard")}
</h2>
<p>${editing ? t("workboard.editCardHelp") : t("workboard.newCardHelp")}</p>
<p id=${workboardCardModalDescriptionId}>
${editing ? t("workboard.editCardHelp") : t("workboard.newCardHelp")}
</p>
</div>
<button
class="btn btn--icon workboard-card__icon"
type="button"
title=${t("common.cancel")}
aria-label=${t("common.cancel")}
@click=${() => {
resetDraft(state);
props.onRequestUpdate?.();
@@ -644,6 +822,7 @@ function renderCardModal(props: WorkboardProps) {
<span>${t("workboard.fieldTitle")}</span>
<input
class="input workboard-draft__title"
data-workboard-autofocus="true"
placeholder=${t("workboard.titlePlaceholder")}
.value=${state.draftTitle}
@input=${(event: InputEvent) => {
@@ -1122,10 +1301,8 @@ function renderDetailList(
function renderCardDetailsPanel(props: WorkboardProps) {
const state = getWorkboardState(props.host);
const card = state.detailCardId
? (state.cards.find((entry) => entry.id === state.detailCardId) ?? null)
: null;
if (!card || (card.metadata?.archivedAt && !state.showArchived)) {
const card = getVisibleDetailCard(state);
if (!card) {
return nothing;
}
const task = state.tasksByCardId.get(card.id);
@@ -1149,20 +1326,33 @@ function renderCardDetailsPanel(props: WorkboardProps) {
const showStartControls = writable && cardCanStart(state, props.sessions, card);
const dependencies = getWorkboardDependencyState(card, state.cards);
return html`
<aside class="workboard-detail-drawer" aria-label=${t("workboard.detailTitle")}>
<aside
id=${workboardCardDetailDrawerId}
class="workboard-detail-drawer"
role="dialog"
aria-modal="true"
aria-labelledby=${workboardCardDetailTitleId}
aria-describedby=${workboardCardDetailDescriptionId}
tabindex="-1"
${ref((element) => syncWorkboardDialog(element))}
@keydown=${(event: KeyboardEvent) =>
handleWorkboardDialogKeydown(event, props, () => closeCardDetails(state))}
>
<div class="workboard-detail">
<header class="workboard-detail__header">
<div>
<span class="workboard-card__priority">${card.priority}</span>
<h2>${card.title}</h2>
<h2 id=${workboardCardDetailTitleId}>
<span class="workboard-sr-only">${t("workboard.detailTitle")}: </span>${card.title}
</h2>
</div>
<button
class="btn btn--icon workboard-card__icon"
type="button"
title=${t("common.cancel")}
aria-label=${t("common.cancel")}
@click=${() => {
state.detailCardId = null;
state.detailCommentBody = "";
closeCardDetails(state);
props.onRequestUpdate?.();
}}
>
@@ -1175,7 +1365,7 @@ function renderCardDetailsPanel(props: WorkboardProps) {
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
${formatted.label}
</span>
<span class="workboard-card__lifecycle-detail">
<span id=${workboardCardDetailDescriptionId} class="workboard-card__lifecycle-detail">
${task && taskIsAuthoritative
? taskDetail(task)
: (lifecycle.session?.displayName ?? formatted.detail)}
@@ -1401,9 +1591,13 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
role="button"
tabindex="0"
title=${t("workboard.viewDetails")}
aria-haspopup="dialog"
aria-expanded=${state.detailCardId === card.id ? "true" : "false"}
aria-controls=${workboardCardDetailDrawerId}
draggable=${writable ? "true" : "false"}
@click=${(event: MouseEvent) => {
if (!isCardActionTarget(event)) {
rememberWorkboardReturnFocus(event.currentTarget);
openCardDetails(state, card);
props.onRequestUpdate?.();
}
@@ -1412,6 +1606,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
if (isCardActionTarget(event) || (event.key !== "Enter" && event.key !== " ")) {
return;
}
rememberWorkboardReturnFocus(event.currentTarget);
openCardDetails(state, card);
props.onRequestUpdate?.();
event.preventDefault();
@@ -1452,7 +1647,9 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
type="button"
title=${t("workboard.editCard")}
aria-label=${t("workboard.editCard")}
@click=${() => {
aria-haspopup="dialog"
@click=${(event: MouseEvent) => {
rememberWorkboardReturnFocus(event.currentTarget);
openEditModal(state, card);
props.onRequestUpdate?.();
}}
@@ -1502,7 +1699,12 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
<button
class="btn btn--icon workboard-card__icon"
title=${t("workboard.viewDetails")}
@click=${() => {
aria-label=${t("workboard.viewDetails")}
aria-haspopup="dialog"
aria-expanded=${state.detailCardId === card.id ? "true" : "false"}
aria-controls=${workboardCardDetailDrawerId}
@click=${(event: MouseEvent) => {
rememberWorkboardReturnFocus(event.currentTarget);
openCardDetails(state, card);
props.onRequestUpdate?.();
}}
@@ -1668,148 +1870,160 @@ export function renderWorkboard(props: WorkboardProps) {
for (const card of filtered) {
byStatus.get(card.status)?.push(card);
}
const dialogOpen = state.draftOpen || Boolean(getVisibleDetailCard(state));
return html`
<section class="workboard">
<div class="workboard-toolbar">
<div class="workboard-toolbar__filters">
<input
class="input"
type="search"
title=${t("workboard.searchPlaceholder")}
placeholder=${t("workboard.searchPlaceholder")}
.value=${state.query}
@input=${(event: InputEvent) => {
state.query = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
<select
class="input"
title=${t("workboard.allPriorities")}
.value=${state.priorityFilter}
@change=${(event: Event) => {
state.priorityFilter = (event.currentTarget as HTMLSelectElement)
.value as WorkboardUiState["priorityFilter"];
props.onRequestUpdate?.();
}}
>
<option value="all">${t("workboard.allPriorities")}</option>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
)}
</select>
<select
class="input"
title=${t("workboard.agentFilter")}
.value=${state.agentFilter}
@change=${(event: Event) => {
state.agentFilter = (event.currentTarget as HTMLSelectElement).value;
props.onRequestUpdate?.();
}}
>
${agentOptions.map((agent) => html`<option value=${agent.id}>${agent.label}</option>`)}
</select>
<button
class="btn workboard-archive-toggle ${state.showArchived ? "active" : ""}"
type="button"
title=${state.showArchived ? t("workboard.hideArchived") : t("workboard.showArchived")}
aria-pressed=${state.showArchived}
@click=${() => {
state.showArchived = !state.showArchived;
props.onRequestUpdate?.();
}}
>
${state.showArchived ? icons.eye : icons.eyeOff}
${state.showArchived
? t("workboard.hideArchivedShort")
: t("workboard.showArchivedShort")}
</button>
<div class="workboard-layout-toggle" role="group" aria-label=${t("workboard.layout")}>
<button
class="btn btn--icon ${state.layout === "compact" ? "active" : ""}"
type="button"
title=${t("workboard.layoutCompact")}
aria-label=${t("workboard.layoutCompact")}
aria-pressed=${state.layout === "compact"}
@click=${() => {
state.layout = "compact";
<div class="workboard-main" ?inert=${dialogOpen} aria-hidden=${dialogOpen ? "true" : nothing}>
<div class="workboard-toolbar">
<div class="workboard-toolbar__filters">
<input
class="input"
type="search"
title=${t("workboard.searchPlaceholder")}
placeholder=${t("workboard.searchPlaceholder")}
.value=${state.query}
@input=${(event: InputEvent) => {
state.query = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
<select
class="input"
title=${t("workboard.allPriorities")}
.value=${state.priorityFilter}
@change=${(event: Event) => {
state.priorityFilter = (event.currentTarget as HTMLSelectElement)
.value as WorkboardUiState["priorityFilter"];
props.onRequestUpdate?.();
}}
>
${icons.layoutCompact}
</button>
<button
class="btn btn--icon ${state.layout === "comfortable" ? "active" : ""}"
type="button"
title=${t("workboard.layoutComfortable")}
aria-label=${t("workboard.layoutComfortable")}
aria-pressed=${state.layout === "comfortable"}
@click=${() => {
state.layout = "comfortable";
<option value="all">${t("workboard.allPriorities")}</option>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
)}
</select>
<select
class="input"
title=${t("workboard.agentFilter")}
.value=${state.agentFilter}
@change=${(event: Event) => {
state.agentFilter = (event.currentTarget as HTMLSelectElement).value;
props.onRequestUpdate?.();
}}
>
${icons.layoutComfortable}
${agentOptions.map(
(agent) => html`<option value=${agent.id}>${agent.label}</option>`,
)}
</select>
<button
class="btn workboard-archive-toggle ${state.showArchived ? "active" : ""}"
type="button"
title=${state.showArchived
? t("workboard.hideArchived")
: t("workboard.showArchived")}
aria-pressed=${state.showArchived}
@click=${() => {
state.showArchived = !state.showArchived;
props.onRequestUpdate?.();
}}
>
${state.showArchived ? icons.eye : icons.eyeOff}
${state.showArchived
? t("workboard.hideArchivedShort")
: t("workboard.showArchivedShort")}
</button>
<div class="workboard-layout-toggle" role="group" aria-label=${t("workboard.layout")}>
<button
class="btn btn--icon ${state.layout === "compact" ? "active" : ""}"
type="button"
title=${t("workboard.layoutCompact")}
aria-label=${t("workboard.layoutCompact")}
aria-pressed=${state.layout === "compact"}
@click=${() => {
state.layout = "compact";
props.onRequestUpdate?.();
}}
>
${icons.layoutCompact}
</button>
<button
class="btn btn--icon ${state.layout === "comfortable" ? "active" : ""}"
type="button"
title=${t("workboard.layoutComfortable")}
aria-label=${t("workboard.layoutComfortable")}
aria-pressed=${state.layout === "comfortable"}
@click=${() => {
state.layout = "comfortable";
props.onRequestUpdate?.();
}}
>
${icons.layoutComfortable}
</button>
</div>
</div>
<div class="workboard-toolbar__actions">
<button
class="btn"
type="button"
title=${t("common.refresh")}
?disabled=${state.loading}
@click=${() =>
loadWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
force: true,
})}
>
${state.loading ? t("common.refreshing") : t("common.refresh")}
</button>
${writable
? html`
<button
class="btn"
type="button"
title=${t("workboard.dispatch")}
?disabled=${state.loading}
@click=${() =>
dispatchWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.zap} ${t("workboard.dispatch")}
</button>
`
: nothing}
${writable
? html`
<button
class="btn primary"
type="button"
title=${t("workboard.newCard")}
aria-haspopup="dialog"
aria-expanded=${state.draftOpen ? "true" : "false"}
aria-controls=${workboardCardModalId}
@click=${(event: MouseEvent) => {
rememberWorkboardReturnFocus(event.currentTarget);
openCreateModal(state);
props.onRequestUpdate?.();
}}
>
${icons.plus} ${t("workboard.newCard")}
</button>
`
: nothing}
</div>
</div>
<div class="workboard-toolbar__actions">
<button
class="btn"
type="button"
title=${t("common.refresh")}
?disabled=${state.loading}
@click=${() =>
loadWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
force: true,
})}
>
${state.loading ? t("common.refreshing") : t("common.refresh")}
</button>
${writable
? html`
<button
class="btn"
type="button"
title=${t("workboard.dispatch")}
?disabled=${state.loading}
@click=${() =>
dispatchWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
})}
>
${icons.zap} ${t("workboard.dispatch")}
</button>
`
: nothing}
${writable
? html`
<button
class="btn primary"
type="button"
title=${t("workboard.newCard")}
@click=${() => {
openCreateModal(state);
props.onRequestUpdate?.();
}}
>
${icons.plus} ${t("workboard.newCard")}
</button>
`
: nothing}
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderDispatchSummary(state)}
<div class="workboard-board workboard-board--${state.layout}">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>
</div>
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderDispatchSummary(state)} ${renderCardModal(props)} ${renderCardDetailsPanel(props)}
<div class="workboard-board workboard-board--${state.layout}">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>
${renderCardModal(props)} ${renderCardDetailsPanel(props)}
</section>
`;
}

View File

@@ -9,6 +9,32 @@ import {
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const workspaceSourceAliases = [
{
find: /^@openclaw\/normalization-core\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/normalization-core/src/$1"),
},
{
find: "@openclaw/normalization-core",
replacement: path.resolve(repoRoot, "packages/normalization-core/src/index.ts"),
},
{
find: /^@openclaw\/media-core\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/media-core/src/$1"),
},
{
find: "@openclaw/media-core",
replacement: path.resolve(repoRoot, "packages/media-core/src/index.ts"),
},
{
find: /^@openclaw\/net-policy\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/net-policy/src/$1"),
},
{
find: "@openclaw/net-policy",
replacement: path.resolve(repoRoot, "packages/net-policy/src/index.ts"),
},
];
const sharedUiTestConfig = {
isolate: false,
pool: resolveDefaultVitestPool(),
@@ -21,21 +47,15 @@ const nodeDrivenBrowserLayoutTests = [
export default defineConfig({
resolve: {
alias: [
{
find: /^@openclaw\/normalization-core\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/normalization-core/src/$1"),
},
{
find: /^@openclaw\/media-core\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/media-core/src/$1"),
},
],
alias: workspaceSourceAliases,
},
test: {
...sharedUiTestConfig,
projects: [
defineProject({
resolve: {
alias: workspaceSourceAliases,
},
test: {
...sharedUiTestConfig,
deps: jsdomOptimizedDeps,
@@ -47,6 +67,9 @@ export default defineConfig({
},
}),
defineProject({
resolve: {
alias: workspaceSourceAliases,
},
test: {
...sharedUiTestConfig,
deps: jsdomOptimizedDeps,
@@ -57,6 +80,9 @@ export default defineConfig({
},
}),
defineProject({
resolve: {
alias: workspaceSourceAliases,
},
test: {
...sharedUiTestConfig,
name: "browser",