mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
101
ui/src/ui/views/workboard.browser.test.ts
Normal file
101
ui/src/ui/views/workboard.browser.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user