feat: refactor the Control UI architecture

Refactor the Control UI around route-owned page lifecycle and state while preserving existing behavior and design.

Prepared head SHA: bd51b6fa76
Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
Shakker
2026-07-04 23:19:38 +01:00
committed by GitHub
parent 832cb3c488
commit 65e12328aa
569 changed files with 40926 additions and 57760 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ const config = {
entry: [
"index.html!",
"src/main.ts!",
"src/ui/browser-redact.ts!",
"src/lib/browser-redact.ts!",
"vite.config.ts!",
"vitest*.ts!",
],
@@ -211,7 +211,7 @@ describe("qa coverage report", () => {
).toContainEqual({
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
path: "ui/src/e2e/chat-flow.e2e.test.ts",
role: "primary",
scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.yaml"],
});
@@ -286,7 +286,7 @@ describe("qa coverage report", () => {
expect(report).toContain(
"- browser-automation-and-exec-sandbox-tools.tool-invocation-and-execution (browser-automation-and-exec-sandbox-tools / Tool Invocation and Execution; partial): profiles: all, release, smoke-ci; coverage IDs:",
);
expect(report).toContain("primary:playwright:ui/src/ui/e2e/chat-flow.e2e.test.ts (ui.control)");
expect(report).toContain("primary:playwright:ui/src/e2e/chat-flow.e2e.test.ts (ui.control)");
expect(report).not.toContain("### Unknown Scenario Coverage IDs");
});
@@ -300,12 +300,12 @@ describe("qa coverage report", () => {
expect(report).toContain(
"- Suite command: `pnpm openclaw qa suite --scenario control-ui-chat-flow-playwright`",
);
expect(report).toContain(" - execution: playwright ui/src/ui/e2e/chat-flow.e2e.test.ts");
expect(report).toContain(" - execution: playwright ui/src/e2e/chat-flow.e2e.test.ts");
expect(report).not.toContain("Native test refs");
});
it("splits qa suite targets when matches mix execution kinds", () => {
const playwrightExecutionPath = "ui/src/ui/e2e/chat-flow.e2e.test.ts";
const playwrightExecutionPath = "ui/src/e2e/chat-flow.e2e.test.ts";
const flowScenario = scenarioWithCoverage({
primary: [TEST_EXECUTABLE_COVERAGE_ID],
});
@@ -389,7 +389,7 @@ describe("qa coverage report", () => {
primary: [TEST_BROWSER_COVERAGE_ID],
sourcePath: "qa/scenarios/ui/control-ui-chat-flow-playwright.yaml",
executionKind: "playwright",
executionPath: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
executionPath: "ui/src/e2e/chat-flow.e2e.test.ts",
}),
],
});
@@ -405,7 +405,7 @@ describe("qa coverage report", () => {
{
coverageId: TEST_BROWSER_COVERAGE_ID,
kind: "playwright",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
path: "ui/src/e2e/chat-flow.e2e.test.ts",
role: "primary",
scenarioRefs: ["qa/scenarios/ui/control-ui-chat-flow-playwright.yaml"],
},
+1 -1
View File
@@ -44,7 +44,7 @@ const scenarios = [
successCriteria: ["playwright pass"],
execution: {
kind: "playwright" as const,
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
path: "ui/src/e2e/chat-flow.e2e.test.ts",
},
},
];
@@ -221,7 +221,7 @@ describe("qa scenario catalog", () => {
if (scenario.execution.kind !== "playwright") {
throw new Error(`expected Playwright scenario, got ${scenario.execution.kind}`);
}
expect(scenario.execution.path).toBe("ui/src/ui/e2e/chat-flow.e2e.test.ts");
expect(scenario.execution.path).toBe("ui/src/e2e/chat-flow.e2e.test.ts");
expect(scenario.execution.flow).toBeUndefined();
expect(scenario.coverage?.primary).toContain("ui.control");
expect(uxMatrix.execution.kind).toBe("script");
+1 -1
View File
@@ -25,7 +25,7 @@ function makePlaywrightQaSuiteTestScenario(id: string): ReturnType<typeof makeQa
...makeQaSuiteTestScenario(id),
execution: {
kind: "playwright",
path: `ui/src/ui/e2e/${id}.e2e.test.ts`,
path: `ui/src/e2e/${id}.e2e.test.ts`,
},
};
}
@@ -163,7 +163,7 @@ describe("qa test file scenario runner", () => {
outputDir: path.join(repoRoot, ".artifacts", "qa-e2e", "scenario-playwright"),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [makeTestFileScenario("playwright", "ui/src/ui/e2e/chat-flow.e2e.test.ts")],
scenarios: [makeTestFileScenario("playwright", "ui/src/e2e/chat-flow.e2e.test.ts")],
runCommand: async (command) => {
commands.push(command);
return {
@@ -187,7 +187,7 @@ describe("qa test file scenario runner", () => {
"test/vitest/vitest.ui-e2e.config.ts",
"--configLoader",
"runner",
"ui/src/ui/e2e/chat-flow.e2e.test.ts",
"ui/src/e2e/chat-flow.e2e.test.ts",
"--reporter=verbose",
],
]);
@@ -202,7 +202,7 @@ describe("qa test file scenario runner", () => {
kind: "playwright-test",
id: "scenario-playwright",
source: {
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
path: "ui/src/e2e/chat-flow.e2e.test.ts",
},
},
coverage: [
@@ -222,7 +222,7 @@ describe("qa test file scenario runner", () => {
},
{
kind: "code",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
path: "ui/src/e2e/chat-flow.e2e.test.ts",
},
],
execution: {
@@ -248,7 +248,7 @@ describe("qa test file scenario runner", () => {
outputDir: path.join(repoRoot, ".artifacts", "qa-e2e", "scenario-playwright"),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [makeTestFileScenario("playwright", "ui/src/ui/e2e/chat-flow.e2e.test.ts")],
scenarios: [makeTestFileScenario("playwright", "ui/src/e2e/chat-flow.e2e.test.ts")],
writeEvidenceFile: false,
runCommand: async () => ({
exitCode: 0,
+9
View File
@@ -1942,6 +1942,9 @@ importers:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../packages/normalization-core
'@openclaw/uirouter':
specifier: 0.1.0
version: 0.1.0
dompurify:
specifier: 3.4.11
version: 3.4.11
@@ -3149,6 +3152,10 @@ packages:
peerDependencies:
undici: '>=8.3.0 <9'
'@openclaw/uirouter@0.1.0':
resolution: {integrity: sha512-w5tNj2FIukVJqJ1wt5wiDbrI6DI4tOkUbtqnnU5Fl4EgnRKFJtfHI3WrWTWTTJlXbbrwGSMptyrSZmQVdRo83Q==}
engines: {node: ^22.18.0 || >=24.11.0}
'@opentelemetry/api-logs@0.219.0':
resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==}
engines: {node: '>=8.0.0'}
@@ -8910,6 +8917,8 @@ snapshots:
dependencies:
undici: 8.5.0
'@openclaw/uirouter@0.1.0': {}
'@opentelemetry/api-logs@0.219.0':
dependencies:
'@opentelemetry/api': 1.9.1
+1
View File
@@ -10,6 +10,7 @@ minimumReleaseAgeExclude:
- "@openclaw/crabline@0.1.9"
- "@openclaw/fs-safe@0.4.1"
- "@openclaw/proxyline@0.3.3"
- "@openclaw/uirouter@0.1.0"
- "acpx"
- "tokenjuice"
- "@agentclientprotocol/sdk"
+3 -2
View File
@@ -18,9 +18,10 @@ scenario:
- docs/web/control-ui.md
- docs/help/testing.md
codeRefs:
- ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts
- ui/src/e2e/browser-talk-start-stop.e2e.test.ts
- ui/src/test-helpers/control-ui-e2e.ts
- ui/src/pages/chat/realtime-talk-google-live.test.ts
execution:
kind: playwright
path: ui/src/ui/e2e/browser-talk-start-stop.e2e.test.ts
path: ui/src/e2e/browser-talk-start-stop.e2e.test.ts
summary: Playwright coverage for browser realtime Talk start and stop through the Control UI.
@@ -12,8 +12,8 @@ scenario:
docsRefs:
- docs/web/control-ui.md
codeRefs:
- ui/src/ui/e2e/chat-flow.e2e.test.ts
- ui/src/e2e/chat-flow.e2e.test.ts
execution:
kind: playwright
path: ui/src/ui/e2e/chat-flow.e2e.test.ts
path: ui/src/e2e/chat-flow.e2e.test.ts
summary: Playwright coverage for the Control UI chat flow.
+1 -1
View File
@@ -86,7 +86,7 @@ export async function main() {
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error("Use openExternalUrlSafe(...) from ui/src/ui/open-external-url.ts instead.");
console.error("Use openExternalUrlSafe(...) from ui/src/lib/open-external-url.ts instead.");
process.exit(1);
}
+4 -2
View File
@@ -65,7 +65,9 @@ fi
path_exists_or_tracked() {
local candidate=$1
[ -e "$candidate" ] || git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1
[ -e "$candidate" ] ||
git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1 ||
git cat-file -e "HEAD:$candidate" >/dev/null 2>&1
}
append_normalized_file_arg() {
@@ -206,7 +208,7 @@ for file in "${files[@]}"; do
done
run_git_with_lock_retry "unstaging files" git restore --staged :/
run_git_with_lock_retry "staging files" git add --force -- "${files[@]}"
run_git_with_lock_retry "staging files" git add --all --force -- "${files[@]}"
if git diff --staged --quiet; then
printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2
+1 -1
View File
@@ -22,13 +22,13 @@ const LOCALE_LABELS: Record<string, string> = {
nl: "Dutch",
pl: "Polish",
"pt-BR": "Brazilian Portuguese",
ru: "Russian",
th: "Thai",
tr: "Turkish",
uk: "Ukrainian",
vi: "Vietnamese",
"zh-CN": "Simplified Chinese",
"zh-TW": "Traditional Chinese",
ru: "Russian",
};
const REPORT_LOCALES = new Set(Object.keys(LOCALE_LABELS));
const PATH_LABELS: Record<string, string> = {
+9 -2
View File
@@ -111,7 +111,12 @@ const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales");
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts");
const SOURCE_LOCALE = "en";
const CONTROL_UI_SOURCE_DIR = path.join(ROOT, "ui", "src", "ui");
const CONTROL_UI_RAW_COPY_SOURCE_DIRS = [
path.join(ROOT, "ui", "src", "app"),
path.join(ROOT, "ui", "src", "components"),
path.join(ROOT, "ui", "src", "lib"),
path.join(ROOT, "ui", "src", "pages"),
] as const;
const RAW_COPY_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "raw-copy-baseline.json");
const RAW_COPY_BASELINE_VERSION = 1;
const MAX_BATCH_ITEMS = 20;
@@ -809,7 +814,9 @@ function collectRawCopyFromSource(params: {
}
async function collectControlUiRawCopyFindings(): Promise<RawCopyFinding[]> {
const files = await walkControlUiSourceFiles(CONTROL_UI_SOURCE_DIR);
const files = (
await Promise.all(CONTROL_UI_RAW_COPY_SOURCE_DIRS.map((dir) => walkControlUiSourceFiles(dir)))
).flat();
const findings: RawCopyFinding[] = [];
for (const filePath of files.toSorted((left, right) => left.localeCompare(right))) {
const source = await readFile(filePath, "utf8");
+2 -2
View File
@@ -338,7 +338,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
"export default function controlUiViteConfig() {\n return { server: { strictPort: true } };\n}\n",
],
[
"ui/src/ui/e2e/chat-flow.e2e.test.ts",
"ui/src/e2e/chat-flow.e2e.test.ts",
"it('keeps the session workspace useful while browsing files', async () => {\n await page.getByText('Project files').waitFor();\n});\n",
],
]);
@@ -510,7 +510,7 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
{
kind: "file",
name: "chat-flow.e2e.test.ts",
path: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
path: "ui/src/e2e/chat-flow.e2e.test.ts",
size: 24950,
updatedAtMs: baseTime - 25_000,
},
@@ -16,7 +16,6 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"extensions/diffs/src/viewer-client.ts",
"extensions/diffs/src/viewer-payload.ts",
"extensions/matrix/src/plugin-entry.runtime.js",
"ui/src/ui/browser-redact.ts",
"src/agents/subagent-registry.runtime.ts",
"src/auto-reply/reply/get-reply.test-loader.ts",
"src/cli/daemon-cli-compat.ts",
+2 -2
View File
@@ -47,8 +47,8 @@ const CORE_TEST_REQUIRED_PATHS = [
"ui/config/control-ui-chunking.ts",
"ui/src/i18n/lib/registry.ts",
"ui/src/i18n/lib/types.ts",
"ui/src/ui/app-settings.ts",
"ui/src/ui/gateway.ts",
"ui/src/app/settings.ts",
"ui/src/api/gateway.ts",
];
/**
+4 -11
View File
@@ -5,7 +5,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { isUiTestTarget, isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs";
import { isUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs";
import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs";
import { resolveLocalVitestEnv } from "./lib/vitest-local-scheduling.mjs";
import { spawnPnpmRunner } from "./pnpm-runner.mjs";
@@ -31,7 +31,6 @@ export const DEFAULT_EXTRA_LONG_RUNNING_VITEST_NO_OUTPUT_TIMEOUT_MS = 2_400_000;
const VITEST_NO_OUTPUT_TIMEOUT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS";
const VITEST_NO_OUTPUT_HEARTBEAT_ENV_KEY = "OPENCLAW_VITEST_NO_OUTPUT_HEARTBEAT_MS";
const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
const UNIT_UI_VITEST_CONFIG = "test/vitest/vitest.unit-ui.config.ts";
const TOOLING_DOCKER_VITEST_CONFIG = "test/vitest/vitest.tooling-docker.config.ts";
const TOOLING_VITEST_CONFIG = "test/vitest/vitest.tooling.config.ts";
const GATEWAY_CORE_VITEST_CONFIG = "test/vitest/vitest.gateway-core.config.ts";
@@ -744,16 +743,10 @@ export function resolveImplicitVitestArgs(argv, cwd = process.cwd()) {
if (testTargets.length > 0 && testTargets.every(isToolingTestTarget)) {
return withImplicitVitestConfig(argv, TOOLING_VITEST_CONFIG);
}
if (testTargets.length === 0 || !testTargets.every(isUnitUiTestTarget)) {
if (
testTargets.length > 0 &&
testTargets.every((target) => isUiTestTarget(target) && !isUnitUiTestTarget(target))
) {
return withImplicitVitestConfig(argv, UI_VITEST_CONFIG);
}
return argv;
if (testTargets.length > 0 && testTargets.every(isUiTestTarget)) {
return withImplicitVitestConfig(argv, UI_VITEST_CONFIG);
}
return withImplicitVitestConfig(argv, UNIT_UI_VITEST_CONFIG);
return argv;
}
function spawnVitestProcess({ pnpmArgs, spawnParams }) {
+5 -16
View File
@@ -42,7 +42,6 @@ import {
resolvePluginSdkLightIncludePattern,
} from "../test/vitest/vitest.plugin-sdk-paths.mjs";
import { fullSuiteVitestShards } from "../test/vitest/vitest.test-shards.mjs";
import { isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs";
import {
getUnitFastTestFiles,
resolveUnitFastTestIncludePattern,
@@ -158,7 +157,6 @@ const UNIT_FAST_FAKE_TIMERS_VITEST_CONFIG = "test/vitest/vitest.unit-fast-fake-t
const UNIT_SECURITY_VITEST_CONFIG = "test/vitest/vitest.unit-security.config.ts";
const UNIT_SRC_VITEST_CONFIG = "test/vitest/vitest.unit-src.config.ts";
const UNIT_SUPPORT_VITEST_CONFIG = "test/vitest/vitest.unit-support.config.ts";
const UNIT_UI_VITEST_CONFIG = "test/vitest/vitest.unit-ui.config.ts";
const FULL_SUITE_CONFIG_WEIGHT = new Map([
[GATEWAY_VITEST_CONFIG, 180],
@@ -212,7 +210,6 @@ const FULL_SUITE_CONFIG_WEIGHT = new Map([
[COMMANDS_LIGHT_VITEST_CONFIG, 48],
[PLUGIN_SDK_VITEST_CONFIG, 46],
[AUTO_REPLY_TOP_LEVEL_VITEST_CONFIG, 45],
[UNIT_UI_VITEST_CONFIG, 40],
[PLUGIN_SDK_LIGHT_VITEST_CONFIG, 38],
[DAEMON_VITEST_CONFIG, 36],
[BOUNDARY_VITEST_CONFIG, 34],
@@ -378,7 +375,6 @@ const VITEST_CONFIG_BY_KIND = {
unitSecurity: UNIT_SECURITY_VITEST_CONFIG,
unitSrc: UNIT_SRC_VITEST_CONFIG,
unitSupport: UNIT_SUPPORT_VITEST_CONFIG,
unitUi: UNIT_UI_VITEST_CONFIG,
runtimeConfig: RUNTIME_CONFIG_VITEST_CONFIG,
secrets: SECRETS_VITEST_CONFIG,
sharedCore: SHARED_CORE_VITEST_CONFIG,
@@ -2079,7 +2075,7 @@ const SOURCE_TEST_TARGETS = new Map([
],
["src/plugins/runtime-sidecar-paths-baseline.ts", RUNTIME_SIDECAR_BASELINE_OWNER_TEST_TARGETS],
["src/plugins/runtime-sidecar-paths.ts", RUNTIME_SIDECAR_PATH_CONSUMER_TEST_TARGETS],
["ui/config/control-ui-chunking.ts", ["ui/src/ui/control-ui-chunking.test.ts"]],
["ui/config/control-ui-chunking.ts", ["ui/src/app/control-ui-chunking.test.ts"]],
[
"src/plugin-sdk/test-helpers/directory-ids.ts",
[
@@ -3024,8 +3020,8 @@ function isVitestConfigTargetForKind(kind, targetArg, cwd) {
function isControlUiE2eTarget(relative) {
return (
relative === "ui/src/test-helpers/control-ui-e2e.ts" ||
relative === "ui/src/ui/e2e" ||
relative.startsWith("ui/src/ui/e2e/") ||
relative === "ui/src/e2e" ||
relative.startsWith("ui/src/e2e/") ||
(relative.startsWith("ui/src/") && relative.endsWith(".e2e.test.ts"))
);
}
@@ -3306,10 +3302,7 @@ function shouldCombineSiblingTestWithImportGraph(changedPath) {
}
function shouldRouteChangedTargetWithoutImportGraph(changedPath) {
return (
changedPath.endsWith(".live.test.ts") ||
(changedPath.startsWith("ui/src/") && !changedPath.startsWith("ui/src/ui/"))
);
return changedPath.endsWith(".live.test.ts") || changedPath.startsWith("ui/src/");
}
function resolvePromptSnapshotFixtureTargets(changedPath) {
@@ -3463,9 +3456,6 @@ function classifyTarget(arg, cwd) {
return "uiE2e";
}
if (isPathAtOrUnder(relative, "ui/src")) {
if (isUnitUiTestTarget(relative)) {
return "unitUi";
}
return "ui";
}
if (relative.startsWith("src/tui/tui-pty-")) {
@@ -3704,7 +3694,7 @@ function shouldUseWholeConfigTarget(kind, targetArg, cwd) {
if (isTestFileTarget(relative)) {
return false;
}
return relative.startsWith("ui/src/") && !relative.startsWith("ui/src/ui/");
return relative.startsWith("ui/src/");
}
function createVitestArgs(params) {
@@ -3903,7 +3893,6 @@ export function buildVitestRunPlans(
"unitSrc",
"unitSecurity",
"unitSupport",
"unitUi",
"utils",
"wizard",
"e2e",
+2 -2
View File
@@ -40,7 +40,7 @@ function extractConstUnionValues(schema: SchemaLike): string[] {
.filter((value): value is string => typeof value === "string");
}
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
const UI_FILES = ["ui/src/api/types.ts", "ui/src/lib/cron/index.ts", "ui/src/pages/cron/view.ts"];
const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`];
const SWIFT_STATUS_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
@@ -86,7 +86,7 @@ describe("cron protocol conformance", () => {
it("cron status shape matches gateway fields in UI + Swift", async () => {
const cwd = process.cwd();
const uiTypes = await fs.readFile(path.join(cwd, "ui/src/ui/types.ts"), "utf-8");
const uiTypes = await fs.readFile(path.join(cwd, "ui/src/api/types.ts"), "utf-8");
expect(uiTypes).toContain("export type CronStatus");
expect(uiTypes).toContain("jobs:");
expect(uiTypes).not.toContain("jobCount");
+1 -1
View File
@@ -2,7 +2,7 @@
// auth error details that cannot recover by retrying.
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/ui/gateway.ts";
import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/api/gateway.ts";
function makeError(detailCode: string): GatewayErrorInfo {
return { code: "connect_failed", message: "auth failed", details: { code: detailCode } };
+1 -1
View File
@@ -834,7 +834,7 @@ describe("test-projects args", () => {
it("routes unit ui targets to the unit ui config", () => {
expect(buildVitestRunPlans(["ui/src/ui/views/channels.test.ts"])).toEqual([
{
config: "test/vitest/vitest.unit-ui.config.ts",
config: "test/vitest/vitest.ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/views/channels.test.ts"],
watchMode: false,
@@ -1,125 +0,0 @@
// Tests UI app settings refresh behavior for agent files.
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadAgentsMock = vi.hoisted(() =>
vi.fn(async (host: { agentsList?: unknown }) => {
host.agentsList = {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [{ id: "main" }],
};
}),
);
const loadConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentIdentitiesMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentIdentityMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentSkillsMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadAgentFilesMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadChannelsMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../ui/src/ui/controllers/agents.ts", () => ({
loadAgents: loadAgentsMock,
}));
vi.mock("../ui/src/ui/controllers/config.ts", () => ({
loadConfig: loadConfigMock,
loadConfigSchema: vi.fn(async () => undefined),
}));
vi.mock("../ui/src/ui/controllers/agent-identity.ts", () => ({
loadAgentIdentities: loadAgentIdentitiesMock,
loadAgentIdentity: loadAgentIdentityMock,
}));
vi.mock("../ui/src/ui/controllers/agent-skills.ts", () => ({
loadAgentSkills: loadAgentSkillsMock,
}));
vi.mock("../ui/src/ui/controllers/agent-files.ts", () => ({
loadAgentFiles: loadAgentFilesMock,
}));
vi.mock("../ui/src/ui/controllers/channels.ts", () => ({
loadChannels: loadChannelsMock,
}));
vi.mock("../ui/src/ui/controllers/cron.ts", () => ({
loadCronJobsPage: vi.fn(async () => undefined),
loadCronRuns: vi.fn(async () => undefined),
loadCronStatus: vi.fn(async () => undefined),
}));
import { refreshActiveTab } from "../ui/src/ui/app-settings.ts";
type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
function createHost(agentsPanel: AgentsPanel): Parameters<typeof refreshActiveTab>[0] {
return {
tab: "agents",
connected: true,
agentsPanel,
agentsList: null,
agentsSelectedId: null,
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
},
theme: "claw",
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
dreamingStatusLoading: false,
dreamingStatusError: null,
dreamingStatus: null,
dreamingModeSaving: false,
dreamDiaryLoading: false,
dreamDiaryError: null,
dreamDiaryPath: null,
dreamDiaryContent: null,
} as Parameters<typeof refreshActiveTab>[0];
}
describe("refreshActiveTab (agents/files)", () => {
beforeEach(() => {
loadAgentsMock.mockClear();
loadConfigMock.mockClear();
loadAgentIdentitiesMock.mockClear();
loadAgentIdentityMock.mockClear();
loadAgentSkillsMock.mockClear();
loadAgentFilesMock.mockClear();
loadChannelsMock.mockClear();
});
it("loads agent files when the active agents panel is files", async () => {
const host = createHost("files");
await refreshActiveTab(host);
expect(loadAgentFilesMock).toHaveBeenCalledTimes(1);
expect(loadAgentFilesMock).toHaveBeenCalledWith(host, "main");
});
it("does not load agent files on non-files panels", async () => {
const host = createHost("overview");
await refreshActiveTab(host);
expect(loadAgentFilesMock).not.toHaveBeenCalled();
});
});
-5
View File
@@ -215,11 +215,6 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => {
requiresDist: false,
shardName: "core-unit-src-security",
},
{
configs: ["test/vitest/vitest.unit-ui.config.ts"],
requiresDist: false,
shardName: "core-unit-ui",
},
{
configs: ["test/vitest/vitest.unit-support.config.ts"],
requiresDist: false,
+8 -8
View File
@@ -52,8 +52,8 @@ describe("run-tsgo sparse guard", () => {
"ui/config/control-ui-chunking.ts",
"ui/src/i18n/lib/registry.ts",
"ui/src/i18n/lib/types.ts",
"ui/src/ui/app-settings.ts",
"ui/src/ui/gateway.ts",
"ui/src/app/settings.ts",
"ui/src/api/gateway.ts",
];
for (const relativePath of requiredPaths) {
@@ -79,8 +79,8 @@ describe("run-tsgo sparse guard", () => {
"ui/config/control-ui-chunking.ts",
"ui/src/i18n/lib/registry.ts",
"ui/src/i18n/lib/types.ts",
"ui/src/ui/app-settings.ts",
"ui/src/ui/gateway.ts",
"ui/src/app/settings.ts",
"ui/src/api/gateway.ts",
];
for (const relativePath of requiredPaths) {
@@ -98,8 +98,8 @@ describe("run-tsgo sparse guard", () => {
"/ui/config/control-ui-chunking.ts",
"/ui/src/i18n/lib/registry.ts",
"/ui/src/i18n/lib/types.ts",
"/ui/src/ui/app-settings.ts",
"/ui/src/ui/gateway.ts",
"/ui/src/app/settings.ts",
"/ui/src/api/gateway.ts",
],
}),
).toMatchInlineSnapshot(`
@@ -141,10 +141,10 @@ describe("run-tsgo sparse guard", () => {
"tsconfig.core.test.json cannot be typechecked from this sparse checkout because tracked project inputs are missing or only partially included:
- packages/plugin-package-contract/src/index.ts
- ui/config/control-ui-chunking.ts
- ui/src/api/gateway.ts
- ui/src/app/settings.ts
- ui/src/i18n/lib/registry.ts
- ui/src/i18n/lib/types.ts
- ui/src/ui/app-settings.ts
- ui/src/ui/gateway.ts
Expand this worktree's sparse checkout to include those paths, or rerun in a full worktree."
`);
});
+3 -33
View File
@@ -148,27 +148,11 @@ describe("scripts/run-vitest", () => {
).toContain("[vitest] Vitest is not installed in node_modules.");
});
it("routes explicit unit ui tests through the narrow unit ui config", () => {
expect(
resolveImplicitVitestArgs([
"ui/src/ui/controllers/chat.test.ts",
"-t",
"keeps optimistic user attachment previews",
]),
).toEqual([
"--config",
"test/vitest/vitest.unit-ui.config.ts",
"ui/src/ui/controllers/chat.test.ts",
"-t",
"keeps optimistic user attachment previews",
]);
});
it("does not override explicit vitest configs", () => {
const argv = [
"--config",
"test/vitest/vitest.ui.config.ts",
"ui/src/ui/controllers/chat.test.ts",
"ui/src/pages/chat/chat-send.test.ts",
];
expect(resolveImplicitVitestArgs(argv)).toBe(argv);
});
@@ -400,29 +384,15 @@ describe("scripts/run-vitest", () => {
).toEqual([]);
});
it("keeps the run subcommand first when routing unit ui tests", () => {
expect(resolveImplicitVitestArgs(["run", "ui/src/ui/controllers/chat.test.ts"])).toEqual([
"run",
"--config",
"test/vitest/vitest.unit-ui.config.ts",
"ui/src/ui/controllers/chat.test.ts",
]);
});
it("routes explicit non-e2e ui tests through the ui config", () => {
expect(resolveImplicitVitestArgs(["run", "ui/src/ui/app-gateway.node.test.ts"])).toEqual([
expect(resolveImplicitVitestArgs(["run", "ui/src/pages/chat/chat-send.test.ts"])).toEqual([
"run",
"--config",
"test/vitest/vitest.ui.config.ts",
"ui/src/ui/app-gateway.node.test.ts",
"ui/src/pages/chat/chat-send.test.ts",
]);
});
it("keeps mixed unit ui and broader ui targets on existing routing", () => {
const argv = ["ui/src/ui/controllers/chat.test.ts", "ui/src/ui/app-gateway.node.test.ts"];
expect(resolveImplicitVitestArgs(argv)).toBe(argv);
});
it("allows opting back into Maglev explicitly", () => {
expect(
resolveVitestNodeArgs({
+6 -66
View File
@@ -161,21 +161,6 @@ function withTinyGitRepo(files: Record<string, string>, test: (cwd: string) => v
}
}
function commitTinyGitRepo(cwd: string): void {
const commit = spawnSync("git", ["commit", "-m", "initial"], {
cwd,
env: {
...process.env,
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_AUTHOR_NAME: "OpenClaw Test",
GIT_COMMITTER_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "OpenClaw Test",
},
stdio: "ignore",
});
expect(commit.status).toBe(0);
}
function withTinyFileTree(files: Record<string, string>, test: (cwd: string) => void): void {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-projects-"));
try {
@@ -3185,36 +3170,7 @@ describe("scripts/test-projects changed-target routing", () => {
{
config: "test/vitest/vitest.ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/control-ui-chunking.test.ts"],
watchMode: false,
},
]);
});
it("routes unit ui test targets to the unit ui lane", () => {
expect(buildVitestRunPlans(["ui/src/ui/chat/grouped-render.test.ts"])).toEqual([
{
config: "test/vitest/vitest.unit-ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/chat/grouped-render.test.ts"],
watchMode: false,
},
]);
expect(buildVitestRunPlans(["ui/src/ui/views/chat.test.ts"])).toEqual([
{
config: "test/vitest/vitest.unit-ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/views/chat.test.ts"],
watchMode: false,
},
]);
expect(buildVitestRunPlans(["ui/src/ui/views/dreaming.test.ts"])).toEqual([
{
config: "test/vitest/vitest.unit-ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/views/dreaming.test.ts"],
includePatterns: ["ui/src/app/control-ui-chunking.test.ts"],
watchMode: false,
},
]);
@@ -3243,11 +3199,11 @@ describe("scripts/test-projects changed-target routing", () => {
});
it("routes control ui e2e tests to the ui e2e lane", () => {
expect(buildVitestRunPlans(["ui/src/ui/e2e/chat-flow.e2e.test.ts"])).toEqual([
expect(buildVitestRunPlans(["ui/src/e2e/chat-flow.e2e.test.ts"])).toEqual([
{
config: "test/vitest/vitest.ui-e2e.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/e2e/chat-flow.e2e.test.ts"],
includePatterns: ["ui/src/e2e/chat-flow.e2e.test.ts"],
watchMode: false,
},
]);
@@ -3261,31 +3217,16 @@ describe("scripts/test-projects changed-target routing", () => {
},
]);
expect(buildVitestRunPlans(["ui/src/ui/e2e"])).toEqual([
expect(buildVitestRunPlans(["ui/src/e2e"])).toEqual([
{
config: "test/vitest/vitest.ui-e2e.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/e2e/**/*.test.ts"],
includePatterns: ["ui/src/e2e/**/*.test.ts"],
watchMode: false,
},
]);
expect(buildVitestArgs(["ui/src/ui/e2e"])).toContain("--configLoader");
});
it("routes changed unit ui tests to the unit ui lane", () => {
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
"ui/src/ui/chat/grouped-render.test.ts",
]);
expect(plans).toEqual([
{
config: "test/vitest/vitest.unit-ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/ui/chat/grouped-render.test.ts"],
watchMode: false,
},
]);
expect(buildVitestArgs(["ui/src/e2e"])).toContain("--configLoader");
});
it("routes auto-reply route source files to route regression tests", () => {
@@ -4199,7 +4140,6 @@ describe("scripts/test-projects full-suite sharding", () => {
"test/vitest/vitest.unit-fast-fake-timers.config.ts",
"test/vitest/vitest.unit-src.config.ts",
"test/vitest/vitest.unit-security.config.ts",
"test/vitest/vitest.unit-ui.config.ts",
"test/vitest/vitest.unit-support.config.ts",
"test/vitest/vitest.boundary.config.ts",
"test/vitest/vitest.tooling.config.ts",
+2 -2
View File
@@ -70,7 +70,7 @@ describeLive("provider live", () => {});
);
writeRepoFile(
repoRoot,
"ui/src/ui/e2e/chat-flow.e2e.test.ts",
"ui/src/e2e/chat-flow.e2e.test.ts",
`
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
describeControlUiE2e("control UI", () => {});
@@ -184,7 +184,7 @@ describe("collectTestSkipInventoryReport", () => {
target: "it",
},
{
file: "ui/src/ui/e2e/chat-flow.e2e.test.ts",
file: "ui/src/e2e/chat-flow.e2e.test.ts",
kind: "alias",
method: "skip",
reason: "optional-dependency",
+1 -1
View File
@@ -8,7 +8,7 @@
"../../src/**/*.test.ts",
"../../src/**/*.test.tsx",
"../../ui/**/*.d.ts",
"../../ui/src/ui/app.ts",
"../../ui/src/main.ts",
"../../ui/**/*.test.ts",
"../../ui/**/*.test.tsx",
"../../packages/**/*.d.ts",
+1 -1
View File
@@ -5,7 +5,7 @@
"../../src/**/*.test.ts",
"../../src/**/*.test.tsx",
"../../ui/**/*.d.ts",
"../../ui/src/ui/app.ts",
"../../ui/src/main.ts",
"../../extensions/**/*.d.ts",
"../../packages/**/*.d.ts"
]
+1 -1
View File
@@ -1,7 +1,7 @@
// UI presenter next-run tests cover presenter scheduling output.
import { describe, expect, it } from "vitest";
import { t } from "../ui/src/i18n/index.ts";
import { formatNextRun } from "../ui/src/ui/presenter.ts";
import { formatNextRun } from "../ui/src/lib/presenter.ts";
describe("formatNextRun", () => {
it("returns localized n/a for nullish values", () => {
-13
View File
@@ -27,11 +27,9 @@ import {
sharedVitestConfig,
} from "./vitest/vitest.shared.config.ts";
import { fullSuiteVitestShards } from "./vitest/vitest.test-shards.mjs";
import { unitUiIncludePatterns } from "./vitest/vitest.ui-paths.mjs";
import { createUiVitestConfig } from "./vitest/vitest.ui.config.ts";
import { createUnitFastFakeTimersVitestConfig } from "./vitest/vitest.unit-fast-fake-timers.config.ts";
import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts";
import unitUiConfig from "./vitest/vitest.unit-ui.config.ts";
import { createUnitVitestConfig } from "./vitest/vitest.unit.config.ts";
const patternFiles = createPatternFileHelper("openclaw-vitest-projects-config-");
@@ -217,17 +215,6 @@ describe("projects vitest config", () => {
expect(requireWebOptimizer(testConfig).enabled).toBe(true);
});
it("keeps the unit-ui shard aligned with the shared jsdom setup", () => {
const testConfig = requireTestConfig(unitUiConfig);
expect(testConfig.environment).toBe("jsdom");
expect(testConfig.isolate).toBe(false);
expect(normalizeConfigPath(testConfig.runner)).toBe("test/non-isolated-runner.ts");
expect(unitUiIncludePatterns).toContain("ui/src/ui/views/dreaming.test.ts");
const setupFiles = normalizeConfigPaths(testConfig.setupFiles);
expect(setupFiles).not.toContain("test/setup-openclaw-runtime.ts");
expect(setupFiles).toContain("ui/src/test-helpers/lit-warnings.setup.ts");
});
it("keeps the unit lane on the non-isolated runner by default", () => {
const config = createUnitVitestConfig();
expect(config.test.isolate).toBe(false);
+1 -1
View File
@@ -1039,7 +1039,7 @@ describe("scoped vitest configs", () => {
const testConfig = requireTestConfig(defaultUiConfig);
expect(testConfig.dir).toBe(process.cwd());
expect(testConfig.include).toEqual(["ui/src/**/*.test.ts"]);
expect(testConfig.exclude).toContain("ui/src/ui/app-chat.test.ts");
expect(testConfig.exclude).toContain("ui/src/**/*.e2e.test.ts");
});
it("normalizes utils include patterns relative to the scoped dir", () => {
-1
View File
@@ -132,7 +132,6 @@ describe("unit-fast vitest lane", () => {
expect(testConfig.include).toContain("src/security/audit-gateway-tools-http.test.ts");
expect(testConfig.include).toContain("src/security/audit-plugin-readonly-scope.test.ts");
expect(testConfig.include).toContain("src/security/audit-loopback-logging.test.ts");
expect(testConfig.include).toContain("src/ui-app-settings.agents-files-refresh.test.ts");
expect(testConfig.include).toContain("src/video-generation/provider-registry.test.ts");
expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts");
expect(testConfig.include).toContain("src/security/dangerous-config-flags.test.ts");
-1
View File
@@ -12,7 +12,6 @@ export { resolveDefaultVitestPool, resolveLocalVitestMaxWorkers, resolveLocalVit
export const rootVitestProjects = [
"test/vitest/vitest.unit.config.ts",
"test/vitest/vitest.unit-ui.config.ts",
"test/vitest/vitest.infra.config.ts",
"test/vitest/vitest.boundary.config.ts",
"test/vitest/vitest.contracts-channel-surface.config.ts",
@@ -1,9 +0,0 @@
// Vitest full core unit ui config wires the full core unit ui test shard.
import { createProjectShardVitestConfig } from "./vitest.project-shard-config.ts";
import { fullSuiteVitestShards } from "./vitest.test-shards.mjs";
export default createProjectShardVitestConfig(
fullSuiteVitestShards.find(
(shard) => shard.config === "test/vitest/vitest.full-core-unit-ui.config.ts",
)?.projects ?? [],
);
-1
View File
@@ -160,7 +160,6 @@ const SCOPED_PROJECT_GROUP_ORDER_BY_NAME = new Map(
"unit-security",
"unit-src",
"unit-support",
"unit-ui",
"utils",
"wizard",
].map((name, index) => [name, index + 10]),
+1 -17
View File
@@ -571,23 +571,7 @@ export const sharedVitestConfig = {
BUNDLED_PLUGIN_TEST_GLOB,
"packages/**/*.test.ts",
"test/**/*.test.ts",
"ui/src/ui/app-chat.test.ts",
"ui/src/ui/chat/**/*.test.ts",
"ui/src/ui/views/agents-utils.test.ts",
"ui/src/ui/views/channels.test.ts",
"ui/src/ui/views/chat.test.ts",
"ui/src/ui/views/nodes.devices.test.ts",
"ui/src/ui/views/skills.test.ts",
"ui/src/ui/views/dreaming.test.ts",
"ui/src/ui/views/usage-render-details.test.ts",
"ui/src/ui/controllers/agents.test.ts",
"ui/src/ui/controllers/chat.test.ts",
"ui/src/ui/controllers/skills.test.ts",
"ui/src/ui/controllers/sessions.test.ts",
"ui/src/ui/views/sessions.test.ts",
"ui/src/ui/app-tool-stream.node.test.ts",
"ui/src/ui/app-gateway.sessions.node.test.ts",
"ui/src/ui/chat/slash-command-executor.node.test.ts",
"ui/src/pages/chat/tool-stream.node.test.ts",
],
setupFiles: [resolveRepoRootPath("test/setup.ts")],
exclude: [
-5
View File
@@ -29,11 +29,6 @@ export const fullSuiteVitestShards = [
name: "core-unit-security",
projects: ["test/vitest/vitest.unit-security.config.ts"],
},
{
config: "test/vitest/vitest.full-core-unit-ui.config.ts",
name: "core-unit-ui",
projects: ["test/vitest/vitest.unit-ui.config.ts"],
},
{
config: "test/vitest/vitest.full-core-unit-support.config.ts",
name: "core-unit-support",
+1 -30
View File
@@ -1,33 +1,4 @@
// Test routing globs and predicates for UI unit tests.
export const unitUiIncludePatterns = [
"ui/src/ui/app-chat.test.ts",
"ui/src/ui/chat/**/*.test.ts",
"ui/src/ui/views/agents-utils.test.ts",
"ui/src/ui/views/channels.test.ts",
"ui/src/ui/views/chat.test.ts",
"ui/src/ui/views/dreaming.test.ts",
"ui/src/ui/views/usage-render-details.test.ts",
"ui/src/ui/controllers/agents.test.ts",
"ui/src/ui/controllers/chat.test.ts",
];
export function isUnitUiTestTarget(relative) {
if (!relative.endsWith(".test.ts")) {
return false;
}
return (
relative === "ui/src/ui/app-chat.test.ts" ||
relative.startsWith("ui/src/ui/chat/") ||
relative === "ui/src/ui/views/agents-utils.test.ts" ||
relative === "ui/src/ui/views/channels.test.ts" ||
relative === "ui/src/ui/views/chat.test.ts" ||
relative === "ui/src/ui/views/dreaming.test.ts" ||
relative === "ui/src/ui/views/usage-render-details.test.ts" ||
relative === "ui/src/ui/controllers/agents.test.ts" ||
relative === "ui/src/ui/controllers/chat.test.ts"
);
}
// Test routing predicate for Control UI tests.
export function isUiTestTarget(relative) {
return (
relative.startsWith("ui/src/") &&
+1 -4
View File
@@ -1,16 +1,13 @@
// Vitest ui config wires the ui test shard.
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { jsdomOptimizedDeps } from "./vitest.shared.config.ts";
import { unitUiIncludePatterns } from "./vitest.ui-paths.mjs";
export function createUiVitestConfig(
env?: Record<string, string | undefined>,
options?: { includePatterns?: string[]; name?: string },
) {
const includePatterns = options?.includePatterns ?? ["ui/src/**/*.test.ts"];
const exclude = options?.includePatterns
? []
: [...unitUiIncludePatterns, "ui/src/**/*.e2e.test.ts"];
const exclude = options?.includePatterns ? [] : ["ui/src/**/*.e2e.test.ts"];
return createScopedVitestConfig(includePatterns, {
deps: jsdomOptimizedDeps,
environment: "jsdom",
-1
View File
@@ -200,7 +200,6 @@ export const forcedUnitFastTestFiles = [
"src/tts/provider-registry.test.ts",
"src/tts/status-config.test.ts",
"src/tts/tts-config.test.ts",
"src/ui-app-settings.agents-files-refresh.test.ts",
"packages/terminal-core/src/restore.test.ts",
"packages/terminal-core/src/table.test.ts",
"src/test-helpers/state-dir-env.test.ts",
-8
View File
@@ -1,8 +0,0 @@
// Vitest unit ui config wires the unit ui test shard.
import { unitUiIncludePatterns } from "./vitest.ui-paths.mjs";
import { createUiVitestConfig } from "./vitest.ui.config.ts";
export default createUiVitestConfig(process.env, {
includePatterns: unitUiIncludePatterns,
name: "unit-ui",
});
+1
View File
@@ -13,6 +13,7 @@
"@noble/ed25519": "3.1.0",
"@openclaw/media-core": "workspace:*",
"@openclaw/normalization-core": "workspace:*",
"@openclaw/uirouter": "0.1.0",
"dompurify": "3.4.11",
"ghostty-web": "0.4.0",
"highlight.js": "11.11.1",
@@ -1,4 +1,4 @@
// Control UI module implements app events behavior.
// Shared event-log contract used by application instrumentation and page views.
export type EventLogEntry = {
ts: number;
event: string;
@@ -4,9 +4,9 @@ import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../../../packages/gateway-protocol/src/version.js";
import type { DeviceIdentity } from "../lib/nodes/index.ts";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../lib/nodes/index.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import type { DeviceIdentity } from "./device-identity.ts";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
@@ -93,7 +93,8 @@ class MockWebSocket {
}
}
vi.mock("./device-identity.ts", () => ({
vi.mock("../lib/nodes/index.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/nodes/index.ts")>()),
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
signDevicePayload: signDevicePayloadMock,
}));
@@ -21,9 +21,14 @@ import {
PROTOCOL_VERSION,
} from "../../../packages/gateway-protocol/src/version.js";
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts";
import { generateUUID } from "./uuid.ts";
import {
clearDeviceAuthToken,
loadDeviceAuthToken,
storeDeviceAuthToken,
loadOrCreateDeviceIdentity,
signDevicePayload,
} from "../lib/nodes/index.ts";
import { generateUUID } from "../lib/uuid.ts";
export type GatewayEventFrame = {
type: "event";
+1 -15
View File
@@ -322,8 +322,6 @@ export type GatewayThinkingLevelOption = {
label: string;
};
export type ChatModelOverride = import("./chat-model-ref.types.ts").ChatModelOverride;
export type GatewayAgentRow = SharedGatewayAgentRow;
export type AgentsListResult = {
@@ -509,7 +507,6 @@ export type GatewaySessionRow = {
childSessions?: string[];
model?: string;
modelProvider?: string;
/** Resolved effective usage-footer mode (session override → per-channel config → default → off), carried from gateway session rows/events. */
effectiveResponseUsage?: "on" | "off" | "tokens" | "full";
agentRuntime?: GatewayAgentRuntime;
contextTokens?: number;
@@ -579,7 +576,7 @@ export type {
SessionsUsageTotals,
SessionUsageTimePoint,
SessionUsageTimeSeries,
} from "./usage-types.ts";
} from "../pages/usage/data-types.ts";
export type CronRunStatus = "ok" | "error" | "skipped";
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
@@ -877,17 +874,6 @@ export type ModelAuthStatusProvider =
export type ModelAuthStatusResult =
import("../../../src/gateway/server-methods/models-auth-status.js").ModelAuthStatusResult;
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
raw: string;
time?: string | null;
level?: LogLevel | null;
subsystem?: string | null;
message?: string | null;
meta?: Record<string, unknown> | null;
};
// ── Attention ───────────────────────────────────────
export type AttentionSeverity = "error" | "warning" | "info";
+55
View File
@@ -0,0 +1,55 @@
// Control UI tests cover navigation groups behavior.
import { describe, expect, it } from "vitest";
import {
SETTINGS_NAVIGATION_ROUTES,
SIDEBAR_SECTIONS,
isSettingsNavigationRoute,
isRouteInSidebarSection,
} from "./app-navigation.ts";
import { routeIdFromPath } from "./app-routes.ts";
describe("SIDEBAR_SECTIONS", () => {
it("collapses detailed settings slices into one sidebar entry", () => {
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
expect(settings?.routes).toEqual(["config"]);
expect(SETTINGS_NAVIGATION_ROUTES.every((routeId) => isSettingsNavigationRoute(routeId))).toBe(
true,
);
});
it("keeps channel management out of the primary control sidebar", () => {
const control = SIDEBAR_SECTIONS.find((group) => group.label === "control");
expect(control?.routes).toEqual([
"overview",
"activity",
"workboard",
"instances",
"sessions",
"usage",
"cron",
]);
expect(SETTINGS_NAVIGATION_ROUTES).toContain("channels");
});
it("keeps the settings group active for nested settings routes", () => {
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
if (!settings) {
throw new Error("Expected settings group");
}
expect(isRouteInSidebarSection(settings, "appearance")).toBe(true);
expect(isRouteInSidebarSection(settings, "channels")).toBe(true);
expect(isRouteInSidebarSection(settings, "debug")).toBe(true);
expect(isRouteInSidebarSection(settings, "chat")).toBe(false);
});
it("routes every published settings slice", () => {
expect(routeIdFromPath("/communications")).toBe("communications");
expect(routeIdFromPath("/appearance")).toBe("appearance");
expect(routeIdFromPath("/automation")).toBe("automation");
expect(routeIdFromPath("/infrastructure")).toBe("infrastructure");
expect(routeIdFromPath("/ai-agents")).toBe("ai-agents");
expect(routeIdFromPath("/config")).toBe("config");
expect(routeIdFromPath("/channels")).toBe("channels");
});
});
@@ -1,23 +1,27 @@
// Control UI tests cover navigation behavior.
import { describe, expect, it } from "vitest";
import {
TAB_GROUPS,
SETTINGS_TABS,
iconForTab,
SETTINGS_NAVIGATION_ROUTES,
SIDEBAR_SECTIONS,
navigationIconForRoute,
subtitleForRoute,
titleForRoute,
} from "./app-navigation.ts";
import {
inferBasePathFromPathname,
isSettingsTab,
normalizeBasePath,
normalizePath,
pathForTab,
subtitleForTab,
tabFromPath,
titleForTab,
type Tab,
} from "./navigation.ts";
pathForRoute,
routeIdFromPath,
type RouteId,
} from "./app-routes.ts";
/** All valid tab identifiers derived from visible groups plus routed settings slices. */
const ALL_TABS: Tab[] = Array.from(
new Set<Tab>([...(TAB_GROUPS.flatMap((group) => group.tabs) as Tab[]), ...SETTINGS_TABS]),
/** All route identifiers derived from visible groups plus routed settings slices. */
const ALL_ROUTES: RouteId[] = Array.from(
new Set<RouteId>([
...(SIDEBAR_SECTIONS.flatMap((group) => group.routes) as RouteId[]),
...SETTINGS_NAVIGATION_ROUTES,
]),
);
const leadingSlashNormalizerCases = [
@@ -25,9 +29,11 @@ const leadingSlashNormalizerCases = [
{ name: "normalizePath", normalize: normalizePath, input: "chat", expected: "/chat" },
];
describe("iconForTab", () => {
it("returns stable icons for every tab", () => {
expect(Object.fromEntries(ALL_TABS.map((tab) => [tab, iconForTab(tab)]))).toEqual({
describe("navigationIconForRoute", () => {
it("returns stable icons for every route", () => {
expect(
Object.fromEntries(ALL_ROUTES.map((routeId) => [routeId, navigationIconForRoute(routeId)])),
).toEqual({
chat: "messageSquare",
overview: "barChart",
activity: "activity",
@@ -39,7 +45,7 @@ describe("iconForTab", () => {
cron: "loader",
agents: "folder",
skills: "zap",
skillWorkshop: "wrench",
"skill-workshop": "wrench",
nodes: "monitor",
dreams: "moon",
config: "settings",
@@ -48,22 +54,24 @@ describe("iconForTab", () => {
automation: "terminal",
mcp: "wrench",
infrastructure: "globe",
aiAgents: "brain",
"ai-agents": "brain",
debug: "bug",
logs: "scrollText",
});
});
it("returns a fallback icon for unknown tab", () => {
it("returns a fallback icon for unknown route", () => {
// TypeScript won't allow this normally, but runtime could receive unexpected values
const unknownTab = "unknown" as Tab;
expect(iconForTab(unknownTab)).toBe("folder");
const unknownRouteId = "unknown" as RouteId;
expect(navigationIconForRoute(unknownRouteId)).toBe("folder");
});
});
describe("titleForTab", () => {
it("returns expected titles for every tab", () => {
expect(Object.fromEntries(ALL_TABS.map((tab) => [tab, titleForTab(tab)]))).toEqual({
describe("titleForRoute", () => {
it("returns expected titles for every route", () => {
expect(
Object.fromEntries(ALL_ROUTES.map((routeId) => [routeId, titleForRoute(routeId)])),
).toEqual({
chat: "Chat",
overview: "Overview",
activity: "Activity",
@@ -75,7 +83,7 @@ describe("titleForTab", () => {
cron: "Cron Jobs",
agents: "Agents",
skills: "Skills",
skillWorkshop: "Skill Workshop",
"skill-workshop": "Skill Workshop",
nodes: "Nodes",
dreams: "Dreaming",
config: "Settings",
@@ -84,16 +92,18 @@ describe("titleForTab", () => {
automation: "Automation",
mcp: "MCP",
infrastructure: "Infrastructure",
aiAgents: "AI & Agents",
"ai-agents": "AI & Agents",
debug: "Debug",
logs: "Logs",
});
});
});
describe("subtitleForTab", () => {
it("returns expected subtitles for every tab", () => {
expect(Object.fromEntries(ALL_TABS.map((tab) => [tab, subtitleForTab(tab)]))).toEqual({
describe("subtitleForRoute", () => {
it("returns expected subtitles for every route", () => {
expect(
Object.fromEntries(ALL_ROUTES.map((routeId) => [routeId, subtitleForRoute(routeId)])),
).toEqual({
chat: "Gateway chat for quick interventions.",
overview: "Status, entry points, health.",
activity: "Browser-local tool activity summaries.",
@@ -105,7 +115,7 @@ describe("subtitleForTab", () => {
cron: "Wakeups and recurring runs.",
agents: "Workspaces, tools, identities.",
skills: "Skills and API keys.",
skillWorkshop: "Review, refine, and apply proposals before they become live skills.",
"skill-workshop": "Review, refine, and apply proposals before they become live skills.",
nodes: "Paired devices and commands.",
dreams: "Memory dreaming, consolidation, and reflection.",
config: "Edit openclaw.json.",
@@ -114,7 +124,7 @@ describe("subtitleForTab", () => {
automation: "Commands, hooks, cron, and plugins.",
mcp: "MCP servers, auth, tools, and diagnostics.",
infrastructure: "Gateway, web, browser, and media settings.",
aiAgents: "Agents, models, skills, tools, memory, session.",
"ai-agents": "Agents, models, skills, tools, memory, session.",
debug: "Snapshots, events, RPC.",
logs: "Live gateway logs.",
});
@@ -159,44 +169,44 @@ describe("normalizePath", () => {
});
});
describe("pathForTab", () => {
describe("pathForRoute", () => {
it("returns correct path without base", () => {
expect(pathForTab("chat")).toBe("/chat");
expect(pathForTab("overview")).toBe("/overview");
expect(pathForRoute("chat")).toBe("/chat");
expect(pathForRoute("overview")).toBe("/overview");
});
it("prepends base path", () => {
expect(pathForTab("chat", "/ui")).toBe("/ui/chat");
expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions");
expect(pathForRoute("chat", "/ui")).toBe("/ui/chat");
expect(pathForRoute("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions");
});
});
describe("tabFromPath", () => {
describe("routeIdFromPath", () => {
it("returns tab for valid path", () => {
expect(tabFromPath("/chat")).toBe("chat");
expect(tabFromPath("/overview")).toBe("overview");
expect(tabFromPath("/activity")).toBe("activity");
expect(tabFromPath("/sessions")).toBe("sessions");
expect(tabFromPath("/dreaming")).toBe("dreams");
expect(tabFromPath("/dreams")).toBe("dreams");
expect(routeIdFromPath("/chat")).toBe("chat");
expect(routeIdFromPath("/overview")).toBe("overview");
expect(routeIdFromPath("/activity")).toBe("activity");
expect(routeIdFromPath("/sessions")).toBe("sessions");
expect(routeIdFromPath("/dreaming")).toBe("dreams");
expect(routeIdFromPath("/dreams")).toBe("dreams");
});
it("returns chat for root path", () => {
expect(tabFromPath("/")).toBe("chat");
it("leaves root fallback to application startup", () => {
expect(routeIdFromPath("/")).toBeNull();
});
it("handles base paths", () => {
expect(tabFromPath("/ui/chat", "/ui")).toBe("chat");
expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions");
expect(routeIdFromPath("/ui/chat", "/ui")).toBe("chat");
expect(routeIdFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions");
});
it("returns null for unknown path", () => {
expect(tabFromPath("/unknown")).toBeNull();
expect(routeIdFromPath("/unknown")).toBeNull();
});
it("is case-insensitive", () => {
expect(tabFromPath("/CHAT")).toBe("chat");
expect(tabFromPath("/Overview")).toBe("overview");
it("matches canonical route casing exactly", () => {
expect(routeIdFromPath("/CHAT")).toBeNull();
expect(routeIdFromPath("/Overview")).toBeNull();
});
});
@@ -217,27 +227,33 @@ describe("inferBasePathFromPathname", () => {
expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw");
});
it("preserves mount roots without a route suffix", () => {
expect(inferBasePathFromPathname("/__openclaw__/")).toBe("/__openclaw__");
expect(inferBasePathFromPathname("/apps/openclaw/")).toBe("/apps/openclaw");
expect(inferBasePathFromPathname("/typo")).toBe("");
});
it("handles index.html suffix", () => {
expect(inferBasePathFromPathname("/index.html")).toBe("");
expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
});
});
describe("TAB_GROUPS", () => {
describe("SIDEBAR_SECTIONS", () => {
it("contains all expected groups", () => {
expect(TAB_GROUPS.map((g) => g.label)).toEqual(["chat", "control", "agent", "settings"]);
expect(SIDEBAR_SECTIONS.map((g) => g.label)).toEqual(["chat", "control", "agent", "settings"]);
});
it("all tabs are unique", () => {
const allTabs = TAB_GROUPS.flatMap((g) => g.tabs);
const uniqueTabs = new Set(allTabs);
expect(uniqueTabs.size).toBe(allTabs.length);
it("all routes are unique", () => {
const allRoutes = SIDEBAR_SECTIONS.flatMap((g) => g.routes);
const uniqueRoutes = new Set(allRoutes);
expect(uniqueRoutes.size).toBe(allRoutes.length);
});
it("keeps detailed settings slices routed but out of the root sidebar", () => {
const settings = TAB_GROUPS.find((group) => group.label === "settings");
expect(settings?.tabs).toEqual(["config"]);
expect(SETTINGS_TABS).toEqual([
const settings = SIDEBAR_SECTIONS.find((group) => group.label === "settings");
expect(settings?.routes).toEqual(["config"]);
expect(SETTINGS_NAVIGATION_ROUTES).toEqual([
"config",
"channels",
"communications",
@@ -245,10 +261,9 @@ describe("TAB_GROUPS", () => {
"automation",
"mcp",
"infrastructure",
"aiAgents",
"ai-agents",
"debug",
"logs",
]);
expect(SETTINGS_TABS.every((tab) => isSettingsTab(tab))).toBe(true);
});
});
+170
View File
@@ -0,0 +1,170 @@
// Control UI app navigation defines sidebar and settings presentation metadata.
import type { RouteId } from "./app-route-paths.ts";
import type { IconName } from "./components/icons.ts";
import { t } from "./i18n/index.ts";
export type NavigationRouteId = RouteId;
type SidebarSection = {
label: string;
routes: readonly NavigationRouteId[];
};
type NavigationItem = {
[TRouteId in NavigationRouteId]: IconName;
};
export const SIDEBAR_SECTIONS = [
{ label: "chat", routes: ["chat"] },
{
label: "control",
routes: ["overview", "activity", "workboard", "instances", "sessions", "usage", "cron"],
},
{ label: "agent", routes: ["agents", "skills", "skill-workshop", "nodes", "dreams"] },
{ label: "settings", routes: ["config"] },
] as const satisfies readonly SidebarSection[];
export const SETTINGS_NAVIGATION_ROUTES = [
"config",
"channels",
"communications",
"appearance",
"automation",
"mcp",
"infrastructure",
"ai-agents",
"debug",
"logs",
] as const satisfies readonly NavigationRouteId[];
const NAVIGATION_ICONS: NavigationItem = {
agents: "folder",
activity: "activity",
overview: "barChart",
workboard: "folder",
channels: "link",
instances: "radio",
sessions: "fileText",
usage: "barChart",
cron: "loader",
skills: "zap",
"skill-workshop": "wrench",
nodes: "monitor",
chat: "messageSquare",
config: "settings",
communications: "send",
appearance: "spark",
automation: "terminal",
mcp: "wrench",
infrastructure: "globe",
"ai-agents": "brain",
debug: "bug",
logs: "scrollText",
dreams: "moon",
};
export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean {
return (SETTINGS_NAVIGATION_ROUTES as readonly NavigationRouteId[]).includes(routeId);
}
export function isRouteInSidebarSection(
section: SidebarSection,
routeId: NavigationRouteId,
): boolean {
if (section.label === "settings") {
return isSettingsNavigationRoute(routeId);
}
return section.routes.includes(routeId);
}
export function navigationIconForRoute(routeId: NavigationRouteId): IconName {
return NAVIGATION_ICONS[routeId] ?? "folder";
}
export function scheduleRoutePreload<TRouteId extends string>(
timers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>,
routeId: TRouteId,
event: Event,
preload: ((routeId: TRouteId) => Promise<void> | void) | undefined,
disabled = false,
immediate = false,
) {
if (disabled || !preload) {
return;
}
const target = event.currentTarget;
if (!target) {
return;
}
const start = () => {
timers.delete(target);
try {
void Promise.resolve(preload(routeId)).catch(() => undefined);
} catch {
// Preloading is opportunistic; navigation still handles real route errors.
}
};
if (immediate) {
cancelRoutePreload(timers, event);
start();
return;
}
if (!timers.has(target)) {
timers.set(target, globalThis.setTimeout(start, 50));
}
}
export function cancelRoutePreload(
timers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>,
event: Event,
) {
const target = event.currentTarget;
if (!target) {
return;
}
const timer = timers.get(target);
if (timer !== undefined) {
globalThis.clearTimeout(timer);
timers.delete(target);
}
}
const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey: string }> = {
agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" },
activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" },
overview: { titleKey: "tabs.overview", subtitleKey: "subtitles.overview" },
workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" },
channels: { titleKey: "tabs.channels", subtitleKey: "subtitles.channels" },
instances: { titleKey: "tabs.instances", subtitleKey: "subtitles.instances" },
sessions: { titleKey: "tabs.sessions", subtitleKey: "subtitles.sessions" },
usage: { titleKey: "tabs.usage", subtitleKey: "subtitles.usage" },
cron: { titleKey: "tabs.cron", subtitleKey: "subtitles.cron" },
skills: { titleKey: "tabs.skills", subtitleKey: "subtitles.skills" },
"skill-workshop": {
titleKey: "tabs.skillWorkshop",
subtitleKey: "subtitles.skillWorkshop",
},
nodes: { titleKey: "tabs.nodes", subtitleKey: "subtitles.nodes" },
chat: { titleKey: "tabs.chat", subtitleKey: "subtitles.chat" },
config: { titleKey: "nav.settings", subtitleKey: "subtitles.config" },
communications: {
titleKey: "tabs.communications",
subtitleKey: "subtitles.communications",
},
appearance: { titleKey: "tabs.appearance", subtitleKey: "subtitles.appearance" },
automation: { titleKey: "tabs.automation", subtitleKey: "subtitles.automation" },
mcp: { titleKey: "tabs.mcp", subtitleKey: "subtitles.mcp" },
infrastructure: { titleKey: "tabs.infrastructure", subtitleKey: "subtitles.infrastructure" },
"ai-agents": { titleKey: "tabs.aiAgents", subtitleKey: "subtitles.aiAgents" },
debug: { titleKey: "tabs.debug", subtitleKey: "subtitles.debug" },
logs: { titleKey: "tabs.logs", subtitleKey: "subtitles.logs" },
dreams: { titleKey: "tabs.dreams", subtitleKey: "subtitles.dreams" },
};
export function titleForRoute(routeId: NavigationRouteId): string {
return t(NAVIGATION_COPY[routeId].titleKey);
}
export function subtitleForRoute(routeId: NavigationRouteId): string {
return t(NAVIGATION_COPY[routeId].subtitleKey);
}
+108
View File
@@ -0,0 +1,108 @@
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
import type { RouteLocation } from "@openclaw/uirouter";
export const APP_ROUTE_DEFINITIONS = {
chat: { path: "/chat" },
overview: { path: "/overview" },
activity: { path: "/activity" },
agents: { path: "/agents" },
channels: { path: "/channels" },
config: { path: "/config" },
communications: { path: "/communications" },
appearance: { path: "/appearance" },
automation: { path: "/automation" },
mcp: { path: "/mcp" },
infrastructure: { path: "/infrastructure" },
"ai-agents": { path: "/ai-agents" },
workboard: { path: "/workboard" },
instances: { path: "/instances" },
sessions: { path: "/sessions" },
usage: { path: "/usage" },
debug: { path: "/debug" },
logs: { path: "/logs" },
"skill-workshop": { path: "/skills/workshop" },
skills: { path: "/skills" },
cron: { path: "/cron" },
nodes: { path: "/nodes" },
dreams: { path: "/dreaming", aliases: ["/dreams"] },
} as const;
export type RouteId = keyof typeof APP_ROUTE_DEFINITIONS;
export const APP_ROUTE_IDS = Object.keys(APP_ROUTE_DEFINITIONS) as RouteId[];
export function isRouteId(routeId: string): routeId is RouteId {
return routeId in APP_ROUTE_DEFINITIONS;
}
export function normalizeBasePath(basePath: string): string {
return normalizeRouteBasePath(basePath);
}
export function normalizePath(path: string): string {
return normalizeRoutePath(path);
}
export function pathForRoute(routeId: RouteId, basePath = ""): string {
const normalizedBasePath = normalizeBasePath(basePath);
const path = APP_ROUTE_DEFINITIONS[routeId].path;
return normalizedBasePath ? `${normalizedBasePath}${path}` : path;
}
export function routeIdFromPath(pathname: string, basePath = ""): RouteId | null {
const normalizedPath = normalizePath(pathname);
const normalizedBasePath = normalizeBasePath(basePath);
const routePath = normalizedBasePath
? normalizedPath.slice(normalizedBasePath.length) || "/"
: normalizedPath;
for (const routeId of APP_ROUTE_IDS) {
const definition = APP_ROUTE_DEFINITIONS[routeId];
const paths: readonly string[] =
"aliases" in definition ? [definition.path, ...definition.aliases] : [definition.path];
if (paths.some((candidate) => normalizePath(candidate) === routePath)) {
return routeId;
}
}
return null;
}
export function inferBasePathFromPathname(pathname: string): string {
const isMountRoot = pathname.trim().endsWith("/");
const normalizedPath = normalizePath(pathname);
if (normalizedPath.toLowerCase().endsWith("/index.html")) {
return normalizeBasePath(normalizedPath.slice(0, -"/index.html".length));
}
if (normalizedPath === "/") {
return "";
}
const segments = normalizedPath.split("/").filter(Boolean);
const routePaths = APP_ROUTE_IDS.flatMap((routeId) => {
const definition = APP_ROUTE_DEFINITIONS[routeId];
const paths: string[] = [definition.path];
if ("aliases" in definition) {
paths.push(...definition.aliases);
}
return paths;
});
for (let index = 0; index < segments.length; index += 1) {
const candidate = `/${segments.slice(index).join("/")}`;
const routePath = routePaths.find((path) => normalizePath(path) === candidate);
if (!routePath) {
continue;
}
const previousSegment = segments[index - 1];
const firstRouteSegment = routePath.split("/").find(Boolean);
if (index > 0 && previousSegment === firstRouteSegment && candidate === routePath) {
return "";
}
return index ? `/${segments.slice(0, index).join("/")}` : "";
}
return isMountRoot && segments.length ? `/${segments.join("/")}` : "";
}
export function locationForRoute(routeId: RouteId, basePath: string): RouteLocation {
return {
pathname: pathForRoute(routeId, basePath),
search: "",
hash: "",
};
}
+99
View File
@@ -0,0 +1,99 @@
import { createRouter } from "@openclaw/uirouter";
import type { PageDefinition, Router, RouterHistory } from "@openclaw/uirouter";
import { routeIdFromPath, type RouteId } from "./app-route-paths.ts";
import type { ApplicationContext } from "./app/context.ts";
import { page as activityPage } from "./pages/activity/route.ts";
import { page as agentsPage } from "./pages/agents/route.ts";
import { page as channelsPage } from "./pages/channels/route.ts";
import { page as chatPage } from "./pages/chat/route.ts";
import { pages as configPages } from "./pages/config/route.ts";
import { page as cronPage } from "./pages/cron/route.ts";
import { page as debugPage } from "./pages/debug/route.ts";
import { page as dreamsPage } from "./pages/dreams/route.ts";
import { page as instancesPage } from "./pages/instances/route.ts";
import { page as logsPage } from "./pages/logs/route.ts";
import { page as nodesPage } from "./pages/nodes/route.ts";
import { page as overviewPage } from "./pages/overview/route.ts";
import { page as sessionsPage } from "./pages/sessions/route.ts";
import { page as skillWorkshopPage } from "./pages/skill-workshop/route.ts";
import { page as skillsPage } from "./pages/skills/route.ts";
import { page as usagePage } from "./pages/usage/route.ts";
import { page as workboardPage } from "./pages/workboard/route.ts";
export type AppRouteModule = {
render: (data: unknown) => unknown;
};
export type ApplicationRouter = Router<
RouteId,
ApplicationContext<RouteId>,
AppRouteModule,
unknown
>;
export type AppRoute = PageDefinition<RouteId, ApplicationContext<RouteId>, AppRouteModule>;
export const APP_ROUTE_TREE = [
chatPage,
overviewPage,
activityPage,
agentsPage,
channelsPage,
...configPages,
workboardPage,
instancesPage,
sessionsPage,
usagePage,
debugPage,
logsPage,
skillWorkshopPage,
skillsPage,
cronPage,
nodesPage,
dreamsPage,
] as const;
const appRoutes = APP_ROUTE_TREE as readonly AppRoute[];
export function createApplicationRouter(): ApplicationRouter {
return createRouter<RouteId, ApplicationContext<RouteId>, AppRouteModule>({
routes: appRoutes,
});
}
export async function startApplicationRouter(
router: ApplicationRouter,
history: RouterHistory,
basePath: string,
context: ApplicationContext<RouteId>,
): Promise<void> {
const location = history.location();
if (routeIdFromPath(location.pathname, basePath) === null) {
history.replace({
...location,
pathname: router.pathForRoute("chat", basePath),
});
}
await router.start(history, basePath, context);
}
export function startAppRouter(
router: ApplicationRouter,
history: RouterHistory,
basePath: string,
context: ApplicationContext<RouteId>,
): Promise<void> {
return startApplicationRouter(router, history, basePath, context);
}
export {
APP_ROUTE_DEFINITIONS,
APP_ROUTE_IDS,
inferBasePathFromPathname,
isRouteId,
locationForRoute,
normalizeBasePath,
normalizePath,
pathForRoute,
routeIdFromPath,
type RouteId,
} from "./app-route-paths.ts";
+62
View File
@@ -0,0 +1,62 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
type AgentSelectionGateway = {
readonly snapshot: {
client: GatewayBrowserClient | null;
assistantAgentId: string | null;
};
subscribe: (listener: (snapshot: AgentSelectionGateway["snapshot"]) => void) => () => void;
};
export type AgentSelectionState = {
selectedId: string | null;
};
export type AgentSelectionCapability = {
readonly state: AgentSelectionState;
set: (agentId: string | null) => void;
subscribe: (listener: (state: AgentSelectionState) => void) => () => void;
};
export function createAgentSelectionCapability(
gateway: AgentSelectionGateway,
): AgentSelectionCapability {
let state: AgentSelectionState = {
selectedId: gateway.snapshot.assistantAgentId
? normalizeAgentId(gateway.snapshot.assistantAgentId)
: null,
};
let client = gateway.snapshot.client;
const listeners = new Set<(next: AgentSelectionState) => void>();
const publish = (selectedId: string | null) => {
if (state.selectedId === selectedId) {
return;
}
state = { selectedId };
for (const listener of listeners) {
listener(state);
}
};
gateway.subscribe((next) => {
if (next.client !== client) {
client = next.client;
publish(next.assistantAgentId ? normalizeAgentId(next.assistantAgentId) : null);
}
});
return {
get state() {
return state;
},
set(agentId) {
publish(agentId?.trim() ? normalizeAgentId(agentId) : null);
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
+668
View File
@@ -0,0 +1,668 @@
import { consume, ContextProvider } from "@lit/context";
import type { RouteLocation, RouterState } from "@openclaw/uirouter";
import { html, LitElement, nothing } from "lit";
import { property, query, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { AgentsListResult } from "../api/types.ts";
import "../components/app-sidebar.ts";
import "../components/app-topbar.ts";
import "../components/exec-approval.ts";
import "../components/gateway-url-confirmation.ts";
import "../components/login-gate.ts";
import "../components/terminal/terminal-panel.ts";
import "../components/tooltip.ts";
import "../components/update-banner.ts";
import { APP_ROUTE_IDS, isRouteId, pathForRoute, type RouteId } from "../app-routes.ts";
import {
COMMAND_PALETTE_TARGET_EVENT,
type CommandPalette,
type CommandPaletteTargetDetail,
} from "../components/command-palette.ts";
import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationNavigationOptions,
} from "./context.ts";
import { hasOperatorAdminAccess } from "./operator-access.ts";
import type { ApplicationOverlaySnapshot } from "./overlays.ts";
import { selectRenderedRouteMatch } from "./router-outlet.ts";
type ShellRouteState = {
routeId?: RouteId;
location?: RouteLocation;
};
function selectShellRouteState(routerState: RouterState<RouteId>): ShellRouteState {
const match = selectRenderedRouteMatch(routerState.matches[0], routerState.pendingMatches[0]);
return match
? {
routeId: match.routeId,
location: match.location,
}
: {};
}
function equalShellRouteState(previous: ShellRouteState, next: ShellRouteState): boolean {
return (
previous.routeId === next.routeId &&
previous.location?.pathname === next.location?.pathname &&
previous.location?.search === next.location?.search &&
previous.location?.hash === next.location?.hash
);
}
function resolveAgentLabel(sessionKey: string, agentsList: AgentsListResult | null): string {
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const agent = agentsList?.agents.find(
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === agentId,
);
return (
normalizeOptionalString(agent?.identity?.name) ??
normalizeOptionalString(agent?.name) ??
agentId
);
}
function resolveOnboardingMode(): boolean {
const raw = new URLSearchParams(globalThis.location?.search ?? "").get("onboarding");
return raw !== null && /^(?:1|true|yes|on)$/iu.test(raw.trim());
}
function resolveTerminalThemeMode(): "dark" | "light" {
return document.documentElement.dataset.themeMode === "light" ? "light" : "dark";
}
function isTerminalAvailable(
snapshot: ApplicationContext["gateway"]["snapshot"],
terminalEnabled: boolean,
): boolean {
if (!snapshot.connected || !terminalEnabled) {
return false;
}
return (
hasOperatorAdminAccess(snapshot.hello?.auth ?? null) &&
isGatewayMethodAdvertised(snapshot, "terminal.open") === true
);
}
export class OpenClawApp extends LitElement {
@state() private gatewayConnected = false;
@state() private gatewayLastError: string | null = null;
@state() private gatewayLastErrorCode: string | null = null;
@state() private loginGatewayUrl = "";
@state() private loginToken = "";
@state() private loginPassword = "";
@state() private loginShowGatewayToken = false;
@state() private loginShowGatewayPassword = false;
@state() private pendingGatewayUrl: string | null = null;
@state() private onboarding = resolveOnboardingMode();
private runtime: ApplicationRuntime | undefined;
private context: ApplicationContext<RouteId> | undefined;
private readonly contextProvider = new ContextProvider(this, {
context: applicationContext,
});
private stopGatewaySubscription: (() => void) | undefined;
override createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
this.runtime = bootstrapApplication();
this.context = this.runtime.context;
this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null;
this.contextProvider.setValue(this.context);
this.syncLoginConnection();
let gatewayClient = this.context.gateway.snapshot.client;
this.updateGatewayStatus(this.context.gateway.snapshot);
this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => {
if (snapshot.client !== gatewayClient) {
gatewayClient = snapshot.client;
this.syncLoginConnection();
}
this.updateGatewayStatus(snapshot);
});
void this.runtime.start().catch((error: unknown) => {
console.error("[openclaw] application start failed", error);
});
}
override disconnectedCallback() {
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.runtime?.stop();
this.runtime = undefined;
this.context = undefined;
this.pendingGatewayUrl = null;
super.disconnectedCallback();
}
private syncLoginConnection() {
const connection = this.context?.gateway.connection;
if (!connection) {
return;
}
this.loginGatewayUrl = connection.gatewayUrl;
this.loginToken = connection.token;
this.loginPassword = connection.password;
}
private readonly updateGatewayStatus = (snapshot: {
connected: boolean;
lastError: string | null;
lastErrorCode: string | null;
}) => {
this.gatewayConnected = snapshot.connected;
this.gatewayLastError = snapshot.lastError;
this.gatewayLastErrorCode = snapshot.lastErrorCode;
};
override render() {
const context = this.context;
const runtime = this.runtime;
if (!context || !runtime) {
return html`<main class="app-shell app-shell--booting" aria-busy="true"></main>`;
}
const gatewayUrlConfirmation = this.pendingGatewayUrl
? html`
<openclaw-gateway-url-confirmation
.props=${{
pendingGatewayUrl: this.pendingGatewayUrl,
onConfirm: () => {
runtime.confirmPendingGatewayConnection();
this.pendingGatewayUrl = null;
},
onCancel: () => {
runtime.cancelPendingGatewayConnection();
this.pendingGatewayUrl = null;
},
}}
></openclaw-gateway-url-confirmation>
`
: nothing;
if (!this.gatewayConnected) {
return html`
<openclaw-tooltip-provider>
<openclaw-login-gate
.props=${{
basePath: context.basePath,
connected: this.gatewayConnected,
lastError: this.gatewayLastError,
lastErrorCode: this.gatewayLastErrorCode,
hasToken: Boolean(this.loginToken.trim()),
hasPassword: Boolean(this.loginPassword.trim()),
gatewayUrl: this.loginGatewayUrl,
token: this.loginToken,
password: this.loginPassword,
showGatewayToken: this.loginShowGatewayToken,
showGatewayPassword: this.loginShowGatewayPassword,
onGatewayUrlChange: (value: string) => {
this.loginGatewayUrl = value;
},
onTokenChange: (value: string) => {
this.loginToken = value;
},
onPasswordChange: (value: string) => {
this.loginPassword = value;
},
onToggleGatewayToken: () => {
this.loginShowGatewayToken = !this.loginShowGatewayToken;
},
onToggleGatewayPassword: () => {
this.loginShowGatewayPassword = !this.loginShowGatewayPassword;
},
onConnect: () => {
context.gateway.connect({
gatewayUrl: this.loginGatewayUrl,
token: this.loginToken,
password: this.loginPassword,
});
},
}}
></openclaw-login-gate>
${gatewayUrlConfirmation}
</openclaw-tooltip-provider>
`;
}
return html`
<openclaw-tooltip-provider>
${gatewayUrlConfirmation}
<openclaw-app-shell .runtime=${runtime} .onboarding=${this.onboarding}></openclaw-app-shell>
</openclaw-tooltip-provider>
`;
}
}
class OpenClawShell extends LitElement {
@property({ attribute: false }) runtime?: ApplicationRuntime;
@property({ attribute: false }) onboarding = false;
@consume({ context: applicationContext, subscribe: false })
private context?: ApplicationContext<RouteId>;
@state() private navCollapsed = false;
@state() private navGroupsCollapsed: Record<string, boolean> = {};
@state() private recentSessionsCollapsed = false;
@state() private navDrawerOpen = false;
@state() private gatewayConnected = false;
@state() private terminalAvailable = false;
@state() private terminalClient: GatewayBrowserClient | null = null;
@state() private activeSessionKey = "";
@state() private agentLabel = "";
@state() private routeState: ShellRouteState = {};
@state() private overlaySnapshot: ApplicationOverlaySnapshot = {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
approvalQueue: [],
approvalBusy: false,
approvalError: null,
};
@query("openclaw-command-palette") private commandPalette?: CommandPalette;
private commandPaletteTarget?: CommandPaletteTargetDetail;
private navDrawerTrigger: HTMLElement | null = null;
private agentsListClient: GatewayBrowserClient | null = null;
private sessionKeyClient: GatewayBrowserClient | null = null;
private stopAgentsSubscription: (() => void) | undefined;
private stopConfigSubscription: (() => void) | undefined;
private stopGatewaySubscription: (() => void) | undefined;
private stopNavigationSubscription: (() => void) | undefined;
private stopRouteSubscription: (() => void) | undefined;
private stopOverlaySubscription: (() => void) | undefined;
private stopThemeSubscription: (() => void) | undefined;
override createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
this.startSubscriptions();
this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
}
override updated() {
this.startSubscriptions();
}
private startSubscriptions() {
const runtime = this.runtime;
const context = this.context;
if (
!runtime ||
!context ||
this.stopAgentsSubscription ||
this.stopConfigSubscription ||
this.stopGatewaySubscription ||
this.stopNavigationSubscription ||
this.stopRouteSubscription ||
this.stopOverlaySubscription ||
this.stopThemeSubscription
) {
return;
}
this.updateNavigationPreferences(context.navigation.snapshot);
this.stopNavigationSubscription = context.navigation.subscribe((snapshot) => {
this.updateNavigationPreferences(snapshot);
});
this.updateGatewaySessionKey(context.gateway.snapshot);
this.updateGatewayStatus(context.gateway.snapshot);
this.updateTerminalSurface(context.gateway.snapshot);
this.updateAgentLabel();
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
this.updateGatewaySessionKey(snapshot);
this.updateGatewayStatus(snapshot);
this.updateTerminalSurface(snapshot);
this.updateAgentLabel();
this.ensureAgentsList(snapshot);
});
this.stopConfigSubscription = context.config.subscribe(() => {
this.updateTerminalSurface(context.gateway.snapshot);
});
this.stopThemeSubscription = context.theme.subscribe(() => this.requestUpdate());
this.stopAgentsSubscription = context.agents.subscribe(() => {
this.updateAgentLabel();
});
this.updateRouteState(selectShellRouteState(runtime.router.getState()));
this.stopRouteSubscription = runtime.router.subscribeSelector(
selectShellRouteState,
(routeState) => {
this.updateRouteState(routeState);
},
equalShellRouteState,
);
this.overlaySnapshot = context.overlays.snapshot;
this.stopOverlaySubscription = context.overlays.subscribe((snapshot) => {
this.overlaySnapshot = snapshot;
});
}
override disconnectedCallback() {
this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopConfigSubscription?.();
this.stopConfigSubscription = undefined;
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopNavigationSubscription?.();
this.stopNavigationSubscription = undefined;
this.stopRouteSubscription?.();
this.stopRouteSubscription = undefined;
this.stopOverlaySubscription?.();
this.stopOverlaySubscription = undefined;
this.stopThemeSubscription?.();
this.stopThemeSubscription = undefined;
this.agentsListClient = null;
this.sessionKeyClient = null;
this.terminalClient = null;
this.navDrawerTrigger = null;
super.disconnectedCallback();
}
private readonly handleThemeChange = (event: CustomEvent<ThemeModeChangeDetail>) => {
const context = this.context;
if (!context) {
return;
}
context.theme.setMode(event.detail.mode, event.detail.element);
this.requestUpdate();
};
private chatNavigationOptions(options?: ApplicationNavigationOptions) {
if (options) {
return options;
}
const sessionKey = this.activeSessionKey.trim();
return sessionKey ? { search: searchForSession(sessionKey) } : undefined;
}
private navigate(routeId: string, options?: ApplicationNavigationOptions) {
const context = this.context;
if (!context || !isRouteId(routeId)) {
return;
}
this.closeNavDrawer({ restoreFocus: true });
context.navigate(routeId, routeId === "chat" ? this.chatNavigationOptions(options) : options);
}
private replaceChatWithCurrentSession() {
this.context?.replace("chat", this.chatNavigationOptions());
}
private toggleNavDrawer(trigger: HTMLElement) {
if (this.navDrawerOpen) {
this.closeNavDrawer({ restoreFocus: true });
return;
}
this.navDrawerTrigger = trigger;
this.navDrawerOpen = true;
}
private closeNavDrawer(options: { restoreFocus?: boolean } = {}) {
const focusTarget = options.restoreFocus ? this.navDrawerTrigger : null;
this.navDrawerOpen = false;
this.navDrawerTrigger = null;
if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) {
return;
}
requestAnimationFrame(() => {
if (focusTarget.isConnected) {
focusTarget.focus();
}
});
}
private readonly handleShellKeydown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.key !== "Escape" || !this.navDrawerOpen) {
return;
}
event.preventDefault();
this.closeNavDrawer({ restoreFocus: true });
};
private readonly openPalette = () => {
this.commandPalette?.openPalette();
};
private readonly handleCommandPaletteSlashCommand = (command: string) => {
const chatHandler = this.commandPaletteTarget?.owner.isConnected
? this.commandPaletteTarget.onSlashCommand
: null;
if (chatHandler) {
chatHandler(command);
return;
}
// Keep Chat's in-place draft path fast; other routes hand the draft through navigation.
const search = new URLSearchParams(this.chatNavigationOptions()?.search);
search.set("draft", command.endsWith(" ") ? command : `${command} `);
this.navigate("chat", { search: `?${search.toString()}` });
};
private readonly handleCommandPaletteTarget = (event: Event) => {
const detail = (event as CustomEvent<CommandPaletteTargetDetail>).detail;
if (!detail || !(detail.owner instanceof Element)) {
return;
}
if (detail.onSlashCommand) {
this.commandPaletteTarget = detail;
} else if (this.commandPaletteTarget?.owner === detail.owner) {
this.commandPaletteTarget = undefined;
}
this.requestUpdate();
};
private readonly updateGatewayStatus = (snapshot: { connected: boolean }) => {
if (snapshot.connected === this.gatewayConnected) {
return;
}
this.gatewayConnected = snapshot.connected;
};
private updateTerminalSurface(snapshot: ApplicationContext["gateway"]["snapshot"]) {
this.terminalClient = snapshot.connected ? snapshot.client : null;
this.terminalAvailable = isTerminalAvailable(
snapshot,
this.context?.config.current.terminalEnabled ?? false,
);
}
private ensureAgentsList(snapshot: { client: GatewayBrowserClient | null; connected: boolean }) {
if (!snapshot.connected || !snapshot.client) {
this.agentsListClient = null;
return;
}
const routeId = this.routeState.routeId;
if (!routeId || routeId === "chat" || this.context?.agents.state.agentsList) {
return;
}
if (this.agentsListClient === snapshot.client) {
return;
}
this.agentsListClient = snapshot.client;
void this.context?.agents.ensureList();
}
private updateGatewaySessionKey(snapshot: {
client: GatewayBrowserClient | null;
sessionKey: string;
}) {
const sessionKey = snapshot.sessionKey.trim();
if (snapshot.client === this.sessionKeyClient && sessionKey === this.activeSessionKey) {
return;
}
this.sessionKeyClient = snapshot.client;
if (sessionKey) {
this.activeSessionKey = sessionKey;
}
}
private updateRouteState(routeState: ShellRouteState) {
this.routeState = routeState;
const context = this.context;
if (context) {
this.ensureAgentsList(context.gateway.snapshot);
}
if (routeState.routeId !== "chat") {
return;
}
const sessionKey = new URLSearchParams(routeState.location?.search).get("session")?.trim();
if (sessionKey) {
this.activeSessionKey = sessionKey;
this.updateAgentLabel();
}
}
private updateAgentLabel() {
const context = this.context;
if (!context) {
return;
}
this.agentLabel = resolveAgentLabel(
this.activeSessionKey || context.gateway.snapshot.sessionKey,
context.agents.state.agentsList,
);
}
private readonly updateNavigationPreferences = (
snapshot: ApplicationRuntime["context"]["navigation"]["snapshot"],
) => {
this.navCollapsed = snapshot.navCollapsed;
this.navGroupsCollapsed = snapshot.navGroupsCollapsed;
this.recentSessionsCollapsed = snapshot.recentSessionsCollapsed;
};
override render() {
const context = this.context;
const runtime = this.runtime;
if (!context || !runtime) {
return nothing;
}
const activeRoute = this.routeState.routeId ?? "chat";
const navDrawerOpen = this.navDrawerOpen && !this.onboarding;
const navCollapsed = this.navCollapsed && !navDrawerOpen;
return html`
<openclaw-command-palette
.onNavigate=${(routeId: RouteId) => this.navigate(routeId)}
.onSlashCommand=${this.handleCommandPaletteSlashCommand}
></openclaw-command-palette>
<div
class="shell ${activeRoute === "chat" ? "shell--chat" : ""} ${navCollapsed
? "shell--nav-collapsed"
: ""} ${navDrawerOpen ? "shell--nav-drawer-open" : ""} ${this.onboarding
? "shell--onboarding"
: ""}"
@keydown=${this.handleShellKeydown}
@theme-change=${this.handleThemeChange}
>
<button
type="button"
class="shell-nav-backdrop"
aria-label="Close navigation"
@click=${() => this.closeNavDrawer({ restoreFocus: true })}
></button>
<openclaw-app-topbar
.routeId=${activeRoute}
.basePath=${context.basePath}
.agentLabel=${this.agentLabel}
.overviewHref=${pathForRoute("overview", context.basePath)}
.searchDisabled=${false}
.navDrawerOpen=${navDrawerOpen}
.themeMode=${context.theme.mode}
.onboarding=${this.onboarding}
.onOpenPalette=${this.openPalette}
.terminalAvailable=${this.terminalAvailable}
.onToggleTerminal=${() =>
window.dispatchEvent(new CustomEvent("openclaw:terminal-toggle"))}
.onToggleDrawer=${(trigger: HTMLElement) => this.toggleNavDrawer(trigger)}
.onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
this.navigate(routeId, options)}
></openclaw-app-topbar>
<div class="shell-nav">
<openclaw-app-sidebar
.basePath=${context.basePath}
.activeRouteId=${activeRoute}
.enabledRouteIds=${APP_ROUTE_IDS}
.sessionKey=${this.activeSessionKey}
.collapsed=${navCollapsed}
.connected=${this.gatewayConnected}
.navGroupsCollapsed=${this.navGroupsCollapsed}
.recentSessionsCollapsed=${this.recentSessionsCollapsed}
.themeMode=${context.theme.mode}
.onToggleCollapsed=${() => {
if (navDrawerOpen) {
this.closeNavDrawer({ restoreFocus: true });
return;
}
context.navigation.update({
navCollapsed: !navCollapsed,
});
}}
.onToggleGroup=${(label: string) => {
const current = context.navigation.snapshot.navGroupsCollapsed[label] ?? false;
context.navigation.update({
navGroupsCollapsed: {
...context.navigation.snapshot.navGroupsCollapsed,
[label]: !current,
},
});
}}
.onToggleRecentSessions=${() =>
context.navigation.update({
recentSessionsCollapsed: !context.navigation.snapshot.recentSessionsCollapsed,
})}
.onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
this.navigate(routeId, options)}
.onPreloadRoute=${(routeId: string) =>
isRouteId(routeId) ? context.preload(routeId) : Promise.resolve()}
></openclaw-app-sidebar>
</div>
<main class="content ${activeRoute === "chat" ? "content--chat" : ""}">
<openclaw-update-banner
.props=${{
statusBanner: this.overlaySnapshot.updateStatusBanner,
updateAvailable: this.overlaySnapshot.updateAvailable,
updateRunning: this.overlaySnapshot.updateRunning,
connected: this.gatewayConnected,
onUpdate: () => context.overlays.runUpdate(),
onDismiss: () => context.overlays.dismissUpdate(),
}}
></openclaw-update-banner>
<openclaw-router-outlet
.router=${runtime.router}
.retryContext=${context}
.onNotFound=${() => this.replaceChatWithCurrentSession()}
></openclaw-router-outlet>
</main>
<openclaw-terminal-panel
.client=${this.terminalClient}
.available=${this.terminalAvailable}
.themeMode=${resolveTerminalThemeMode()}
></openclaw-terminal-panel>
<openclaw-exec-approval
.props=${{
queue: this.overlaySnapshot.approvalQueue,
busy: this.overlaySnapshot.approvalBusy,
error: this.overlaySnapshot.approvalError,
onDecision: (decision: Parameters<typeof context.overlays.decideApproval>[0]) =>
context.overlays.decideApproval(decision),
}}
></openclaw-exec-approval>
</div>
`;
}
}
if (!customElements.get("openclaw-app")) {
customElements.define("openclaw-app", OpenClawApp);
}
if (!customElements.get("openclaw-app-shell")) {
customElements.define("openclaw-app-shell", OpenClawShell);
}
+118
View File
@@ -0,0 +1,118 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { normalizeAssistantIdentity, type AssistantIdentity } from "../lib/assistant-identity.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
export type LocalAssistantIdentity = { avatar: string | null; agentId?: string | null };
type PersistedLocalAssistantIdentities = {
avatars?: Record<string, unknown>;
avatar?: unknown;
agentId?: unknown;
};
function parseLocalAssistantAvatarMap(raw: string): {
avatars: Record<string, string>;
legacyAvatar: string | null;
} {
const parsed = JSON.parse(raw) as PersistedLocalAssistantIdentities;
const avatars = Object.create(null) as Record<string, string>;
if (parsed.avatars && typeof parsed.avatars === "object" && !Array.isArray(parsed.avatars)) {
for (const [agentId, avatar] of Object.entries(parsed.avatars)) {
const normalizedAgentId = normalizeOptionalString(agentId);
const normalizedAvatar = normalizeOptionalString(avatar);
if (normalizedAgentId && normalizedAvatar) {
avatars[normalizedAgentId] = normalizedAvatar;
}
}
}
const legacyAvatar = normalizeOptionalString(parsed.avatar);
const legacyAgentId = normalizeOptionalString(parsed.agentId);
if (legacyAvatar && legacyAgentId && !Object.hasOwn(avatars, legacyAgentId)) {
avatars[legacyAgentId] = legacyAvatar;
}
return { avatars, legacyAvatar: legacyAgentId ? null : (legacyAvatar ?? null) };
}
function persistLocalAssistantAvatarMap(storage: Storage | null, avatars: Record<string, string>) {
if (Object.keys(avatars).length === 0) {
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
return;
}
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatars }));
}
export function loadLocalAssistantIdentity(opts?: {
agentId?: string | null;
}): LocalAssistantIdentity {
const agentId = normalizeOptionalString(opts?.agentId);
if (!agentId) {
return { avatar: null };
}
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
if (!raw) {
return { avatar: null };
}
const { avatars, legacyAvatar } = parseLocalAssistantAvatarMap(raw);
if (!Object.hasOwn(avatars, agentId) && legacyAvatar) {
// Assign the old global override to the first concrete agent that loads it.
avatars[agentId] = legacyAvatar;
persistLocalAssistantAvatarMap(storage, avatars);
}
return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId };
} catch {
return { avatar: null };
}
}
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
const agentId = normalizeOptionalString(next.agentId);
if (!agentId) {
return;
}
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
const avatars = raw
? parseLocalAssistantAvatarMap(raw).avatars
: (Object.create(null) as Record<string, string>);
const avatar = normalizeOptionalString(next.avatar);
if (avatar) {
avatars[agentId] = avatar;
} else {
delete avatars[agentId];
}
persistLocalAssistantAvatarMap(storage, avatars);
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
}
export async function fetchAssistantIdentity(
client: GatewayBrowserClient,
sessionKey?: string,
): Promise<AssistantIdentity | null> {
const result = await client.request<Partial<AssistantIdentity>>(
"agent.identity.get",
sessionKey?.trim() ? { sessionKey: sessionKey.trim() } : {},
);
if (!result) {
return null;
}
const identity = normalizeAssistantIdentity(result);
const localAvatar = loadLocalAssistantIdentity({ agentId: identity.agentId }).avatar;
return localAvatar
? {
...identity,
avatar: localAvatar,
avatarSource: localAvatar,
avatarStatus: "data",
avatarReason: null,
}
: identity;
}
+630
View File
@@ -0,0 +1,630 @@
import type { RouteLocation } from "@openclaw/uirouter";
import type { EventLogEntry } from "../api/event-log.ts";
import {
GatewayBrowserClient,
type GatewayEventListener,
type GatewayHelloOk,
} from "../api/gateway.ts";
import {
createApplicationRouter,
inferBasePathFromPathname,
locationForRoute,
normalizeBasePath,
pathForRoute,
routeIdFromPath,
startApplicationRouter,
type ApplicationRouter,
type RouteId,
} from "../app-routes.ts";
import { createAgentIdentityCapability } from "../lib/agents/identity.ts";
import { createAgentCapability } from "../lib/agents/index.ts";
import { createChannelCapability } from "../lib/channels/index.ts";
import { createRuntimeConfigCapability } from "../lib/config/index.ts";
import { createSessionCapability, resolveSessionKey } from "../lib/sessions/index.ts";
import { generateUUID } from "../lib/uuid.ts";
import { createWorkboardCapability } from "../lib/workboard/capability.ts";
import { createAgentSelectionCapability } from "./agent-selection.ts";
import { createBrowserHistory } from "./browser.ts";
import { createApplicationConfigCapability } from "./config.ts";
import type {
ApplicationGateway,
ApplicationGatewayConnectOptions,
ApplicationGatewayConnection,
ApplicationNavigationOptions,
ApplicationGatewaySnapshot,
ApplicationContext,
ApplicationNavigationPreferences,
ApplicationNavigationPreferencesSnapshot,
ApplicationSkillWorkshopRevisionHandoff,
ApplicationTheme,
} from "./context.ts";
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
import { createNativeChatDrafts } from "./native-bridge.ts";
import { createApplicationOverlays } from "./overlays.ts";
import {
loadSettings,
patchSettings,
resolveApplicationStartupSettings,
saveSettings,
type UiSettings,
} from "./settings.ts";
import { startThemeTransition } from "./theme-transition.ts";
import { resolveTheme, type ThemeMode } from "./theme.ts";
import { createWebPushCapability } from "./web-push.ts";
function normalizeInitialApplicationLocation(
location: RouteLocation,
basePath: string,
sessionKey: string,
) {
const routeId = routeIdFromPath(location.pathname, basePath);
if ((routeId !== null && routeId !== "chat") || !sessionKey.trim()) {
return location;
}
const search = new URLSearchParams(location.search);
if (!search.get("session")?.trim()) {
search.set("session", sessionKey);
}
return {
...location,
pathname: routeId === null ? pathForRoute("chat", basePath) : location.pathname,
search: `?${search.toString()}`,
};
}
function applyStartupPresentation(settings: ReturnType<typeof loadSettings>): void {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const resolvedTheme = resolveTheme(settings.theme, settings.themeMode);
root.dataset.theme = resolvedTheme;
root.dataset.themeMode = resolvedTheme.endsWith("light") ? "light" : "dark";
root.style.colorScheme = root.dataset.themeMode;
root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`);
syncCustomThemeStyleTag(settings.customTheme);
}
function createApplicationTheme(
initialSettings: UiSettings,
): ApplicationTheme & { dispose: () => void } {
let settings = initialSettings;
let systemThemeCleanup: (() => void) | undefined;
const listeners = new Set<() => void>();
const publish = () => {
applyStartupPresentation(settings);
for (const listener of listeners) {
listener();
}
};
const detachSystemThemeListener = () => {
systemThemeCleanup?.();
systemThemeCleanup = undefined;
};
const syncSystemThemeListener = () => {
detachSystemThemeListener();
if (settings.themeMode !== "system" || typeof globalThis.matchMedia !== "function") {
return;
}
const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: light)");
const onChange = () => {
if (settings.themeMode === "system") {
publish();
}
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", onChange);
systemThemeCleanup = () => mediaQuery.removeEventListener("change", onChange);
} else if (typeof mediaQuery.addListener === "function") {
mediaQuery.addListener(onChange);
systemThemeCleanup = () => mediaQuery.removeListener(onChange);
}
};
syncSystemThemeListener();
return {
get mode() {
return settings.themeMode;
},
setMode(mode: ThemeMode, element) {
const currentSettings = loadSettings();
const nextSettings = { ...currentSettings, themeMode: mode };
const currentTheme = resolveTheme(currentSettings.theme, currentSettings.themeMode);
const nextTheme = resolveTheme(nextSettings.theme, nextSettings.themeMode);
startThemeTransition({
nextTheme,
currentTheme,
context: { element },
applyTheme: () => {
settings = patchSettings({ themeMode: mode });
publish();
syncSystemThemeListener();
},
});
},
refresh() {
settings = loadSettings();
publish();
syncSystemThemeListener();
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
detachSystemThemeListener();
listeners.clear();
},
};
}
function createApplicationNavigationPreferences(
initialSettings: UiSettings,
): ApplicationNavigationPreferences {
let settings = initialSettings;
let snapshot: ApplicationNavigationPreferencesSnapshot = {
navCollapsed: settings.navCollapsed,
navGroupsCollapsed: settings.navGroupsCollapsed,
recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false,
};
const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>();
return {
get snapshot() {
return snapshot;
},
update(patch) {
const nextSnapshot = { ...snapshot, ...patch };
if (
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed &&
nextSnapshot.navGroupsCollapsed === snapshot.navGroupsCollapsed
) {
return;
}
settings = patchSettings({
navCollapsed: nextSnapshot.navCollapsed,
navGroupsCollapsed: nextSnapshot.navGroupsCollapsed,
recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed,
});
snapshot = nextSnapshot;
for (const listener of listeners) {
listener(snapshot);
}
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
function createSkillWorkshopRevisionHandoff(): ApplicationSkillWorkshopRevisionHandoff {
let pending: Parameters<ApplicationSkillWorkshopRevisionHandoff["prepare"]>[0] | null = null;
return {
prepare: (handoff) => {
pending = handoff;
},
consume: (sessionKey) => {
if (!pending || pending.sessionKey !== sessionKey) {
return null;
}
const handoff = pending;
pending = null;
return handoff;
},
clear: () => {
pending = null;
},
};
}
function createApplicationGateway(
initialSettings: ReturnType<typeof loadSettings>,
initialPassword = "",
): ApplicationGateway {
let settings = initialSettings;
let connection: ApplicationGatewayConnection = {
gatewayUrl: settings.gatewayUrl,
token: settings.token,
password: initialPassword,
};
let snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
hello: null,
assistantAgentId: "main",
sessionKey: settings.sessionKey,
lastError: null,
lastErrorCode: null,
};
let client: GatewayBrowserClient | null = null;
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<GatewayEventListener>();
const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>();
let eventLog: EventLogEntry[] = [];
let stopClientEvents: (() => void) | undefined;
const syncClientEvents = (nextClient: GatewayBrowserClient | null) => {
stopClientEvents?.();
stopClientEvents = undefined;
if (!nextClient || eventListeners.size === 0) {
return;
}
const removers = [...eventListeners].map((listener) => nextClient.addEventListener(listener));
stopClientEvents = () => {
for (const remove of removers) {
remove();
}
};
};
const notify = () => {
for (const listener of listeners) {
listener(snapshot);
}
};
const setSnapshot = (next: ApplicationGatewaySnapshot) => {
snapshot = next;
notify();
};
const publishEventLog = () => {
for (const listener of eventLogListeners) {
listener(eventLog);
}
};
const recordGatewayEvent = (event: Parameters<GatewayEventListener>[0]) => {
eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice(
0,
250,
);
publishEventLog();
};
const connect = (overrides: ApplicationGatewayConnectOptions = {}) => {
const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides;
const nextConnection = { ...connection, ...connectionOverrides };
const hasRequestedSessionKey = requestedSessionKey !== undefined;
const nextSessionKey = hasRequestedSessionKey
? requestedSessionKey.trim()
: snapshot.sessionKey;
connection = nextConnection;
settings = patchSettings({
gatewayUrl: nextConnection.gatewayUrl,
token: nextConnection.token,
...(hasRequestedSessionKey
? {
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
}
: {}),
});
client?.stop();
stopClientEvents?.();
stopClientEvents = undefined;
const nextClient = new GatewayBrowserClient({
url: nextConnection.gatewayUrl,
token: nextConnection.token.trim() ? nextConnection.token : undefined,
password: nextConnection.password.trim() ? nextConnection.password : undefined,
clientName: "openclaw-control-ui",
clientVersion: "dev",
mode: "webchat",
instanceId: generateUUID(),
onHello: (hello: GatewayHelloOk) => {
if (client !== nextClient) {
return;
}
settings = loadSettings();
const sessionDefaults = readSessionDefaults(hello);
const sessionKey = resolveSessionKey(snapshot.sessionKey, hello);
const lastActiveSessionKey = resolveSessionKey(settings.lastActiveSessionKey, hello);
if (
sessionKey !== settings.sessionKey ||
lastActiveSessionKey !== settings.lastActiveSessionKey
) {
settings = patchSettings({
sessionKey,
lastActiveSessionKey,
});
}
setSnapshot({
...snapshot,
client: nextClient,
connected: true,
hello,
assistantAgentId: sessionDefaults?.defaultAgentId ?? "main",
sessionKey,
lastError: null,
lastErrorCode: null,
});
},
onClose: ({ code, reason, error }) => {
if (client !== nextClient) {
return;
}
setSnapshot({
...snapshot,
client: nextClient,
connected: false,
hello: null,
lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`,
lastErrorCode: error?.code ?? null,
});
},
onGap: ({ expected, received }) => {
if (client !== nextClient) {
return;
}
setSnapshot({
...snapshot,
lastError: `event gap detected (expected seq ${expected}, got ${received}); reconnecting`,
lastErrorCode: null,
});
connect();
},
onEvent: recordGatewayEvent,
});
client = nextClient;
syncClientEvents(nextClient);
setSnapshot({
...snapshot,
client: nextClient,
connected: false,
hello: null,
sessionKey: nextSessionKey,
lastError: null,
lastErrorCode: null,
});
nextClient.start();
};
const gateway: ApplicationGateway = {
get snapshot() {
return snapshot;
},
get connection() {
return connection;
},
get eventLog() {
return eventLog;
},
connect,
setSessionKey: (sessionKey) => {
const nextSessionKey = sessionKey.trim();
if (!nextSessionKey || nextSessionKey === snapshot.sessionKey) {
return;
}
settings = patchSettings({
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
});
setSnapshot({ ...snapshot, sessionKey: nextSessionKey });
},
start: () => connect(),
stop: () => {
stopClientEvents?.();
stopClientEvents = undefined;
client?.stop();
client = null;
setSnapshot({
...snapshot,
client: null,
connected: false,
hello: null,
lastError: null,
lastErrorCode: null,
});
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEventLog: (listener) => {
eventLogListeners.add(listener);
return () => eventLogListeners.delete(listener);
},
subscribeEvents: (listener) => {
eventListeners.add(listener);
syncClientEvents(client);
return () => {
if (eventListeners.delete(listener)) {
syncClientEvents(client);
}
};
},
};
return gateway;
}
function readSessionDefaults(
hello: GatewayHelloOk,
): { defaultAgentId?: string | null } | undefined {
const snapshot = hello.snapshot;
if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) {
return undefined;
}
const defaults = snapshot.sessionDefaults;
return defaults && typeof defaults === "object"
? (defaults as { defaultAgentId?: string | null })
: undefined;
}
export type ApplicationRuntime = {
readonly context: ApplicationContext<RouteId>;
readonly router: ApplicationRouter;
readonly pendingGatewayConnection: {
readonly gatewayUrl: string;
readonly token: string;
} | null;
readonly confirmPendingGatewayConnection: () => void;
readonly cancelPendingGatewayConnection: () => void;
start: () => Promise<void>;
stop: () => void;
};
export function bootstrapApplication(): ApplicationRuntime {
const initialSettings = loadSettings();
const history = createBrowserHistory();
const startup = resolveApplicationStartupSettings(initialSettings, history.location());
if (startup.changed) {
saveSettings(startup.settings);
}
const basePath = normalizeBasePath(
inferBasePathFromPathname(startup.location.pathname || globalThis.location?.pathname || "/"),
);
const initialLocation = normalizeInitialApplicationLocation(
startup.location,
basePath,
startup.settings.sessionKey,
);
const currentLocation = history.location();
if (
currentLocation.pathname !== initialLocation.pathname ||
currentLocation.search !== initialLocation.search ||
currentLocation.hash !== initialLocation.hash
) {
history.replace(initialLocation);
}
const settings = startup.settings;
const gateway = createApplicationGateway(settings, startup.password ?? "");
const agents = createAgentCapability(gateway);
const agentIdentity = createAgentIdentityCapability(gateway);
const agentSelection = createAgentSelectionCapability(gateway);
const channels = createChannelCapability(gateway);
const config = createApplicationConfigCapability({
basePath,
auth: {
settings: { token: settings.token },
password: startup.password ?? "",
},
});
const sessions = createSessionCapability(gateway);
const workboard = createWorkboardCapability();
const runtimeConfig = createRuntimeConfigCapability(gateway);
const overlays = createApplicationOverlays(gateway);
const navigation = createApplicationNavigationPreferences(settings);
const theme = createApplicationTheme(settings);
const nativeChatDrafts = createNativeChatDrafts();
const webPush = createWebPushCapability(gateway);
const skillWorkshopRevision = createSkillWorkshopRevisionHandoff();
applyStartupPresentation(settings);
const router = createApplicationRouter();
let pendingGatewayConnection =
startup.pendingGatewayUrl !== null
? {
gatewayUrl: startup.pendingGatewayUrl,
token: startup.pendingGatewayToken ?? "",
}
: null;
let lastConfigRefreshClient: GatewayBrowserClient | null = null;
const stopConfigRefresh = gateway.subscribe((snapshot) => {
if (!snapshot.connected || !snapshot.client) {
lastConfigRefreshClient = null;
return;
}
if (lastConfigRefreshClient === snapshot.client) {
return;
}
lastConfigRefreshClient = snapshot.client;
void config.refresh({
auth: {
hello: snapshot.hello,
settings: { token: gateway.connection.token },
password: gateway.connection.password,
},
});
});
const routeLocation = (routeId: RouteId, options?: ApplicationNavigationOptions) => {
const location = locationForRoute(routeId, basePath);
if (options?.search !== undefined || options?.hash !== undefined) {
return {
...location,
search: options?.search ?? "",
hash: options?.hash ?? "",
};
}
return location;
};
const confirmPendingGatewayConnection = () => {
const pending = pendingGatewayConnection;
if (!pending) {
return;
}
pendingGatewayConnection = null;
gateway.connect({
gatewayUrl: pending.gatewayUrl,
token: pending.token,
});
};
const cancelPendingGatewayConnection = () => {
pendingGatewayConnection = null;
};
const context: ApplicationContext<RouteId> = {
basePath,
gateway,
agents,
agentIdentity,
agentSelection,
channels,
config,
runtimeConfig,
sessions,
workboard,
overlays,
navigation,
theme,
nativeChatDrafts,
webPush,
skillWorkshopRevision,
navigate: (routeId, options) => {
void router
.navigate(routeId, context, { history: "push" }, routeLocation(routeId, options))
.catch((error: unknown) => {
console.error("[openclaw] route navigation failed", error);
});
},
replace: (routeId, options) => {
void router
.navigate(routeId, context, { history: "replace" }, routeLocation(routeId, options))
.catch((error: unknown) => {
console.error("[openclaw] route replacement failed", error);
});
},
preload: (routeId) => router.preloadRoute(routeId, context),
};
return {
context,
router,
get pendingGatewayConnection() {
return pendingGatewayConnection;
},
confirmPendingGatewayConnection,
cancelPendingGatewayConnection,
start: async () => {
void config.refresh({ skipWithoutAuthCandidate: true });
const routerStart = startApplicationRouter(router, history, basePath, context);
gateway.start();
await routerStart;
},
stop: () => {
stopConfigRefresh();
router.stop();
gateway.stop();
agents.dispose();
channels.dispose();
sessions.dispose();
workboard.dispose();
runtimeConfig.dispose();
overlays.dispose();
theme.dispose();
nativeChatDrafts.dispose();
webPush.dispose();
skillWorkshopRevision.clear();
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import type { RouteLocation, RouterHistory } from "@openclaw/uirouter";
function readLocation(): RouteLocation {
return {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
};
}
function writeLocation(location: RouteLocation) {
return `${location.pathname}${location.search}${location.hash}`;
}
export function createBrowserHistory(): RouterHistory {
const listeners = new Set<(location: RouteLocation) => void>();
let stopPopState: (() => void) | undefined;
const ensurePopStateListener = () => {
if (stopPopState) {
return;
}
const onPopState = () => {
const location = readLocation();
for (const listener of listeners) {
listener(location);
}
};
window.addEventListener("popstate", onPopState);
stopPopState = () => window.removeEventListener("popstate", onPopState);
};
const releasePopStateListener = () => {
if (listeners.size === 0) {
stopPopState?.();
stopPopState = undefined;
}
};
return {
location: readLocation,
push: (location) => window.history.pushState({}, "", writeLocation(location)),
replace: (location) => window.history.replaceState({}, "", writeLocation(location)),
listen: (listener) => {
listeners.add(listener);
ensurePopStateListener();
return () => {
listeners.delete(listener);
releasePopStateListener();
};
},
};
}
+248
View File
@@ -0,0 +1,248 @@
import { normalizeRouteBasePath } from "@openclaw/uirouter";
import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE,
type ControlUiBootstrapConfig,
type ControlUiEmbedSandboxMode,
} from "../../../src/gateway/control-ui-contract.js";
import { normalizeAssistantIdentity } from "../lib/assistant-identity.ts";
import { setUiTimeFormatPreference } from "../lib/format.ts";
import { resolveControlUiAuthCandidates } from "./control-ui-auth.ts";
type ApplicationConfigAuthSource = {
hello?: { auth?: { deviceToken?: string | null } | null } | null;
settings?: { token?: string | null } | null;
password?: string | null;
};
const SEAM_COLOR_CSS_VARIABLES = [
"--ring",
"--accent",
"--accent-hover",
"--accent-muted",
"--accent-subtle",
"--accent-glow",
"--primary",
"--focus",
"--focus-ring",
"--focus-glow",
] as const;
export type ApplicationConfig = {
assistantIdentity: {
agentId: string | null;
name: string;
avatar: string | null;
avatarSource: string | null;
avatarStatus: "none" | "local" | "remote" | "data" | null;
avatarReason: string | null;
};
serverVersion: string | null;
localMediaPreviewRoots: string[];
embedSandboxMode: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth: string | null;
terminalEnabled: boolean;
};
export type ApplicationConfigCapability = {
readonly current: ApplicationConfig;
refresh: (options?: {
auth?: ApplicationConfigAuthSource;
skipWithoutAuthCandidate?: boolean;
}) => Promise<void>;
subscribe: (listener: (config: ApplicationConfig) => void) => () => void;
};
function readDocumentTerminalEnabled(): boolean | null {
if (typeof document === "undefined") {
return null;
}
const value = document.documentElement.getAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE);
return value === "true" ? true : value === "false" ? false : null;
}
export const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = {
assistantIdentity: {
agentId: null,
name: "Assistant",
avatar: null,
avatarSource: null,
avatarStatus: null,
avatarReason: null,
},
serverVersion: null,
localMediaPreviewRoots: [],
embedSandboxMode: "strict",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
terminalEnabled: readDocumentTerminalEnabled() ?? false,
};
function normalizeSeamColor(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const hex = value.trim().replace(/^#/, "");
return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
}
function applyControlUiSeamColor(value: unknown): void {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const color = normalizeSeamColor(value);
if (!color) {
for (const property of SEAM_COLOR_CSS_VARIABLES) {
root.style.removeProperty(property);
}
return;
}
root.style.setProperty("--ring", color);
root.style.setProperty("--accent", color);
root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)");
root.style.setProperty("--accent-muted", color);
root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)");
root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)");
root.style.setProperty("--primary", color);
root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)");
root.style.setProperty(
"--focus-ring",
"0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)",
);
root.style.setProperty(
"--focus-glow",
"0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)",
);
}
export function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): ApplicationConfig {
const identity = normalizeAssistantIdentity({
agentId: parsed.assistantAgentId ?? null,
name: parsed.assistantName,
avatar: parsed.assistantAvatar ?? null,
avatarSource: parsed.assistantAvatarSource ?? null,
avatarStatus: parsed.assistantAvatarStatus ?? null,
avatarReason: parsed.assistantAvatarReason ?? null,
});
return {
assistantIdentity: {
agentId: identity.agentId ?? null,
name: identity.name,
avatar: identity.avatar,
avatarSource: identity.avatarSource ?? null,
avatarStatus: identity.avatarStatus ?? null,
avatarReason: identity.avatarReason ?? null,
},
serverVersion: parsed.serverVersion ?? null,
localMediaPreviewRoots: Array.isArray(parsed.localMediaPreviewRoots)
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")
: [],
embedSandboxMode:
parsed.embedSandbox === "trusted"
? "trusted"
: parsed.embedSandbox === "strict"
? "strict"
: "scripts",
allowExternalEmbedUrls: parsed.allowExternalEmbedUrls === true,
chatMessageMaxWidth:
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
? parsed.chatMessageMaxWidth
: null,
terminalEnabled: parsed.terminalEnabled === true,
};
}
export async function loadApplicationConfig(params: {
basePath: string;
auth?: ApplicationConfigAuthSource;
skipWithoutAuthCandidate?: boolean;
}): Promise<ApplicationConfig | null> {
if (typeof window === "undefined" || typeof fetch !== "function") {
return null;
}
const basePath = normalizeRouteBasePath(params.basePath);
const url = basePath
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
try {
const resolvedUrl = new URL(url, window.location.origin);
const sameOrigin = resolvedUrl.origin === window.location.origin;
const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(params.auth ?? {}) : [];
if (params.skipWithoutAuthCandidate && sameOrigin && authCandidates.length === 0) {
return null;
}
const attempts = authCandidates.length > 0 ? authCandidates : [""];
let res: Response | null = null;
for (const candidate of attempts) {
const headers: Record<string, string> = { Accept: "application/json" };
if (candidate) {
headers.Authorization = `Bearer ${candidate}`;
}
res = await fetch(url, { method: "GET", headers, credentials: "same-origin" });
if (res.ok) {
break;
}
if (res.status !== 401 && res.status !== 403) {
return null;
}
}
if (!res || !res.ok) {
return null;
}
const parsed = (await res.json()) as ControlUiBootstrapConfig;
setUiTimeFormatPreference(parsed.timeFormat);
applyControlUiSeamColor(parsed.seamColor);
return normalizeApplicationConfig(parsed);
} catch {
return null;
}
}
export function createApplicationConfigCapability(params: {
basePath: string;
auth?: ApplicationConfigAuthSource;
}): ApplicationConfigCapability {
let current = DEFAULT_APPLICATION_CONFIG;
let refreshVersion = 0;
const listeners = new Set<(config: ApplicationConfig) => void>();
const publish = (next: ApplicationConfig) => {
current = next;
for (const listener of listeners) {
listener(current);
}
};
return {
get current() {
return current;
},
async refresh(options) {
const version = ++refreshVersion;
const next = await loadApplicationConfig({
basePath: params.basePath,
auth: options?.auth ?? params.auth,
skipWithoutAuthCandidate: options?.skipWithoutAuthCandidate,
});
if (next && version === refreshVersion) {
const documentTerminalEnabled = readDocumentTerminalEnabled();
if (documentTerminalEnabled !== null && next.terminalEnabled !== documentTerminalEnabled) {
// CSP headers cannot change on a live document. Reload in either
// direction so the document and accepted terminal state stay aligned.
window.location.reload();
return;
}
publish(next);
}
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
+82
View File
@@ -0,0 +1,82 @@
import { createContext } from "@lit/context";
import type { RouteLocation } from "@openclaw/uirouter";
import type { RouteId } from "../app-route-paths.ts";
import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
import type { AgentCapability } from "../lib/agents/index.ts";
import type { ChannelCapability } from "../lib/channels/index.ts";
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import type { WorkboardCapability } from "../lib/workboard/capability.ts";
import type { AgentSelectionCapability } from "./agent-selection.ts";
import type { ApplicationConfigCapability } from "./config.ts";
import type { ApplicationGateway } from "./gateway.ts";
import type { NativeChatDrafts } from "./native-bridge.ts";
import type { ApplicationOverlays } from "./overlays.ts";
import type { ThemeMode } from "./theme.ts";
import type { WebPushCapability } from "./web-push.ts";
export type {
ApplicationGateway,
ApplicationGatewayConnection,
ApplicationGatewayConnectOptions,
ApplicationGatewaySnapshot,
} from "./gateway.ts";
export type ApplicationTheme = {
readonly mode: ThemeMode;
setMode: (mode: ThemeMode, element?: HTMLElement | null) => void;
refresh: () => void;
subscribe: (listener: () => void) => () => void;
};
export type ApplicationNavigationPreferencesSnapshot = {
navCollapsed: boolean;
navGroupsCollapsed: Record<string, boolean>;
recentSessionsCollapsed: boolean;
};
export type ApplicationNavigationPreferences = {
readonly snapshot: ApplicationNavigationPreferencesSnapshot;
update: (patch: Partial<ApplicationNavigationPreferencesSnapshot>) => void;
subscribe: (listener: (snapshot: ApplicationNavigationPreferencesSnapshot) => void) => () => void;
};
export type ApplicationNavigationOptions = Partial<Pick<RouteLocation, "search" | "hash">>;
export type SkillWorkshopRevisionHandoff = {
sessionKey: string;
instructions: string;
proposalId: string;
proposalAgentId: string;
};
export type ApplicationSkillWorkshopRevisionHandoff = {
prepare: (handoff: SkillWorkshopRevisionHandoff) => void;
consume: (sessionKey: string) => SkillWorkshopRevisionHandoff | null;
clear: () => void;
};
export type ApplicationContext<TRouteId extends string = string> = {
readonly basePath: string;
readonly gateway: ApplicationGateway;
readonly agents: AgentCapability;
readonly agentIdentity: AgentIdentityCapability;
readonly agentSelection: AgentSelectionCapability;
readonly channels: ChannelCapability;
readonly config: ApplicationConfigCapability;
readonly runtimeConfig: RuntimeConfigCapability;
readonly sessions: SessionCapability;
readonly workboard: WorkboardCapability;
readonly overlays: ApplicationOverlays;
readonly navigation: ApplicationNavigationPreferences;
readonly theme: ApplicationTheme;
readonly nativeChatDrafts: NativeChatDrafts;
readonly webPush: WebPushCapability;
readonly skillWorkshopRevision: ApplicationSkillWorkshopRevisionHandoff;
readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly preload: (routeId: TRouteId) => Promise<void>;
};
export const applicationContext =
createContext<ApplicationContext<RouteId>>("openclaw.application");
@@ -1,5 +1,5 @@
// Control UI module implements control ui auth behavior.
import { normalizeOptionalString, uniqueStrings } from "./string-coerce.ts";
import { normalizeOptionalString, uniqueStrings } from "../lib/string-coerce.ts";
type ControlUiAuthSource = {
hello?: { auth?: { deviceToken?: string | null } | null } | null;
@@ -1,4 +1,3 @@
// Control UI tests cover control ui chunking behavior.
import { describe, expect, it } from "vitest";
import { controlUiManualChunk, normalizeModuleId } from "../../config/control-ui-chunking.ts";
@@ -23,7 +22,7 @@ describe("Control UI build chunking", () => {
expect(controlUiManualChunk("/tmp/openclaw-pnpm-node-modules/@noble/ed25519/index.js")).toBe(
"gateway-runtime",
);
expect(controlUiManualChunk("/repo/ui/src/ui/app-render.ts")).toBeUndefined();
expect(controlUiManualChunk("/repo/ui/src/app/app-host.ts")).toBeUndefined();
});
it("normalizes Windows module paths before package matching", () => {
@@ -1,6 +1,6 @@
// Control UI module implements custom theme behavior.
import { z } from "zod";
import { normalizeOptionalString } from "./string-coerce.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]);
const THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
@@ -1,5 +1,5 @@
// Control UI controller manages exec approval gateway state.
import { normalizeOptionalString } from "../string-coerce.ts";
// Application-owned approval parsing and queue state.
import { normalizeOptionalString } from "../lib/string-coerce.ts";
export type ExecApprovalRequestPayload = {
command: string;
@@ -45,7 +45,9 @@ export type ExecApprovalPromptState = {
execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean;
execApprovalError: string | null;
execApprovalRefreshRemovedIds?: Set<string> | null;
execApprovalRefreshes?: Set<{ removedIds: Set<string> }>;
execApprovalExpiryTimers?: Map<string, ReturnType<typeof globalThis.setTimeout>>;
execApprovalExpired?: () => void;
};
const APPROVAL_ALREADY_RESOLVED = "APPROVAL_ALREADY_RESOLVED";
@@ -297,17 +299,40 @@ function mergeRefreshedApprovalQueue(
return sortApprovalsNewestFirst([...currentRefreshed, ...arrivedDuringRefresh]);
}
function clearApprovalExpiryTimer(state: ExecApprovalPromptState, id: string): void {
const timer = state.execApprovalExpiryTimers?.get(id);
if (timer === undefined) {
return;
}
globalThis.clearTimeout(timer);
state.execApprovalExpiryTimers?.delete(id);
}
function scheduleApprovalExpiryPrune(
state: ExecApprovalPromptState,
entry: ExecApprovalRequest,
): void {
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
globalThis.setTimeout(() => {
removeExecApprovalFromState(state, entry.id);
}, delay);
clearApprovalExpiryTimer(state, entry.id);
const timer = globalThis.setTimeout(
() => {
const trackedTimer = state.execApprovalExpiryTimers?.get(entry.id);
if (trackedTimer !== undefined && trackedTimer !== timer) {
return;
}
state.execApprovalExpiryTimers?.delete(entry.id);
const hadEntry = state.execApprovalQueue.some((item) => item.id === entry.id);
removeExecApprovalFromState(state, entry.id);
if (hadEntry) {
state.execApprovalExpired?.();
}
},
Math.max(0, entry.expiresAtMs - Date.now() + 500),
);
state.execApprovalExpiryTimers?.set(entry.id, timer);
}
function removeExecApprovalFromState(state: ExecApprovalPromptState, id: string): void {
clearApprovalExpiryTimer(state, id);
const activeId = state.execApprovalQueue[0]?.id ?? null;
state.execApprovalQueue = removeExecApproval(state.execApprovalQueue, id);
if (activeId !== (state.execApprovalQueue[0]?.id ?? null)) {
@@ -324,16 +349,22 @@ export function enqueueExecApprovalPrompt(
scheduleApprovalExpiryPrune(state, entry);
}
export async function refreshPendingApprovalQueue(state: ExecApprovalPromptState): Promise<void> {
export async function refreshPendingApprovalQueue(
state: ExecApprovalPromptState,
options?: {
isCurrentClient?: (client: NonNullable<ExecApprovalPromptState["client"]>) => boolean;
},
): Promise<boolean> {
const client = state.client;
if (!client) {
return;
return false;
}
const removedDuringRefresh = state.execApprovalRefreshRemovedIds ?? new Set<string>();
const ownsRemovedSet = !state.execApprovalRefreshRemovedIds;
if (ownsRemovedSet) {
state.execApprovalRefreshRemovedIds = removedDuringRefresh;
if (options?.isCurrentClient && !options.isCurrentClient(client)) {
return false;
}
const refresh = { removedIds: new Set<string>() };
const refreshes = (state.execApprovalRefreshes ??= new Set());
refreshes.add(refresh);
const refreshStartedWith = pruneExecApprovalQueue(state.execApprovalQueue);
try {
const [execResult, pluginResult] = await Promise.allSettled([
@@ -352,26 +383,41 @@ export async function refreshPendingApprovalQueue(state: ExecApprovalPromptState
sortApprovalsNewestFirst([...execApprovals, ...pluginApprovals]),
refreshStartedWith,
state.execApprovalQueue,
removedDuringRefresh,
refresh.removedIds,
);
if (options?.isCurrentClient && !options.isCurrentClient(client)) {
return false;
}
state.execApprovalQueue = refreshed;
const refreshedIds = new Set(refreshed.map((entry) => entry.id));
for (const id of state.execApprovalExpiryTimers?.keys() ?? []) {
if (!refreshedIds.has(id)) {
clearApprovalExpiryTimer(state, id);
}
}
for (const entry of refreshed) {
scheduleApprovalExpiryPrune(state, entry);
}
return true;
} finally {
if (ownsRemovedSet) {
state.execApprovalRefreshRemovedIds = null;
refreshes.delete(refresh);
if (refreshes.size === 0) {
state.execApprovalRefreshes = undefined;
}
}
}
export function dismissExecApprovalPrompt(state: ExecApprovalPromptState, id: string): void {
removeExecApprovalFromState(state, id);
state.execApprovalRefreshRemovedIds?.add(id);
for (const refresh of state.execApprovalRefreshes ?? []) {
refresh.removedIds.add(id);
}
state.execApprovalError = null;
}
export function clearResolvedExecApprovalPrompt(state: ExecApprovalPromptState, id: string): void {
removeExecApprovalFromState(state, id);
state.execApprovalRefreshRemovedIds?.add(id);
for (const refresh of state.execApprovalRefreshes ?? []) {
refresh.removedIds.add(id);
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { EventLogEntry } from "../api/event-log.ts";
import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts";
export type ApplicationGatewaySnapshot = {
client: GatewayBrowserClient | null;
connected: boolean;
hello: GatewayHelloOk | null;
assistantAgentId: string | null;
sessionKey: string;
lastError: string | null;
lastErrorCode: string | null;
};
export type ApplicationGatewayConnection = {
gatewayUrl: string;
token: string;
password: string;
};
export type ApplicationGatewayConnectOptions = Partial<ApplicationGatewayConnection> & {
sessionKey?: string;
};
export type ApplicationGateway = {
readonly snapshot: ApplicationGatewaySnapshot;
readonly connection: ApplicationGatewayConnection;
readonly eventLog: readonly EventLogEntry[];
connect: (connection?: ApplicationGatewayConnectOptions) => void;
setSessionKey: (sessionKey: string) => void;
start: () => void;
stop: () => void;
subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void;
subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void;
subscribeEvents: (listener: GatewayEventListener) => () => void;
};
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
handleChatDraftChange as applyDraftChange,
navigateChatInputHistory,
type ChatInputHistoryState,
} from "../pages/chat/input-history.ts";
import { createNativeChatDrafts, isWebView2, sendToNative } from "./native-bridge.ts";
type FakeBridge = {
postMessage: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
listeners: ((event: MessageEvent) => void)[];
posted: unknown[];
};
function makeBridge(): FakeBridge {
const listeners: ((event: MessageEvent) => void)[] = [];
const posted: unknown[] = [];
const bridge: FakeBridge = {
posted,
listeners,
postMessage: vi.fn((message: unknown) => posted.push(message)),
addEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => {
listeners.push(listener);
}),
removeEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}),
};
vi.stubGlobal("chrome", { webview: bridge });
return bridge;
}
function dispatch(bridge: FakeBridge, data: unknown) {
for (const listener of bridge.listeners) {
listener({ data } as MessageEvent);
}
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("native chat drafts", () => {
it("detects WebView2 and sends native messages", () => {
expect(isWebView2()).toBe(false);
const bridge = makeBridge();
expect(isWebView2()).toBe(true);
sendToNative({ type: "ready" });
expect(bridge.posted).toEqual([{ type: "ready" }]);
});
it("registers the listener before the ready handshake", () => {
const callOrder: string[] = [];
vi.stubGlobal("chrome", {
webview: {
postMessage: vi.fn(() => callOrder.push("post")),
addEventListener: vi.fn(() => callOrder.push("listen")),
removeEventListener: vi.fn(),
},
});
createNativeChatDrafts();
expect(callOrder).toEqual(["listen", "post"]);
});
it("delivers drafts and ignores invalid messages", () => {
const bridge = makeBridge();
const drafts = createNativeChatDrafts();
const listener = vi.fn();
drafts.subscribe(listener);
dispatch(bridge, { type: "draft-text", payload: { text: "hello from native" } });
dispatch(bridge, { type: "draft-text" });
dispatch(bridge, { type: "draft-text", payload: { text: 42 } });
dispatch(bridge, { type: "unknown" });
dispatch(bridge, null);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith("hello from native");
});
it("removes the native listener and stops delivery on dispose", () => {
const bridge = makeBridge();
const drafts = createNativeChatDrafts();
const listener = vi.fn();
drafts.subscribe(listener);
drafts.dispose();
dispatch(bridge, { type: "draft-text", payload: { text: "after cleanup" } });
expect(bridge.listeners).toHaveLength(0);
expect(listener).not.toHaveBeenCalled();
});
it("applies native drafts through the real Chat draft owner", () => {
const bridge = makeBridge();
const state: ChatInputHistoryState = {
sessionKey: "s1",
chatLoading: false,
chatMessage: "",
chatMessages: [],
chatLocalInputHistoryBySession: { s1: [{ text: "previous input", ts: 1 }] },
chatInputHistorySessionKey: null,
chatInputHistoryItems: null,
chatInputHistoryIndex: -1,
chatDraftBeforeHistory: null,
};
navigateChatInputHistory(state, "up");
const drafts = createNativeChatDrafts();
drafts.subscribe((text) => applyDraftChange(state, text));
dispatch(bridge, { type: "draft-text", payload: { text: "native injection" } });
expect(state.chatMessage).toBe("native injection");
expect(state.chatInputHistoryIndex).toBe(-1);
expect(state.chatInputHistoryItems).toBeNull();
expect(state.chatInputHistorySessionKey).toBeNull();
});
});
@@ -1,4 +1,4 @@
// Control UI module implements app native bridge behavior.
// Application-owned native draft delivery.
type WebView2Bridge = {
postMessage(message: unknown): void;
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
@@ -9,8 +9,9 @@ export type NativeBridgeMessage =
| { type: "draft-text"; payload: { text: string } }
| { type: "ready"; payload?: Record<string, unknown> };
export type NativeBridgeHost = {
handleChatDraftChange: (next: string) => void;
export type NativeChatDrafts = {
subscribe: (listener: (draft: string) => void) => () => void;
dispose: () => void;
};
function getWebview(): WebView2Bridge | undefined {
@@ -26,13 +27,13 @@ export function sendToNative(msg: NativeBridgeMessage): void {
getWebview()?.postMessage(msg);
}
function handleNativeMessage(host: NativeBridgeHost, raw: unknown): void {
function readNativeDraft(raw: unknown): string | null {
if (!raw || typeof raw !== "object") {
return;
return null;
}
const msg = raw as Record<string, unknown>;
if (typeof msg.type !== "string") {
return;
return null;
}
if (msg.type === "draft-text") {
const text =
@@ -40,32 +41,60 @@ function handleNativeMessage(host: NativeBridgeHost, raw: unknown): void {
? (msg.payload as Record<string, unknown>).text
: undefined;
if (typeof text === "string") {
host.handleChatDraftChange(text);
return text;
}
}
return null;
}
/**
* Subscribes to WebView2 native messages and sends the ready handshake.
* addEventListener is called BEFORE the ready handshake so no messages
* are missed between the handshake and the first listen.
* Returns a cleanup function that removes the listener.
* No-op (returns empty cleanup) when not running inside WebView2.
* Drafts received while Chat is not mounted are retained for its next subscriber.
*/
export function initNativeBridge(host: NativeBridgeHost): () => void {
export function createNativeChatDrafts(): NativeChatDrafts {
const bridge = getWebview();
if (!bridge) {
return () => {};
return {
subscribe: () => () => {},
dispose: () => {},
};
}
let pendingDraft: string | null = null;
const listeners = new Set<(draft: string) => void>();
const handler = (event: MessageEvent) => {
handleNativeMessage(host, event.data);
const draft = readNativeDraft(event.data);
if (draft === null) {
return;
}
if (listeners.size === 0) {
pendingDraft = draft;
return;
}
for (const listener of listeners) {
listener(draft);
}
};
bridge.addEventListener("message", handler);
sendToNative({ type: "ready" });
return () => {
bridge.removeEventListener("message", handler);
return {
subscribe(listener) {
listeners.add(listener);
if (pendingDraft !== null) {
const draft = pendingDraft;
pendingDraft = null;
listener(draft);
}
return () => listeners.delete(listener);
},
dispose() {
listeners.clear();
pendingDraft = null;
bridge.removeEventListener("message", handler);
},
};
}
+41
View File
@@ -0,0 +1,41 @@
// Control UI app-level operator scope checks.
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
export function hasOperatorReadAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return false;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.read"],
allowedScopes: auth.scopes,
});
}
export function hasOperatorWriteAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.write"],
allowedScopes: auth.scopes,
});
}
export function hasOperatorAdminAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.admin"],
allowedScopes: auth.scopes,
});
}
+554
View File
@@ -0,0 +1,554 @@
import {
GATEWAY_EVENT_UPDATE_AVAILABLE,
type GatewayUpdateAvailableEventPayload,
} from "../../../src/gateway/events.js";
import type { GatewayEventFrame, GatewayHelloOk } from "../api/gateway.ts";
import type { UpdateAvailable } from "../api/types.ts";
import {
clearResolvedExecApprovalPrompt,
dismissExecApprovalPrompt,
enqueueExecApprovalPrompt,
isStaleApprovalResolutionError,
parseExecApprovalRequested,
parseExecApprovalResolved,
parsePluginApprovalRequested,
refreshPendingApprovalQueue,
type ExecApprovalDecision,
type ExecApprovalPromptState,
type ExecApprovalRequest,
} from "./exec-approval.ts";
import type { ApplicationGateway } from "./gateway.ts";
export type ApplicationStatusBanner = {
tone: "danger" | "warn" | "info";
text: string;
};
export type ApplicationOverlaySnapshot = {
updateAvailable: UpdateAvailable | null;
updateRunning: boolean;
updateStatusBanner: ApplicationStatusBanner | null;
approvalQueue: readonly ExecApprovalRequest[];
approvalBusy: boolean;
approvalError: string | null;
};
export type ApplicationOverlays = {
readonly snapshot: ApplicationOverlaySnapshot;
subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void;
runUpdate: () => Promise<void>;
dismissUpdate: () => void;
decideApproval: (decision: ExecApprovalDecision) => Promise<void>;
dispose: () => void;
};
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending";
const UPDATE_RESTART_VERIFICATION_POLL_MS = 250;
const UPDATE_RESTART_VERIFICATION_TIMEOUT_MS = 10_000;
const UPDATE_HANDOFF_POLL_MS = 1_000;
const UPDATE_HANDOFF_TIMEOUT_MS = 35 * 60_000;
const PENDING_UPDATE_HANDOFF_REASONS = new Set([
UPDATE_HANDOFF_STARTED_REASON,
UPDATE_RESTART_HEALTH_PENDING_REASON,
]);
type UpdateRestartStatusResponse = {
sentinel?: {
kind?: string;
status?: string;
stats?: {
reason?: string | null;
after?: { version?: string | null } | null;
} | null;
} | null;
};
function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null {
const snapshot = hello?.snapshot;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
return null;
}
const update = (snapshot as { updateAvailable?: unknown }).updateAvailable;
if (!update || typeof update !== "object" || Array.isArray(update)) {
return null;
}
const value = update as Partial<UpdateAvailable>;
return typeof value.currentVersion === "string" &&
typeof value.latestVersion === "string" &&
typeof value.channel === "string"
? {
currentVersion: value.currentVersion,
latestVersion: value.latestVersion,
channel: value.channel,
}
: null;
}
function resolveUpdateStatusBanner(params: {
status?: string;
reason?: string;
}): ApplicationStatusBanner {
const status = (params.status ?? "error").trim() || "error";
const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error";
const guidance =
{
dirty: "Commit or stash changes, then retry.",
"no-upstream": "Set an upstream branch, then retry.",
"not-git-install":
"Not a git checkout. Run `openclaw update` from the CLI for a global reinstall.",
"not-openclaw-root":
"Run the update from an OpenClaw checkout or use the CLI global reinstall path.",
"deps-install-failed": "Dependency install failed. Fix the install error and retry.",
"build-failed": "Build failed. Fix the build error and retry.",
"ui-build-failed": "The control UI rebuild failed. Fix the UI build error and retry.",
"global-install-failed":
"The global package install did not verify on disk. Retry or reinstall from the CLI.",
"restart-disabled":
"The update was not applied because gateway restarts are disabled. Enable restarts in config, then retry.",
"restart-unavailable":
"This global install cannot be safely replaced while restarts are disabled and no supervisor is present.",
"restart-unhealthy":
"The replacement process never became healthy. The previous process stayed up so you can recover.",
"doctor-failed": "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.",
}[reason] ?? "See the gateway logs for the exact failure and retry once the cause is fixed.";
return {
tone: status === "skipped" ? "warn" : "danger",
text: `Update ${status}: ${reason}. ${guidance}`,
};
}
function resolveUpdateVerificationBanner(params: {
expectedVersion: string;
actualVersion: string | null;
}): ApplicationStatusBanner {
const actualSuffix = params.actualVersion
? ` Expected v${params.expectedVersion}, running v${params.actualVersion}.`
: "";
return {
tone: "danger",
text: `Update installed but running version did not change — restart may have been blocked.${actualSuffix}`,
};
}
function resolvePostRestartUpdateBanner(
reason: string | null | undefined,
): ApplicationStatusBanner {
const normalizedReason = reason?.trim() || "restart-unhealthy";
const guidance =
normalizedReason === "restart-unhealthy"
? "The replacement process never became healthy and the previous process stayed up."
: "Check the gateway logs for the replacement failure.";
return {
tone: "danger",
text: `Update error: ${normalizedReason}. ${guidance}`,
};
}
function resolvePendingUpdateHandoffTimeoutBanner(): ApplicationStatusBanner {
return {
tone: "danger",
text: "Update handoff started, but completion was not reported after reconnect. Run `openclaw update status` for the final result.",
};
}
function isPendingUpdateHandoffSentinel(
sentinel: UpdateRestartStatusResponse["sentinel"],
): boolean {
const reason = sentinel?.stats?.reason;
return (
sentinel?.kind === "update" &&
sentinel.status === "skipped" &&
typeof reason === "string" &&
PENDING_UPDATE_HANDOFF_REASONS.has(reason)
);
}
function isGatewayEvent(value: unknown): value is GatewayEventFrame {
return Boolean(value && typeof value === "object" && "event" in value);
}
type UpdateRunResponse = {
ok?: boolean;
result?: {
status?: string;
reason?: string;
after?: { version?: string | null } | null;
};
handoff?: { status?: string };
};
export function createApplicationOverlays(gateway: ApplicationGateway): ApplicationOverlays {
let snapshot: ApplicationOverlaySnapshot = {
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
approvalQueue: [],
approvalBusy: false,
approvalError: null,
};
const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>();
let disposed = false;
let activeClient = gateway.snapshot.client;
let pendingUpdateExpectedVersion: string | null = null;
let pendingUpdateHandoff = false;
let updateRunGeneration = 0;
let updateVerificationGeneration = 0;
let updateVerificationTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const promptState: ExecApprovalPromptState = {
client: activeClient,
execApprovalQueue: [],
execApprovalBusy: false,
execApprovalError: null,
execApprovalExpiryTimers: new Map(),
};
const publish = () => {
snapshot = {
updateAvailable: snapshot.updateAvailable,
updateRunning: snapshot.updateRunning,
updateStatusBanner: snapshot.updateStatusBanner,
approvalQueue: promptState.execApprovalQueue,
approvalBusy: promptState.execApprovalBusy,
approvalError: promptState.execApprovalError,
};
for (const listener of listeners) {
listener(snapshot);
}
};
promptState.execApprovalExpired = publish;
const refreshApprovals = async (client: NonNullable<typeof activeClient>) => {
const applied = await refreshPendingApprovalQueue(promptState, {
isCurrentClient: (requestClient) =>
!disposed &&
requestClient === client &&
activeClient === client &&
gateway.snapshot.client === client &&
gateway.snapshot.connected,
});
if (applied && !disposed) {
publish();
}
};
const publishUpdateBanner = (updateStatusBanner: ApplicationStatusBanner | null) => {
snapshot = { ...snapshot, updateStatusBanner };
publish();
};
const cancelUpdateVerification = () => {
updateVerificationGeneration += 1;
if (updateVerificationTimer !== null) {
globalThis.clearTimeout(updateVerificationTimer);
updateVerificationTimer = null;
}
};
const waitForUpdateVerification = (delayMs: number, generation: number) =>
new Promise<boolean>((resolve) => {
const timer = globalThis.setTimeout(() => {
if (updateVerificationTimer === timer) {
updateVerificationTimer = null;
}
resolve(generation === updateVerificationGeneration && !disposed);
}, delayMs);
updateVerificationTimer = timer;
});
const verifyPendingUpdateVersion = async (client: NonNullable<typeof activeClient>) => {
const generation = updateVerificationGeneration;
const expectedVersion = pendingUpdateExpectedVersion?.trim() || null;
const pendingHandoff = pendingUpdateHandoff;
if (!expectedVersion && !pendingHandoff) {
return;
}
const isCurrentVerification = () =>
generation === updateVerificationGeneration &&
!disposed &&
activeClient === client &&
gateway.snapshot.client === client &&
gateway.snapshot.connected;
const deadline =
Date.now() +
(pendingHandoff ? UPDATE_HANDOFF_TIMEOUT_MS : UPDATE_RESTART_VERIFICATION_TIMEOUT_MS);
const pollMs = pendingHandoff ? UPDATE_HANDOFF_POLL_MS : UPDATE_RESTART_VERIFICATION_POLL_MS;
while (isCurrentVerification() && Date.now() < deadline) {
let response: UpdateRestartStatusResponse | null;
try {
response = await client.request<UpdateRestartStatusResponse>("update.status", {});
} catch {
response = null;
}
if (!isCurrentVerification()) {
return;
}
const sentinel = response?.sentinel;
if (isPendingUpdateHandoffSentinel(sentinel)) {
if (!(await waitForUpdateVerification(pollMs, generation))) {
return;
}
continue;
}
if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") {
pendingUpdateExpectedVersion = null;
pendingUpdateHandoff = false;
publishUpdateBanner(resolvePostRestartUpdateBanner(sentinel.stats?.reason));
return;
}
const actualVersion = sentinel?.stats?.after?.version?.trim() || null;
if (
sentinel?.kind === "update" &&
sentinel.status === "ok" &&
!actualVersion &&
!expectedVersion
) {
pendingUpdateExpectedVersion = null;
pendingUpdateHandoff = false;
publish();
return;
}
if (sentinel?.kind === "update" && actualVersion) {
pendingUpdateExpectedVersion = null;
pendingUpdateHandoff = false;
publishUpdateBanner(
expectedVersion && actualVersion !== expectedVersion
? resolveUpdateVerificationBanner({ expectedVersion, actualVersion })
: null,
);
return;
}
if (!(await waitForUpdateVerification(pollMs, generation))) {
return;
}
}
if (!isCurrentVerification()) {
return;
}
const currentVersion = gateway.snapshot.hello?.server?.version?.trim() || null;
pendingUpdateExpectedVersion = null;
pendingUpdateHandoff = false;
publishUpdateBanner(
expectedVersion && currentVersion !== expectedVersion
? resolveUpdateVerificationBanner({ expectedVersion, actualVersion: currentVersion })
: pendingHandoff
? resolvePendingUpdateHandoffTimeoutBanner()
: null,
);
};
const stopGateway = gateway.subscribe((next) => {
updateRunGeneration += 1;
cancelUpdateVerification();
const previousClient = activeClient;
activeClient = next.client;
promptState.client = next.client;
if (!next.connected || !next.client) {
promptState.execApprovalQueue = [];
promptState.execApprovalBusy = false;
promptState.execApprovalError = null;
snapshot = { ...snapshot, updateAvailable: null, updateRunning: false };
for (const timer of promptState.execApprovalExpiryTimers?.values() ?? []) {
globalThis.clearTimeout(timer);
}
promptState.execApprovalExpiryTimers?.clear();
publish();
return;
}
snapshot = { ...snapshot, updateAvailable: readUpdateAvailable(next.hello) };
if (previousClient !== next.client) {
void refreshApprovals(next.client);
if (next.client) {
void verifyPendingUpdateVersion(next.client);
}
} else {
publish();
}
});
const stopEvents = gateway.subscribeEvents((event) => {
if (disposed || !isGatewayEvent(event)) {
return;
}
if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) {
const payload = event.payload as GatewayUpdateAvailableEventPayload | undefined;
snapshot = { ...snapshot, updateAvailable: payload?.updateAvailable ?? null };
publish();
return;
}
if (event.event === "exec.approval.requested") {
const entry = parseExecApprovalRequested(event.payload);
if (entry) {
enqueueExecApprovalPrompt(promptState, entry);
publish();
}
return;
}
if (event.event === "plugin.approval.requested") {
const entry = parsePluginApprovalRequested(event.payload);
if (entry) {
enqueueExecApprovalPrompt(promptState, entry);
publish();
}
return;
}
if (event.event === "exec.approval.resolved" || event.event === "plugin.approval.resolved") {
const resolved = parseExecApprovalResolved(event.payload);
if (resolved) {
clearResolvedExecApprovalPrompt(promptState, resolved.id);
publish();
}
}
});
return {
get snapshot() {
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async runUpdate() {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed || snapshot.updateRunning) {
return;
}
const generation = ++updateRunGeneration;
snapshot = { ...snapshot, updateRunning: true, updateStatusBanner: null };
publish();
try {
const response = await client.request<UpdateRunResponse>("update.run", {});
if (
disposed ||
generation !== updateRunGeneration ||
activeClient !== client ||
gateway.snapshot.client !== client
) {
return;
}
const status = response.result?.status ?? (response.ok === true ? "ok" : "error");
const expectedVersion = response.result?.after?.version?.trim() || null;
if (
response.ok === true &&
status === "skipped" &&
response.result?.reason === UPDATE_HANDOFF_STARTED_REASON &&
response.handoff?.status === "started"
) {
pendingUpdateExpectedVersion = expectedVersion;
pendingUpdateHandoff = true;
return;
}
if (response.ok === true && status === "ok") {
pendingUpdateExpectedVersion = expectedVersion;
pendingUpdateHandoff = false;
return;
}
pendingUpdateExpectedVersion = null;
pendingUpdateHandoff = false;
if (response.ok !== true || status !== "ok") {
snapshot = {
...snapshot,
updateStatusBanner: resolveUpdateStatusBanner({
status,
reason: response.result?.reason,
}),
};
}
} catch (error) {
if (
disposed ||
generation !== updateRunGeneration ||
activeClient !== client ||
gateway.snapshot.client !== client
) {
return;
}
snapshot = {
...snapshot,
updateStatusBanner: {
tone: "danger",
text: `Update error: ${error instanceof Error ? error.message : String(error)}`,
},
};
} finally {
if (
!disposed &&
generation === updateRunGeneration &&
activeClient === client &&
gateway.snapshot.client === client
) {
snapshot = { ...snapshot, updateRunning: false };
publish();
}
}
},
dismissUpdate() {
snapshot = { ...snapshot, updateAvailable: null };
publish();
},
async decideApproval(decision) {
const active = promptState.execApprovalQueue[0];
const client = gateway.snapshot.client;
if (!active || !client || promptState.execApprovalBusy || disposed) {
return;
}
promptState.execApprovalBusy = true;
promptState.execApprovalError = null;
publish();
try {
const method =
active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve";
await client.request(method, { id: active.id, decision });
if (
disposed ||
activeClient !== client ||
gateway.snapshot.client !== client ||
!gateway.snapshot.connected
) {
return;
}
dismissExecApprovalPrompt(promptState, active.id);
} catch (error) {
if (isStaleApprovalResolutionError(error)) {
if (
disposed ||
activeClient !== client ||
gateway.snapshot.client !== client ||
!gateway.snapshot.connected
) {
return;
}
dismissExecApprovalPrompt(promptState, active.id);
const currentClient = activeClient;
if (
currentClient &&
gateway.snapshot.client === currentClient &&
gateway.snapshot.connected
) {
await refreshApprovals(currentClient);
}
return;
}
if (promptState.execApprovalQueue.some((entry) => entry.id === active.id)) {
promptState.execApprovalError = `Approval failed: ${error instanceof Error ? error.message : String(error)}`;
}
} finally {
promptState.execApprovalBusy = false;
publish();
}
},
dispose() {
disposed = true;
updateRunGeneration += 1;
cancelUpdateVerification();
stopGateway();
stopEvents();
for (const timer of promptState.execApprovalExpiryTimers?.values() ?? []) {
globalThis.clearTimeout(timer);
}
promptState.execApprovalExpiryTimers?.clear();
listeners.clear();
},
};
}
@@ -1,5 +1,5 @@
// Control UI module implements public assets behavior.
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "../app-route-paths.ts";
export type ControlUiPublicAsset =
| "apple-touch-icon.png"
+324
View File
@@ -0,0 +1,324 @@
import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter";
import { html, LitElement, nothing } from "lit";
import { AsyncDirective } from "lit/async-directive.js";
import { property } from "lit/decorators.js";
import { directive } from "lit/directive.js";
import { t } from "../i18n/index.ts";
const PENDING_UI_DELAY_MS = 1_000;
type RenderableModule<TData> = {
render: (data: TData | undefined) => unknown;
};
export type RouterOutletOptions<TLoadContext = unknown> = {
retryContext?: TLoadContext;
};
export type RouterOutletBoundaryOptions = {
onNotFound?: () => void;
};
export type RouterOutletSelection<
TRouteId extends string = string,
TModule = unknown,
TData = unknown,
> = {
status: RouterState<TRouteId, TModule, TData>["status"];
active: RouteMatch<TRouteId, TModule, TData> | undefined;
pending: RouteMatch<TRouteId, TModule, TData> | undefined;
showPending: boolean;
};
export function selectRenderedRouteMatch<TRouteId extends string, TModule, TData>(
active: RouteMatch<TRouteId, TModule, TData> | undefined,
pending: RouteMatch<TRouteId, TModule, TData> | undefined,
): RouteMatch<TRouteId, TModule, TData> | undefined {
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
return coldPending && active ? active : (pending ?? active);
}
function selectRouterOutletState<TRouteId extends string, TModule, TData>(
state: RouterState<TRouteId, TModule, TData>,
): RouterOutletSelection<TRouteId, TModule, TData> {
return {
status: state.status,
active: state.matches[0],
pending: state.pendingMatches[0],
showPending: false,
};
}
function equalRouterOutletState(
previous: RouterOutletSelection,
next: RouterOutletSelection,
): boolean {
return (
previous.status === next.status &&
previous.active === next.active &&
previous.pending === next.pending
);
}
function isRenderableModule<TData>(module: unknown): module is RenderableModule<TData> {
return (
typeof module === "object" &&
module !== null &&
"render" in module &&
typeof module.render === "function"
);
}
function measureRoutedRender<T>(routeId: string, render: () => T): T {
const startedAt = globalThis.performance?.now() ?? 0;
const result = render();
const durationMs = Math.round((globalThis.performance?.now() ?? startedAt) - startedAt);
if (durationMs >= 16) {
console.debug("[openclaw] routed render", { routeId, durationMs });
}
return result;
}
function renderPending() {
return html`
<section class="card lazy-view-state lazy-view-state--loading" role="status">
<div class="card-title">${t("lazyView.loadingTitle")}</div>
<div class="card-sub">${t("common.loading")}</div>
</section>
`;
}
function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
router: Router<TRouteId, TLoadContext, TModule, TData>,
retryContext: TLoadContext | undefined,
error: unknown,
routeId: TRouteId,
render?: () => unknown,
) {
const routeError = error instanceof Error ? error.message : String(error);
return html`
${render?.() ?? nothing}
<div class="callout danger" role="alert">
<strong>${t("lazyView.errorTitle")}</strong>
<div>${routeError}</div>
<button
class="btn btn--sm"
@click=${() =>
retryContext === undefined
? undefined
: void router.revalidate(retryContext, routeId).catch(() => undefined)}
>
${t("lazyView.retry")}
</button>
</div>
`;
}
export function renderRouterOutlet<TRouteId extends string, TLoadContext, TModule, TData = unknown>(
router: Router<TRouteId, TLoadContext, TModule, TData>,
selection: RouterOutletSelection<TRouteId, TModule, TData>,
options: RouterOutletOptions<TLoadContext> = {},
): unknown {
const pending = selection.pending;
const renderedMatch = selectRenderedRouteMatch(selection.active, pending);
if (renderedMatch?.status === "notFound") {
return nothing;
}
if (renderedMatch?.status === "redirected") {
return nothing;
}
if (!renderedMatch) {
return nothing;
}
const routeId = renderedMatch.routeId;
if (!renderedMatch?.module) {
return renderedMatch.error
? renderError<TRouteId, TLoadContext, TModule, TData>(
router,
options.retryContext,
renderedMatch.error,
routeId,
)
: selection.showPending
? renderPending()
: nothing;
}
const routeModule = renderedMatch.module;
if (!isRenderableModule<TData>(routeModule)) {
return renderedMatch.error
? renderError<TRouteId, TLoadContext, TModule, TData>(
router,
options.retryContext,
renderedMatch.error,
routeId,
)
: null;
}
const renderedPage = () =>
measureRoutedRender(routeId, () => routeModule.render(renderedMatch.data));
return renderedMatch.error
? renderError<TRouteId, TLoadContext, TModule, TData>(
router,
options.retryContext,
renderedMatch.error,
routeId,
renderedPage,
)
: renderedPage();
}
class RouterOutletDirective extends AsyncDirective {
private router?: Router<string, unknown, unknown, unknown>;
private retryContext: unknown;
private unsubscribe?: () => void;
private boundaryOptions?: RouterOutletBoundaryOptions;
private notFoundScheduled = false;
private pendingMatchId?: string;
private pendingTimer?: ReturnType<typeof globalThis.setTimeout>;
private pendingSelection?: RouterOutletSelection;
private showPending = false;
override render(
router: unknown,
retryContext: unknown,
boundaryOptions: RouterOutletBoundaryOptions,
) {
const nextRouter = router as Router<string, unknown, unknown, unknown>;
this.updateSubscription(nextRouter);
this.router = nextRouter;
this.retryContext = retryContext;
this.boundaryOptions = boundaryOptions;
return this.renderSelection(selectRouterOutletState(nextRouter.getState()));
}
override disconnected() {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.clearPendingTimer();
this.pendingSelection = undefined;
this.boundaryOptions = undefined;
this.retryContext = undefined;
this.notFoundScheduled = false;
}
override reconnected() {
if (this.router) {
this.updateSubscription(this.router);
}
}
private updateSubscription(router: Router<string, unknown, unknown, unknown>) {
if (this.router === router && this.unsubscribe) {
return;
}
this.unsubscribe?.();
this.unsubscribe = router.subscribeSelector(
selectRouterOutletState,
(selection) => {
if (this.isConnected) {
this.setValue(this.renderSelection(selection));
}
},
equalRouterOutletState,
);
}
private renderSelection(selection: RouterOutletSelection) {
this.pendingSelection = selection;
const pending = selection.pending;
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
const needsPendingFallback = coldPending && !selection.active;
if (!needsPendingFallback) {
this.clearPendingTimer();
this.pendingMatchId = undefined;
this.showPending = false;
} else if (this.pendingMatchId !== pending.id) {
this.clearPendingTimer();
this.pendingMatchId = pending.id;
this.showPending = false;
this.pendingTimer = globalThis.setTimeout(() => {
this.pendingTimer = undefined;
const pendingSelection = this.pendingSelection;
if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) {
return;
}
this.showPending = true;
this.setValue(this.renderSelection(pendingSelection));
}, PENDING_UI_DELAY_MS);
}
if (selection.status === "notFound") {
if (!this.notFoundScheduled) {
this.notFoundScheduled = true;
queueMicrotask(() => {
this.notFoundScheduled = false;
this.boundaryOptions?.onNotFound?.();
});
}
} else {
this.notFoundScheduled = false;
}
const router = this.router;
if (!router) {
return nothing;
}
return renderRouterOutlet(
router,
{ ...selection, showPending: this.showPending },
{
retryContext: this.retryContext,
},
);
}
private clearPendingTimer() {
if (this.pendingTimer !== undefined) {
globalThis.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined;
}
}
}
const routerOutletDirective = directive(RouterOutletDirective);
export function routerOutlet<TRouteId extends string, TModule, TData, TContext>(
router: Router<TRouteId, TContext, TModule, TData>,
boundaryOptions: RouterOutletBoundaryOptions,
options: RouterOutletOptions<TContext> = {},
): unknown {
return routerOutletDirective(router, options.retryContext, boundaryOptions);
}
export class OpenClawRouterOutlet<
TRouteId extends string = string,
TLoadContext = unknown,
TModule = unknown,
TData = unknown,
> extends LitElement {
@property({ attribute: false }) router?: Router<TRouteId, TLoadContext, TModule, TData>;
@property({ attribute: false }) retryContext?: TLoadContext;
@property({ attribute: false }) onNotFound?: () => void;
override createRenderRoot() {
return this;
}
override render() {
if (!this.router) {
return nothing;
}
return routerOutlet(
this.router,
{ onNotFound: this.onNotFound },
{
retryContext: this.retryContext,
},
);
}
}
if (!customElements.get("openclaw-router-outlet")) {
customElements.define("openclaw-router-outlet", OpenClawRouterOutlet);
}
@@ -7,7 +7,7 @@ import {
loadSettings,
saveLocalUserIdentity,
saveSettings,
} from "./storage.ts";
} from "./settings.ts";
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
vi.stubGlobal("location", {
@@ -19,17 +19,18 @@ function setTestLocation(params: { protocol: string; host: string; pathname: str
}
function setControlUiBasePath(value: string | undefined) {
type TestWindow = Window & typeof globalThis & { [key: string]: unknown };
if (typeof window === "undefined") {
vi.stubGlobal(
"window",
value == null
? ({} as Window & typeof globalThis)
: ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis),
? ({} as TestWindow)
: ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as unknown as TestWindow),
);
return;
}
if (value == null) {
delete window["__OPENCLAW_CONTROL_UI_BASE_PATH__"];
delete (window as TestWindow)["__OPENCLAW_CONTROL_UI_BASE_PATH__"];
return;
}
Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", {
+221 -96
View File
@@ -2,11 +2,15 @@
const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:";
const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1";
const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1";
const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
const MAX_SCOPED_SESSION_ENTRIES = 10;
type WindowWithControlUiBasePath = Window &
typeof globalThis & {
[key: string]: unknown;
};
function settingsKeyForGateway(gatewayUrl: string): string {
return `${SETTINGS_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
}
@@ -23,11 +27,11 @@ type PersistedUiSettings = Omit<UiSettings, "token" | "sessionKey" | "lastActive
sessionsByGateway?: Record<string, ScopedSessionSelection>;
};
import { inferBasePathFromPathname, normalizeBasePath } from "../app-route-paths.ts";
import { isSupportedLocale } from "../i18n/index.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { getSafeLocalStorage, getSafeSessionStorage } from "../local-storage.ts";
import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
import {
hasLocalUserIdentity,
@@ -103,6 +107,193 @@ export type UiSettings = {
export type { LocalUserIdentity } from "./user-identity.ts";
type LastActiveSessionHost = {
settings: UiSettings;
applySettings(next: UiSettings): void;
};
export function setLastActiveSessionKey(host: LastActiveSessionHost, next: string) {
const trimmed = next.trim();
if (!trimmed || host.settings.lastActiveSessionKey === trimmed) {
return;
}
host.applySettings({ ...host.settings, lastActiveSessionKey: trimmed });
}
export type ApplicationStartupLocation = {
pathname: string;
search: string;
hash: string;
};
type NativeControlAuth = {
gatewayUrl?: string | null;
token?: string | null;
password?: string | null;
};
export type ApplicationStartupSettings = {
settings: UiSettings;
password: string | null;
pendingGatewayUrl: string | null;
pendingGatewayToken: string | null;
queryTokenUsed: boolean;
location: ApplicationStartupLocation;
changed: boolean;
};
declare global {
interface Window {
__OPENCLAW_NATIVE_CONTROL_AUTH__?: NativeControlAuth;
}
}
export function resolveApplicationStartupSettings(
initialSettings: UiSettings,
location: ApplicationStartupLocation,
): ApplicationStartupSettings {
let settings = initialSettings;
let changed = false;
let password: string | null = null;
let pendingGatewayUrl: string | null = null;
let pendingGatewayToken: string | null = null;
let queryTokenUsed = false;
const updateSettings = (patch: Partial<UiSettings>) => {
const entries = Object.entries(patch) as Array<
[keyof UiSettings, UiSettings[keyof UiSettings]]
>;
if (entries.every(([key, value]) => settings[key] === value)) {
return;
}
settings = { ...settings, ...patch };
changed = true;
};
const nativeAuth =
typeof window === "undefined" ? undefined : window["__OPENCLAW_NATIVE_CONTROL_AUTH__"];
if (nativeAuth) {
try {
delete window["__OPENCLAW_NATIVE_CONTROL_AUTH__"];
} catch {
window["__OPENCLAW_NATIVE_CONTROL_AUTH__"] = undefined;
}
const gatewayUrl = normalizeOptionalString(nativeAuth.gatewayUrl);
const token = normalizeOptionalString(nativeAuth.token);
const nativePassword = normalizeOptionalString(nativeAuth.password);
updateSettings({
...(gatewayUrl ? { gatewayUrl } : {}),
...(token ? { token } : {}),
});
if (nativePassword) {
password = nativePassword;
}
}
if (!location.search && !location.hash) {
return {
settings,
password,
pendingGatewayUrl,
pendingGatewayToken,
queryTokenUsed,
location,
changed,
};
}
const url = new URL(
`${location.pathname}${location.search}${location.hash}`,
"http://openclaw.local",
);
const params = new URLSearchParams(url.search);
const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
const nextGatewayUrl = normalizeOptionalString(gatewayUrlRaw) ?? "";
const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== settings.gatewayUrl);
const queryToken = params.get("token");
const hashToken = hashParams.get("token");
const hasTokenParam = hashToken != null || queryToken != null;
const token = normalizeOptionalString(hashToken ?? queryToken);
const session = normalizeOptionalString(params.get("session") ?? hashParams.get("session"));
const shouldResetSessionForToken = Boolean(token && !session && !gatewayUrlChanged);
let shouldCleanUrl = false;
if (params.has("token")) {
params.delete("token");
shouldCleanUrl = true;
}
if (hasTokenParam) {
if (queryToken != null) {
queryTokenUsed = true;
console.warn(
"[openclaw] Auth token passed as query parameter (?token=). Use URL fragment instead: #token=<token>. Query parameters may appear in server logs.",
);
}
if (token && gatewayUrlChanged) {
pendingGatewayToken = token;
} else if (token) {
updateSettings({ token });
}
hashParams.delete("token");
shouldCleanUrl = true;
}
if (shouldResetSessionForToken) {
updateSettings({
sessionKey: "main",
lastActiveSessionKey: "main",
});
}
if (params.has("password") || hashParams.has("password")) {
params.delete("password");
hashParams.delete("password");
shouldCleanUrl = true;
}
if (session) {
updateSettings({
sessionKey: session,
lastActiveSessionKey: session,
});
}
if (gatewayUrlRaw != null) {
pendingGatewayUrl = gatewayUrlChanged ? nextGatewayUrl : null;
if (!gatewayUrlChanged) {
pendingGatewayToken = null;
}
params.delete("gatewayUrl");
hashParams.delete("gatewayUrl");
shouldCleanUrl = true;
}
if (shouldCleanUrl) {
url.search = params.toString();
const nextHash = hashParams.toString();
url.hash = nextHash ? `#${nextHash}` : "";
}
return {
settings,
password,
pendingGatewayUrl,
pendingGatewayToken,
queryTokenUsed,
location: shouldCleanUrl
? {
pathname: url.pathname,
search: url.search,
hash: url.hash,
}
: location,
changed,
};
}
function isViteDevPage(): boolean {
if (typeof document === "undefined") {
return false;
@@ -119,7 +310,9 @@ function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } {
const proto = location.protocol === "https:" ? "wss" : "ws";
const configured =
typeof window !== "undefined" &&
normalizeOptionalString(window["__OPENCLAW_CONTROL_UI_BASE_PATH__"]);
normalizeOptionalString(
(window as WindowWithControlUiBasePath)["__OPENCLAW_CONTROL_UI_BASE_PATH__"],
);
const basePath = configured
? normalizeBasePath(configured)
: inferBasePathFromPathname(location.pathname);
@@ -161,7 +354,7 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string {
function resolveScopedSessionSelection(
gatewayUrl: string,
parsed: PersistedUiSettings,
defaults: UiSettings,
fallback: ScopedSessionSelection,
): ScopedSessionSelection {
const scope = normalizeGatewayTokenScope(gatewayUrl);
const scoped = parsed.sessionsByGateway?.[scope];
@@ -174,11 +367,11 @@ function resolveScopedSessionSelection(
};
}
const legacySessionKey = normalizeOptionalString(parsed.sessionKey) ?? defaults.sessionKey;
const legacySessionKey = normalizeOptionalString(parsed.sessionKey) ?? fallback.sessionKey;
const legacyLastActiveSessionKey =
normalizeOptionalString(parsed.lastActiveSessionKey) ??
legacySessionKey ??
defaults.lastActiveSessionKey;
fallback.lastActiveSessionKey;
return {
sessionKey: legacySessionKey,
@@ -186,6 +379,20 @@ function resolveScopedSessionSelection(
};
}
export function loadGatewaySessionSelection(gatewayUrl: string): ScopedSessionSelection {
const fallback = { sessionKey: "main", lastActiveSessionKey: "main" };
try {
const storage = getSafeLocalStorage();
const raw =
storage?.getItem(settingsKeyForGateway(gatewayUrl)) ?? storage?.getItem(LEGACY_SETTINGS_KEY);
return raw
? resolveScopedSessionSelection(gatewayUrl, JSON.parse(raw) as PersistedUiSettings, fallback)
: fallback;
} catch {
return fallback;
}
}
function loadSessionToken(gatewayUrl: string): string {
try {
const storage = getSessionStorage();
@@ -277,7 +484,7 @@ export function loadSettings(): UiSettings {
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
);
const settings = {
const settings: UiSettings = {
gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: loadSessionToken(gatewayUrl),
@@ -341,6 +548,12 @@ export function saveSettings(next: UiSettings) {
persistSettings(next);
}
export function patchSettings(patch: Partial<UiSettings>): UiSettings {
const next = { ...loadSettings(), ...patch };
persistSettings(next);
return next;
}
export function loadLocalUserIdentity(): LocalUserIdentity {
const storage = getSafeLocalStorage();
try {
@@ -369,94 +582,6 @@ export function saveLocalUserIdentity(next: LocalUserIdentity) {
}
}
export type LocalAssistantIdentity = { avatar: string | null; agentId?: string | null };
type PersistedLocalAssistantIdentities = {
avatars?: Record<string, unknown>;
avatar?: unknown;
agentId?: unknown;
};
function parseLocalAssistantAvatarMap(raw: string): {
avatars: Record<string, string>;
legacyAvatar: string | null;
} {
const parsed = JSON.parse(raw) as PersistedLocalAssistantIdentities;
const avatars = Object.create(null) as Record<string, string>;
if (parsed.avatars && typeof parsed.avatars === "object" && !Array.isArray(parsed.avatars)) {
for (const [agentId, avatar] of Object.entries(parsed.avatars)) {
const normalizedAgentId = normalizeOptionalString(agentId);
const normalizedAvatar = normalizeOptionalString(avatar);
if (normalizedAgentId && normalizedAvatar) {
avatars[normalizedAgentId] = normalizedAvatar;
}
}
}
const legacyAvatar = normalizeOptionalString(parsed.avatar);
const legacyAgentId = normalizeOptionalString(parsed.agentId);
if (legacyAvatar && legacyAgentId && !Object.hasOwn(avatars, legacyAgentId)) {
avatars[legacyAgentId] = legacyAvatar;
}
return { avatars, legacyAvatar: legacyAgentId ? null : (legacyAvatar ?? null) };
}
function persistLocalAssistantAvatarMap(storage: Storage | null, avatars: Record<string, string>) {
if (Object.keys(avatars).length === 0) {
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
return;
}
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatars }));
}
export function loadLocalAssistantIdentity(opts?: {
agentId?: string | null;
}): LocalAssistantIdentity {
const agentId = normalizeOptionalString(opts?.agentId);
if (!agentId) {
return { avatar: null };
}
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
if (!raw) {
return { avatar: null };
}
const { avatars, legacyAvatar } = parseLocalAssistantAvatarMap(raw);
if (!Object.hasOwn(avatars, agentId) && legacyAvatar) {
// Assign the old global override to the first concrete agent that loads it.
avatars[agentId] = legacyAvatar;
persistLocalAssistantAvatarMap(storage, avatars);
}
return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId };
} catch {
return { avatar: null };
}
}
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
const agentId = normalizeOptionalString(next.agentId);
if (!agentId) {
return;
}
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
const avatars = raw
? parseLocalAssistantAvatarMap(raw).avatars
: (Object.create(null) as Record<string, string>);
const avatar = normalizeOptionalString(next.avatar);
if (avatar) {
avatars[agentId] = avatar;
} else {
delete avatars[agentId];
}
persistLocalAssistantAvatarMap(storage, avatars);
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
}
function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token);
const storage = getSafeLocalStorage();
@@ -1,10 +1,7 @@
// Control UI module implements user identity behavior.
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
import { normalizeOptionalString } from "./string-coerce.ts";
import {
isRenderableControlUiAvatarUrl,
resolveChatAvatarRenderUrl,
} from "./views/agents-utils.ts";
import { isRenderableControlUiAvatarUrl, resolveChatAvatarRenderUrl } from "../lib/avatar.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
const MAX_LOCAL_USER_NAME = 50;
const MAX_LOCAL_USER_TEXT_AVATAR = 16;
@@ -1,4 +1,3 @@
// Control UI tests cover control ui vite config behavior.
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
@@ -77,7 +76,7 @@ describe("Control UI Vite config", () => {
{ custom: {}, isEntry: false, ssr: false },
);
expect(resolved).toBe(path.join(repoRoot, "ui/src/ui/browser-redact.ts"));
expect(resolved).toBe(path.join(repoRoot, "ui/src/lib/browser-redact.ts"));
}
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
const SW_READY_TIMEOUT = 10_000;
function swReady(): Promise<ServiceWorkerRegistration> {
return Promise.race([
navigator.serviceWorker.ready,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT);
}),
]);
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const output = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) {
output[i] = raw.charCodeAt(i);
}
return output;
}
export async function getExistingSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator)) {
return null;
}
const registration = await swReady();
return await registration.pushManager.getSubscription();
}
export async function subscribeToWebPush(
client: GatewayBrowserClient,
): Promise<{ subscriptionId: string }> {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error(`Notification permission ${permission}`);
}
const vapidRes = await client.request("push.web.vapidPublicKey", {});
const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey;
if (!vapidPublicKey) {
throw new Error("Failed to retrieve VAPID public key");
}
const registration = await swReady();
const pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer,
});
const subscription = pushSubscription.toJSON();
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys.auth) {
throw new Error("Invalid push subscription from browser");
}
try {
return (await client.request("push.web.subscribe", {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
})) as { subscriptionId: string };
} catch (error) {
try {
await pushSubscription.unsubscribe();
} catch {
// The Gateway error remains the actionable failure.
}
throw error;
}
}
export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise<void> {
const registration = await swReady();
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
return;
}
try {
await client.request("push.web.unsubscribe", {
endpoint: subscription.endpoint,
});
} catch {
// Local unsubscribe still prevents a stale browser subscription.
}
await subscription.unsubscribe();
}
export async function sendTestWebPush(client: GatewayBrowserClient): Promise<void> {
await client.request("push.web.test", {});
}
+147
View File
@@ -0,0 +1,147 @@
// Application-owned browser push subscription lifecycle.
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationGateway } from "./gateway.ts";
export type WebPushSnapshot = {
supported: boolean;
permission: NotificationPermission | "unsupported";
subscribed: boolean;
loading: boolean;
error: string | null;
};
export type WebPushCapability = {
readonly snapshot: WebPushSnapshot;
subscribe: (listener: (snapshot: WebPushSnapshot) => void) => () => void;
enable: () => Promise<void>;
disable: () => Promise<void>;
sendTest: () => Promise<void>;
dispose: () => void;
};
function isWebPushSupported(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
"Notification" in window
);
}
function webPushError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createWebPushCapability(gateway: ApplicationGateway): WebPushCapability {
const supported = isWebPushSupported();
let snapshot: WebPushSnapshot = {
supported,
permission: supported ? Notification.permission : "unsupported",
subscribed: false,
loading: false,
error: null,
};
let disposed = false;
let wasConnected = false;
let operation: Promise<void> | null = null;
const listeners = new Set<(snapshot: WebPushSnapshot) => void>();
const publish = (patch: Partial<WebPushSnapshot>) => {
if (disposed) {
return;
}
snapshot = { ...snapshot, ...patch };
for (const listener of listeners) {
listener(snapshot);
}
};
const readExistingSubscription = async () => {
if (!supported) {
return null;
}
const { getExistingSubscription } = await import("./web-push.runtime.ts");
const subscription = await getExistingSubscription();
publish({ subscribed: subscription !== null });
return subscription;
};
const reconcile = async (client: GatewayBrowserClient) => {
try {
const subscription = await readExistingSubscription();
const json = subscription?.toJSON();
if (!json?.endpoint || !json.keys?.p256dh || !json.keys.auth) {
return;
}
await client.request("push.web.subscribe", {
endpoint: json.endpoint,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
});
} catch {
// Existing subscriptions are reconciled best-effort after reconnect.
}
};
const run = (action: (client: GatewayBrowserClient) => Promise<void>) => {
const client = gateway.snapshot.client;
if (!supported || !client || operation) {
return operation ?? Promise.resolve();
}
publish({ loading: true, error: null });
operation = action(client)
.catch((error: unknown) => {
publish({ error: webPushError(error) });
})
.finally(() => {
operation = null;
publish({
loading: false,
permission: "Notification" in window ? Notification.permission : "unsupported",
});
});
return operation;
};
void readExistingSubscription().catch(() => {});
const stopGateway = gateway.subscribe((gatewaySnapshot) => {
const client = gatewaySnapshot.client;
const connected = gatewaySnapshot.connected && client !== null;
if (connected && !wasConnected && client) {
void reconcile(client);
}
wasConnected = connected;
});
return {
get snapshot() {
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
enable: () =>
run(async (client) => {
const { subscribeToWebPush } = await import("./web-push.runtime.ts");
await subscribeToWebPush(client);
publish({ subscribed: true });
}),
disable: () =>
run(async (client) => {
const { unsubscribeFromWebPush } = await import("./web-push.runtime.ts");
await unsubscribeFromWebPush(client);
publish({ subscribed: false });
}),
sendTest: () =>
run(async (client) => {
const { sendTestWebPush } = await import("./web-push.runtime.ts");
await sendTestWebPush(client);
}),
dispose() {
disposed = true;
stopGateway();
listeners.clear();
},
};
}
+808
View File
@@ -0,0 +1,808 @@
import { consume } from "@lit/context";
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ModelAuthStatusResult, SessionsListResult } from "../api/types.ts";
import {
cancelRoutePreload,
isSettingsNavigationRoute,
navigationIconForRoute,
scheduleRoutePreload,
type NavigationRouteId,
SIDEBAR_SECTIONS,
titleForRoute,
} from "../app-navigation.ts";
import { pathForRoute, type RouteId } from "../app-route-paths.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationNavigationOptions,
} from "../app/context.ts";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import "./theme-mode-toggle.ts";
import "./session-picker.ts";
import "./tooltip.ts";
import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts";
import { formatRelativeTimestamp } from "../lib/format.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
compareSessionRowsByUpdatedAt,
resolveSessionNavigation,
searchForSession,
} from "../lib/sessions/index.ts";
import {
buildAgentMainSessionKey,
canArchiveSessionRow,
normalizeAgentId,
parseAgentSessionKey,
resolveUiConfiguredMainKey,
} from "../lib/sessions/session-key.ts";
import {
resolvePreferredSessionForAgent,
resolveSessionAgentFilterOptions,
} from "../lib/sessions/session-options.ts";
import { icons } from "./icons.ts";
type ProviderQuotaPillRenderer = typeof import("./provider-quota-pill.ts").renderProviderQuotaPill;
type SidebarRecentSession = {
key: string;
label: string;
meta: string;
href: string;
active: boolean;
hasActiveRun: boolean;
kind?: string;
pinned: boolean;
pinnedAt?: number | null;
};
function shouldHandleNavigationClick(event: MouseEvent): boolean {
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
export class AppSidebar extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) collapsed = false;
@property({ attribute: false }) connected = false;
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) navGroupsCollapsed: Record<string, boolean> = {};
@property({ attribute: false }) recentSessionsCollapsed = false;
@property({ attribute: false }) themeMode: ThemeMode = "system";
@property({ attribute: false }) onToggleCollapsed?: () => void;
@property({ attribute: false }) onToggleGroup?: (label: string) => void;
@property({ attribute: false }) onToggleRecentSessions?: () => void;
@property({ attribute: false })
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
@property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
@consume({ context: applicationContext, subscribe: false })
private context?: ApplicationContext<RouteId>;
@state() private sessionsResult: SessionsListResult | null = null;
@state() private sessionsAgentId: string | null = null;
@state() private sessionsLoading = false;
@state() private modelAuthStatusResult: ModelAuthStatusResult | null = null;
@state() private providerQuotaPillRenderer: ProviderQuotaPillRenderer | null = null;
private stopSessionsSubscription: (() => void) | undefined;
private stopAgentsSubscription: (() => void) | undefined;
private stopAgentSelectionSubscription: (() => void) | undefined;
private stopGatewaySubscription: (() => void) | undefined;
private sessionRowsByAgent: Record<string, SessionsListResult["sessions"]> = {};
private modelAuthClient: GatewayBrowserClient | null = null;
private readonly routePreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
>();
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
this.startSubscriptions();
}
override disconnectedCallback() {
this.stopSessionsSubscription?.();
this.stopSessionsSubscription = undefined;
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopAgentSelectionSubscription?.();
this.stopAgentSelectionSubscription = undefined;
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.modelAuthClient = null;
for (const timer of this.routePreloadTimers.values()) {
globalThis.clearTimeout(timer);
}
this.routePreloadTimers.clear();
super.disconnectedCallback();
}
private startSubscriptions() {
const context = this.context;
if (
!context ||
this.stopSessionsSubscription ||
this.stopAgentsSubscription ||
this.stopAgentSelectionSubscription ||
this.stopGatewaySubscription
) {
return;
}
this.updateModelAuthStatus(context.gateway.snapshot);
this.updateSessions(context.sessions.state);
this.stopSessionsSubscription = context.sessions.subscribe((snapshot) => {
this.updateSessions(snapshot);
});
this.stopAgentsSubscription = context.agents.subscribe(() => {
this.requestUpdate();
});
this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => {
this.requestUpdate();
});
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
this.updateModelAuthStatus(snapshot);
this.requestUpdate();
});
}
override updated() {
this.startSubscriptions();
}
private readonly updateSessions = (snapshot: {
result: SessionsListResult | null;
agentId: string | null;
loading: boolean;
}) => {
this.sessionsResult = snapshot.result;
this.sessionsAgentId = snapshot.agentId;
this.sessionsLoading = snapshot.loading;
if (snapshot.result && snapshot.agentId) {
this.sessionRowsByAgent[normalizeAgentId(snapshot.agentId)] = snapshot.result.sessions;
}
};
private updateModelAuthStatus(snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
}) {
const client = snapshot.connected ? snapshot.client : null;
if (client === this.modelAuthClient) {
return;
}
this.sessionRowsByAgent = {};
this.modelAuthClient = client;
this.modelAuthStatusResult = null;
if (!client) {
return;
}
void import("../lib/model-auth.ts")
.then(async ({ isMonitoredAuthProvider, loadModelAuthStatus }) => {
const result = await loadModelAuthStatus(client);
if (this.modelAuthClient !== client) {
return;
}
this.modelAuthStatusResult = result;
const hasQuota = result.providers.some(
(provider) =>
isMonitoredAuthProvider(provider) && Boolean(provider.usage?.windows?.length),
);
if (!hasQuota || this.providerQuotaPillRenderer) {
return;
}
const { renderProviderQuotaPill } = await import("./provider-quota-pill.ts");
if (this.modelAuthClient === client) {
this.providerQuotaPillRenderer = renderProviderQuotaPill;
}
})
.catch(() => {
if (this.modelAuthClient === client) {
this.modelAuthStatusResult = null;
}
});
}
private getRouteSessionKey(): string {
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
}
private getSessionNavigationState() {
const context = this.context;
const routeSessionKey = this.getRouteSessionKey();
const navigation = resolveSessionNavigation({
result: this.sessionsResult,
resultAgentId: this.sessionsAgentId,
sessionKey: routeSessionKey,
assistantAgentId:
context?.agentSelection.state.selectedId ?? context?.gateway.snapshot.assistantAgentId,
hello: context?.gateway.snapshot.hello,
});
const toSidebarSession = (row: SessionsListResult["sessions"][number]) => ({
key: row.key,
label: resolveSessionDisplayName(row.key, row),
meta: row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "",
href: `${pathForRoute("chat", context?.basePath ?? "")}${searchForSession(row.key)}`,
active: row.key === navigation.currentSessionKey,
hasActiveRun: Boolean(row.hasActiveRun),
kind: row.kind,
pinned: row.pinned === true,
pinnedAt: row.pinnedAt,
});
const activeSession = navigation.selectedSession
? toSidebarSession(navigation.selectedSession)
: null;
const recentSessions = navigation.recentSessions
.slice(activeSession ? 1 : 0)
.toSorted(compareSessionRowsByUpdatedAt)
.map(toSidebarSession);
const newSessionDisabled =
!this.connected || this.sessionsLoading || Boolean(navigation.selectedSession?.hasActiveRun);
return {
routeSessionKey: navigation.currentSessionKey,
selectedAgentId: navigation.selectedAgentId,
defaultAgentId: navigation.defaultAgentId,
activeSession,
recentSessions,
newSessionDisabled,
newSessionTitle: !this.connected
? "Connect to create a new session"
: navigation.selectedSession?.hasActiveRun
? "Finish the active run before creating a new session"
: "New session",
};
}
private readonly selectSession = (sessionKey: string) => {
this.context?.gateway.setSessionKey(sessionKey);
this.onNavigate?.("chat", {
search: searchForSession(sessionKey),
});
};
private readonly replaceCurrentSession = (sessionKey: string) => {
this.context?.gateway.setSessionKey(sessionKey);
if (this.activeRouteId === "chat") {
this.onNavigate?.("chat", {
search: searchForSession(sessionKey),
});
}
};
private readonly selectAgent = (agentId: string) => {
const context = this.context;
if (!context) {
return;
}
const { routeSessionKey, selectedAgentId } = this.getSessionNavigationState();
const nextAgentId = normalizeAgentId(agentId);
if (nextAgentId === normalizeAgentId(selectedAgentId)) {
return;
}
const nextSessionKey = resolvePreferredSessionForAgent(
{
agentsList: context.agents.state.agentsList,
chatAgentSessionRowsByAgent: this.sessionRowsByAgent,
sessionsResult: this.sessionsResult,
sessionsResultAgentId: this.sessionsAgentId,
sessionKey: routeSessionKey,
},
nextAgentId,
);
context.agentSelection.set(nextAgentId);
this.selectSession(nextSessionKey);
};
private readonly createSession = async () => {
const context = this.context;
if (!context) {
return;
}
const { routeSessionKey, selectedAgentId, newSessionDisabled } =
this.getSessionNavigationState();
if (newSessionDisabled) {
return;
}
const nextSessionKey = await context.sessions.create({
currentSessionKey: routeSessionKey,
agentId: selectedAgentId,
});
if (nextSessionKey) {
this.selectSession(nextSessionKey);
}
};
private readonly patchSession = async (
session: SidebarRecentSession,
patch: { archived?: boolean; pinned?: boolean },
) => {
const context = this.context;
if (!context || !this.connected) {
return;
}
const { selectedAgentId } = this.getSessionNavigationState();
const agentId = parseAgentSessionKey(session.key)?.agentId;
try {
const patched = await context.sessions.patch(session.key, patch, agentId ? { agentId } : {});
if (!patched || patch.archived !== true || !session.active) {
return;
}
this.replaceCurrentSession(
buildAgentMainSessionKey({
agentId: agentId ?? selectedAgentId,
mainKey: resolveUiConfiguredMainKey({
agentsList: context.agents.state.agentsList,
hello: context.gateway.snapshot.hello,
}),
}),
);
} catch {
// Session capability publishes the actionable error for the owning page.
}
};
private preloadRoute(routeId: NavigationRouteId, event: Event, immediate = false) {
scheduleRoutePreload(
this.routePreloadTimers,
routeId,
event,
(nextRouteId) => this.onPreloadRoute?.(nextRouteId),
routeId === this.activeRouteId || !this.isRouteEnabled(routeId),
immediate,
);
}
private readonly cancelPreload = (event: Event) => {
cancelRoutePreload(this.routePreloadTimers, event);
};
private isRouteEnabled(routeId: NavigationRouteId): boolean {
return this.enabledRouteIds?.includes(routeId) ?? true;
}
private renderRoute(routeId: NavigationRouteId) {
const active =
routeId === "config"
? this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId)
: this.activeRouteId === routeId;
const enabled = this.isRouteEnabled(routeId);
if (!enabled) {
return html`
<span class="nav-item nav-item--disabled" aria-disabled="true">
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
${!this.collapsed
? html`<span class="nav-item__text">${titleForRoute(routeId)}</span>`
: nothing}
</span>
`;
}
const routeSessionKey = routeId === "chat" ? this.getRouteSessionKey() : "";
const href =
routeSessionKey && routeId === "chat"
? `${pathForRoute("chat", this.basePath)}${searchForSession(routeSessionKey)}`
: pathForRoute(routeId, this.basePath);
const label = titleForRoute(routeId);
const link = html`
<a
href=${href}
class="nav-item ${active ? "nav-item--active" : ""}"
@focus=${(event: Event) => this.preloadRoute(routeId, event)}
@blur=${this.cancelPreload}
@pointerenter=${(event: Event) => this.preloadRoute(routeId, event)}
@pointerleave=${this.cancelPreload}
@touchstart=${(event: TouchEvent) => this.preloadRoute(routeId, event, true)}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.(
routeId,
routeId === "chat" && routeSessionKey
? {
search: searchForSession(routeSessionKey),
}
: undefined,
);
}}
>
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
${!this.collapsed ? html`<span class="nav-item__text">${label}</span>` : nothing}
</a>
`;
return this.collapsed
? html`<openclaw-tooltip .content=${label}>${link}</openclaw-tooltip>`
: link;
}
private renderRecentSession(session: SidebarRecentSession) {
const context = this.context;
const archiveAllowed = canArchiveSessionRow(
session,
resolveUiConfiguredMainKey({
agentsList: context?.agents.state.agentsList,
hello: context?.gateway.snapshot.hello,
}),
);
const rowClass = [
"sidebar-recent-session",
"session-row-host",
session.active ? "sidebar-recent-session--active" : "",
session.pinned ? "session-row-host--pinned" : "",
session.hasActiveRun ? "session-row-host--running" : "",
]
.filter(Boolean)
.join(" ");
return html`
<div class=${rowClass} data-session-key=${session.key}>
<a
href=${session.href}
class="sidebar-recent-session__link"
title=${`${session.label} · ${session.key}`}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.selectSession(session.key);
}}
>
<span class="sidebar-recent-session__name">${session.label}</span>
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail">
${session.hasActiveRun
? html`<span
class="session-run-spinner"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: session.meta}
</span>
<span class="session-row-actions">
<button
class="session-action"
data-sidebar-session-archive="true"
type="button"
title=${t("sessionsView.archiveSession")}
aria-label=${t("sessionsView.archiveSession")}
?disabled=${!this.connected || !archiveAllowed}
@click=${() => void this.patchSession(session, { archived: true })}
>
${icons.archive}
</button>
<button
class="session-action session-action--pin"
data-sidebar-session-pin="true"
type="button"
title=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
aria-label=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
?disabled=${!this.connected}
@click=${() => void this.patchSession(session, { pinned: !session.pinned })}
>
${icons.pin}
</button>
</span>
</span>
</div>
`;
}
private renderSessions() {
const context = this.context;
const {
routeSessionKey,
selectedAgentId,
defaultAgentId,
activeSession,
recentSessions,
newSessionDisabled,
newSessionTitle,
} = this.getSessionNavigationState();
const newSessionButton = html`
<button
type="button"
class="sidebar-new-session"
aria-label=${t("chat.runControls.newSession")}
?disabled=${newSessionDisabled}
@click=${this.createSession}
>
<span class="sidebar-new-session__icon" aria-hidden="true">${icons.plus}</span>
${this.collapsed
? nothing
: html`<span class="sidebar-new-session__label"
>${t("chat.runControls.newSession")}</span
>`}
</button>
`;
return html`
<section class="sidebar-sessions ${this.collapsed ? "sidebar-sessions--collapsed" : ""}">
${this.collapsed
? html`<openclaw-tooltip .content=${newSessionTitle}
>${newSessionButton}</openclaw-tooltip
>`
: newSessionButton}
${this.collapsed
? nothing
: html`
<div
class="sidebar-recent-sessions ${this.recentSessionsCollapsed
? "sidebar-recent-sessions--collapsed"
: ""}"
aria-label=${t("overview.cards.recentSessions")}
>
<div class="sidebar-recent-sessions__head">
<button
class="sidebar-recent-sessions__label"
type="button"
aria-expanded=${String(!this.recentSessionsCollapsed)}
@click=${() => this.onToggleRecentSessions?.()}
>
<span class="sidebar-recent-sessions__label-text"
>${t("usage.sessions.recentShort")}</span
>
<span class="sidebar-recent-sessions__chevron"> ${icons.chevronDown} </span>
</button>
<openclaw-session-picker
.sessions=${context?.sessions}
.sessionsResult=${this.sessionsResult}
.currentSessionKey=${routeSessionKey}
.agentId=${selectedAgentId}
.defaultAgentId=${defaultAgentId}
.mainKey=${resolveUiConfiguredMainKey({
agentsList: context?.agents.state.agentsList,
hello: context?.gateway.snapshot.hello,
})}
.connected=${this.connected}
.onSelectSession=${this.selectSession}
.onReplaceCurrentSession=${this.replaceCurrentSession}
></openclaw-session-picker>
</div>
${this.renderAgentFilter(routeSessionKey, selectedAgentId)}
${activeSession
? this.renderRecentSession(activeSession)
: this.renderChatFallback()}
${recentSessions.length === 0
? nothing
: html`
<div class="sidebar-recent-sessions__list">
${recentSessions.map((session) => this.renderRecentSession(session))}
</div>
`}
<a
href=${pathForRoute("sessions", this.basePath)}
class="sidebar-recent-sessions__all"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("sessions");
}}
>
<span>${t("chat.sidebar.allSessions")}</span>
<span class="sidebar-recent-sessions__all-icon" aria-hidden="true"
>${icons.chevronRight}</span
>
</a>
</div>
`}
</section>
`;
}
private renderAgentFilter(sessionKey: string, selectedAgentId: string) {
const options = resolveSessionAgentFilterOptions({
agentsList: this.context?.agents.state.agentsList,
sessionsResult: this.sessionsResult,
sessionsResultAgentId: this.sessionsAgentId,
sessionKey,
});
if (options.length <= 1) {
return nothing;
}
const selectedLabel =
options.find((option) => option.id === selectedAgentId)?.label ?? selectedAgentId;
return html`
<div class="sidebar-agent-filter">
<label class="field chat-controls__session chat-controls__agent">
<select
data-chat-agent-filter="true"
aria-label=${t("chat.selectors.agentFilter")}
title=${selectedLabel}
.value=${selectedAgentId}
?disabled=${!this.connected}
@change=${(event: Event) => this.selectAgent((event.target as HTMLSelectElement).value)}
>
${options.map(
(option) =>
html`<option value=${option.id} ?selected=${option.id === selectedAgentId}>
${option.label}
</option>`,
)}
</select>
</label>
</div>
`;
}
private renderChatFallback() {
return html`
<a
href=${pathForRoute("chat", this.basePath)}
class="sidebar-recent-session ${this.activeRouteId === "chat"
? "sidebar-recent-session--active"
: ""}"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("chat");
}}
>
<span class="sidebar-recent-session__body">
<span class="sidebar-recent-session__name">${t("nav.chat")}</span>
</span>
</a>
`;
}
override render() {
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const quotaPill = this.collapsed
? ""
: (this.providerQuotaPillRenderer?.({
basePath: this.basePath,
modelAuthStatusResult: this.modelAuthStatusResult,
}) ?? "");
return html`
<aside class="sidebar ${this.collapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-shell">
<div class="sidebar-shell__header">
<div class="sidebar-brand">
${this.collapsed
? nothing
: html`
<img
class="sidebar-brand__logo"
src="${controlUiPublicAssetPath("favicon.svg", this.basePath)}"
alt="OpenClaw"
/>
<span class="sidebar-brand__copy">
<span class="sidebar-brand__title">OpenClaw</span>
</span>
`}
</div>
<openclaw-tooltip .content=${this.collapsed ? t("nav.expand") : t("nav.collapse")}>
<button
type="button"
class="nav-collapse-toggle"
@click=${() => this.onToggleCollapsed?.()}
aria-label=${this.collapsed ? t("nav.expand") : t("nav.collapse")}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true"
>${this.collapsed ? icons.panelLeftOpen : icons.panelLeftClose}</span
>
</button>
</openclaw-tooltip>
</div>
<div class="sidebar-shell__body">
${this.renderSessions()}
<nav class="sidebar-nav">
${SIDEBAR_SECTIONS.filter((group) => this.collapsed || group.label !== "chat").map(
(group) => {
const isGroupCollapsed = this.navGroupsCollapsed[group.label] ?? false;
const showItems = this.collapsed || !isGroupCollapsed;
return html`
<section class="nav-section ${!showItems ? "nav-section--collapsed" : ""}">
${!this.collapsed
? html`
<button
class="nav-section__label"
@click=${() => this.onToggleGroup?.(group.label)}
aria-expanded=${showItems}
>
<span class="nav-section__label-text"
>${t(`nav.${group.label}`)}</span
>
<span class="nav-section__chevron"> ${icons.chevronDown} </span>
</button>
`
: nothing}
<div class="nav-section__items">
${group.routes.map((routeId) => this.renderRoute(routeId))}
</div>
</section>
`;
},
)}
</nav>
</div>
<div class="sidebar-shell__footer">
<div class="sidebar-utility-group">
${quotaPill ? html`<div class="sidebar-quota">${quotaPill}</div>` : nothing}
${this.collapsed
? html`
<openclaw-tooltip
.content=${t("chat.docsOpensInNewTab", { label: t("common.docs") })}
>
<a
class="nav-item nav-item--external sidebar-utility-link"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
</a>
</openclaw-tooltip>
`
: html`
<a
class="nav-item nav-item--external sidebar-utility-link"
href="https://docs.openclaw.ai"
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span>
</a>
`}
<div class="sidebar-mode-switch">
<openclaw-theme-mode-toggle .mode=${this.themeMode}></openclaw-theme-mode-toggle>
</div>
<div class="sidebar-status">
<openclaw-tooltip .content=${gatewayStatus}>
<span
class="sidebar-status__dot ${this.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline"}"
role="img"
aria-live="polite"
aria-label=${gatewayStatus}
></span>
</openclaw-tooltip>
${this.collapsed
? nothing
: html`<span class="sidebar-status__text"
>${this.connected ? t("common.online") : t("common.offline")}</span
>`}
</div>
</div>
</div>
</div>
</aside>
`;
}
}
if (!customElements.get("openclaw-app-sidebar")) {
customElements.define("openclaw-app-sidebar", AppSidebar);
}
+113
View File
@@ -0,0 +1,113 @@
import { LitElement, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { NavigationRouteId } from "../app-navigation.ts";
import type { ThemeMode } from "../app/theme.ts";
import "./dashboard-header.ts";
import "./theme-mode-toggle.ts";
import "./tooltip.ts";
import { t } from "../i18n/index.ts";
import { icons } from "./icons.ts";
export class AppTopbar extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) routeId?: NavigationRouteId;
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) agentLabel = "";
@property({ attribute: false }) navDrawerOpen = false;
@property({ attribute: false }) onboarding = false;
@property({ attribute: false }) routeOwnsHeader = false;
@property({ attribute: false }) headerError: string | null = null;
@property({ attribute: false }) themeMode: ThemeMode = "system";
@property({ attribute: false }) onToggleDrawer?: (trigger: HTMLElement) => void;
@property({ attribute: false }) onOpenPalette?: () => void;
@property({ attribute: false }) onToggleTerminal?: () => void;
@property({ attribute: false }) onNavigate?: (routeId: NavigationRouteId) => void;
@property({ attribute: false }) overviewHref = "";
@property({ attribute: false }) searchDisabled = false;
@property({ attribute: false }) terminalAvailable = false;
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
}
private readonly handleNavigate = (event: CustomEvent<NavigationRouteId>) => {
this.onNavigate?.(event.detail);
};
override render() {
const drawerLabel = this.navDrawerOpen ? t("nav.collapse") : t("nav.expand");
const paletteLabel = t("chat.commandPaletteTitle");
return html`
<header
class="topbar"
?inert=${this.onboarding}
aria-hidden=${this.onboarding ? "true" : nothing}
>
<div class="topnav-shell">
<openclaw-tooltip .content=${drawerLabel}>
<button
type="button"
class="sidebar-menu-trigger topbar-nav-toggle"
@click=${(event: MouseEvent) =>
this.onToggleDrawer?.(event.currentTarget as HTMLElement)}
aria-label=${drawerLabel}
aria-expanded=${this.navDrawerOpen}
>
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
</button>
</openclaw-tooltip>
<div class="topnav-shell__content">
<dashboard-header
.routeId=${this.routeId}
.basePath=${this.basePath}
.agentLabel=${this.agentLabel}
.overviewHref=${this.overviewHref}
@navigate=${this.handleNavigate}
></dashboard-header>
</div>
<div class="topnav-shell__actions">
<openclaw-tooltip .content=${paletteLabel}>
<button
class="topbar-search"
?disabled=${this.searchDisabled || !this.onOpenPalette}
@click=${() => this.onOpenPalette?.()}
aria-label=${t("chat.openCommandPalette")}
>
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">K</kbd>
</button>
</openclaw-tooltip>
${this.terminalAvailable
? html`
<openclaw-tooltip .content=${t("terminal.toggle")}>
<button
class="topbar-icon-btn"
type="button"
@click=${() => this.onToggleTerminal?.()}
aria-label=${t("terminal.toggle")}
>
${icons.terminal}
</button>
</openclaw-tooltip>
`
: nothing}
<div class="topbar-status">
${this.routeOwnsHeader && this.headerError
? html`<div class="pill danger">${this.headerError}</div>`
: nothing}
<openclaw-theme-mode-toggle .mode=${this.themeMode}></openclaw-theme-mode-toggle>
</div>
</div>
</div>
</header>
`;
}
}
if (!customElements.get("openclaw-app-topbar")) {
customElements.define("openclaw-app-topbar", AppTopbar);
}
@@ -1,10 +1,11 @@
// Control UI view renders command palette screen content.
import { html, nothing } from "lit";
// Control UI component renders the command palette.
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { ref } from "lit/directives/ref.js";
import { t } from "../../i18n/index.ts";
import { SLASH_COMMANDS } from "../chat/slash-commands.ts";
import { icons, type IconName } from "../icons.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { RouteId } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
import { icons, type IconName } from "./icons.ts";
type PaletteItem = {
id: string;
@@ -15,16 +16,12 @@ type PaletteItem = {
description?: string;
};
function buildSlashPaletteItems(): PaletteItem[] {
return SLASH_COMMANDS.map((command) => ({
id: `slash:${command.name}`,
label: `/${command.name}`,
icon: command.icon ?? "terminal",
category: "search",
action: `/${command.name}`,
description: command.description,
}));
}
export const COMMAND_PALETTE_TARGET_EVENT = "openclaw-command-palette-target";
export type CommandPaletteTargetDetail = {
owner: Element;
onSlashCommand: ((command: string) => void) | null;
};
function getPaletteBaseItems(): PaletteItem[] {
return [
@@ -71,46 +68,39 @@ function getPaletteBaseItems(): PaletteItem[] {
action: "nav:agents",
},
{
id: "skill-shell",
label: t("overview.palette.items.shellCommand"),
icon: "monitor",
category: "skills",
action: "/skill shell",
description: t("overview.palette.descriptions.shellCommand"),
},
{
id: "skill-debug",
label: t("overview.palette.items.debugMode"),
icon: "bug",
category: "skills",
id: "slash:verbose",
label: "/verbose",
icon: "terminal",
category: "search",
action: "/verbose full",
description: t("overview.palette.descriptions.debugMode"),
description: "Toggle verbose mode.",
},
];
}
function getPaletteItemsInternal(): PaletteItem[] {
return [...buildSlashPaletteItems(), ...getPaletteBaseItems()];
return getPaletteBaseItems();
}
export function getPaletteItems(): readonly PaletteItem[] {
return getPaletteItemsInternal();
}
export type CommandPaletteProps = {
type CommandPaletteProps = {
open: boolean;
query: string;
activeIndex: number;
onOpen?: () => void | Promise<void>;
onToggle: () => void;
onQueryChange: (query: string) => void;
onActiveIndexChange: (index: number) => void;
onNavigate: (tab: string) => void;
onSlashCommand: (command: string) => void;
onNavigate: (routeId: RouteId) => void;
onSlashCommand?: (command: string) => void;
};
function filteredItems(query: string): PaletteItem[] {
const items = getPaletteItemsInternal();
function filteredItems(query: string, includeSlashCommands = true): PaletteItem[] {
const items = getPaletteItemsInternal().filter(
(item) => includeSlashCommands || item.category !== "search",
);
if (!query) {
return items;
}
@@ -138,7 +128,6 @@ function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> {
let previouslyFocused: Element | null = null;
let activeDialog: HTMLDialogElement | null = null;
let activeProps: CommandPaletteProps | null = null;
const FOCUSABLE_SELECTOR = [
"a[href]",
@@ -176,9 +165,9 @@ function restoreFocus() {
function selectItem(item: PaletteItem, props: CommandPaletteProps) {
if (item.action.startsWith("nav:")) {
props.onNavigate(item.action.slice(4));
props.onNavigate(item.action.slice(4) as RouteId);
} else {
props.onSlashCommand(item.action);
props.onSlashCommand?.(item.action);
}
props.onToggle();
restoreFocus();
@@ -234,7 +223,7 @@ function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
return;
}
const items = filteredItems(props.query);
const items = filteredItems(props.query, Boolean(props.onSlashCommand));
if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) {
return;
}
@@ -290,7 +279,6 @@ function syncDialog(el: Element | undefined) {
if (activeDialog !== el) {
saveFocus();
activeDialog = el;
void activeProps?.onOpen?.();
}
if (el.open) {
return;
@@ -318,13 +306,11 @@ function focusInput(el: Element | undefined) {
}
}
export function renderCommandPalette(props: CommandPaletteProps) {
function renderCommandPalette(props: CommandPaletteProps) {
if (!props.open) {
return nothing;
}
activeProps = props;
const items = filteredItems(props.query);
const items = filteredItems(props.query, Boolean(props.onSlashCommand));
const grouped = groupItems(items);
const activeItem = items[props.activeIndex];
const activeOptionId = activeItem ? getOptionId(activeItem) : nothing;
@@ -417,3 +403,79 @@ export function renderCommandPalette(props: CommandPaletteProps) {
</dialog>
`;
}
export class CommandPalette extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) onNavigate?: (routeId: RouteId) => void;
@property({ attribute: false }) onSlashCommand?: (command: string) => void;
@state() private open = false;
@state() private query = "";
@state() private activeIndex = 0;
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
document.addEventListener("keydown", this.handleGlobalKeydown);
}
override disconnectedCallback() {
document.removeEventListener("keydown", this.handleGlobalKeydown);
if (activeDialog) {
activeDialog.close();
restoreFocus();
}
super.disconnectedCallback();
}
openPalette() {
this.open = true;
this.query = "";
this.activeIndex = 0;
}
private readonly togglePalette = () => {
if (this.open) {
this.open = false;
restoreFocus();
return;
}
this.openPalette();
};
private readonly handleGlobalKeydown = (event: KeyboardEvent) => {
if (!event.defaultPrevented && event.key === "Escape" && this.open) {
event.preventDefault();
this.togglePalette();
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "k") {
event.preventDefault();
this.togglePalette();
}
};
override render() {
return renderCommandPalette({
open: this.open,
query: this.query,
activeIndex: this.activeIndex,
onToggle: this.togglePalette,
onQueryChange: (query) => {
this.query = query;
this.activeIndex = 0;
},
onActiveIndexChange: (index) => {
this.activeIndex = index;
},
onNavigate: (routeId) => this.onNavigate?.(routeId),
onSlashCommand: this.onSlashCommand,
});
}
}
if (!customElements.get("openclaw-command-palette")) {
customElements.define("openclaw-command-palette", CommandPalette);
}
@@ -1,7 +1,7 @@
// Control UI tests cover config form behavior.
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form.ts";
import { analyzeConfigSchema, renderConfigForm } from "./config-form.ts";
const rootSchema = {
type: "object",
@@ -1,12 +1,13 @@
// Control UI view renders config form screen content.
import { html, nothing, type TemplateResult } from "lit";
import { formatUnknownText } from "../format.ts";
import { icons as sharedIcons } from "../icons.ts";
import type { ConfigUiHints } from "../api/types.ts";
import { icons as sharedIcons } from "../components/icons.ts";
import "../components/tooltip.ts";
import { formatUnknownText } from "../lib/format.ts";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../string-coerce.ts";
import type { ConfigUiHints } from "../types.ts";
} from "../lib/string-coerce.ts";
import {
defaultValue,
hasSensitiveConfigData,
@@ -190,27 +191,25 @@ function renderSensitiveToggleButton(params: {
if (!state.isSensitive || !params.onToggleSensitivePath) {
return nothing;
}
const label = state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value";
return html`
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
title=${state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"}
aria-label=${state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
</button>
<openclaw-tooltip .content=${label}>
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
aria-label=${label}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
</button>
</openclaw-tooltip>
`;
}
@@ -751,15 +750,17 @@ function renderTextInput(params: {
})}
${schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled || effectiveRedacted}
@click=${() => onPatch(path, schema.default)}
>
</button>
<openclaw-tooltip content="Reset to default">
<button
type="button"
class="cfg-input__reset"
aria-label="Reset to default"
?disabled=${disabled || effectiveRedacted}
@click=${() => onPatch(path, schema.default)}
>
</button>
</openclaw-tooltip>
`
: nothing}
</div>
@@ -1135,19 +1136,21 @@ function renderArray(params: {
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
<button
type="button"
class="cfg-array__item-remove"
title="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
<openclaw-tooltip content="Remove item">
<button
type="button"
class="cfg-array__item-remove"
aria-label="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
</openclaw-tooltip>
</div>
<div class="cfg-array__item-content">
${renderNode({
@@ -1284,19 +1287,21 @@ function renderMapField(params: {
}}
/>
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
<openclaw-tooltip content="Remove entry">
<button
type="button"
class="cfg-map__item-remove"
aria-label="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</openclaw-tooltip>
</div>
<div class="cfg-map__item-value">
${anySchema
@@ -1,8 +1,8 @@
// Control UI view renders config form.render screen content.
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { ConfigUiHints } from "../types.ts";
import type { ConfigUiHints } from "../api/types.ts";
import { icons } from "../components/icons.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
@@ -1,6 +1,6 @@
import type { ConfigUiHint, ConfigUiHints } from "../api/types.ts";
// Control UI view renders config form.shared screen content.
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
export type JsonSchema = {
type?: string | string[];
+43
View File
@@ -0,0 +1,43 @@
// Control UI component renders a copyable gateway connection command.
import { html } from "lit";
import { t } from "../i18n/index.ts";
import { renderCopyButton } from "./copy-button.ts";
import "./tooltip.ts";
async function copyCommand(command: string) {
try {
await navigator.clipboard.writeText(command);
} catch {
// Best effort only; the explicit copy button provides visible feedback.
}
}
export function renderConnectCommand(command: string) {
const copyLabel = t("overview.connection.copyCommand");
return html`
<openclaw-tooltip .content=${copyLabel}>
<div
class="login-gate__command"
role="button"
tabindex="0"
aria-label=${t("overview.connection.copyCommandAria", { command })}
@click=${async (event: Event) => {
if ((event.target as HTMLElement | null)?.closest(".chat-copy-btn")) {
return;
}
await copyCommand(command);
}}
@keydown=${async (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
await copyCommand(command);
}}
>
<code>${command}</code>
${renderCopyButton(command, copyLabel)}
</div>
</openclaw-tooltip>
`;
}
+91
View File
@@ -0,0 +1,91 @@
// Control UI chat module implements copy as markdown behavior.
import { html, type TemplateResult } from "lit";
import { copyToClipboard } from "../lib/clipboard.ts";
import { icons } from "./icons.ts";
import "./tooltip.ts";
const COPIED_FOR_MS = 1500;
const ERROR_FOR_MS = 2000;
const COPY_LABEL = "Copy as markdown";
const COPIED_LABEL = "Copied";
const ERROR_LABEL = "Copy failed";
type CopyButtonOptions = {
text: () => string;
label?: string;
};
function setButtonLabel(button: HTMLButtonElement, label: string) {
button.setAttribute("aria-label", label);
}
function createCopyButton(options: CopyButtonOptions): TemplateResult {
const idleLabel = options.label ?? COPY_LABEL;
return html`
<openclaw-tooltip .content=${idleLabel}>
<button
class="btn btn--xs chat-copy-btn"
type="button"
aria-label=${idleLabel}
@click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null;
if (!btn || btn.dataset.copying === "1") {
return;
}
btn.dataset.copying = "1";
btn.setAttribute("aria-busy", "true");
btn.disabled = true;
const copied = await copyToClipboard(options.text());
if (!btn.isConnected) {
return;
}
delete btn.dataset.copying;
btn.removeAttribute("aria-busy");
btn.disabled = false;
if (!copied) {
btn.dataset.error = "1";
setButtonLabel(btn, ERROR_LABEL);
window.setTimeout(() => {
if (!btn.isConnected) {
return;
}
delete btn.dataset.error;
setButtonLabel(btn, idleLabel);
}, ERROR_FOR_MS);
return;
}
btn.dataset.copied = "1";
setButtonLabel(btn, COPIED_LABEL);
window.setTimeout(() => {
if (!btn.isConnected) {
return;
}
delete btn.dataset.copied;
setButtonLabel(btn, idleLabel);
}, COPIED_FOR_MS);
}}
>
<span class="chat-copy-btn__icon" aria-hidden="true">
<span class="chat-copy-btn__icon-copy">${icons.copy}</span>
<span class="chat-copy-btn__icon-check">${icons.check}</span>
</span>
</button>
</openclaw-tooltip>
`;
}
export function renderCopyButton(text: string, label = COPY_LABEL): TemplateResult {
return createCopyButton({ text: () => text, label });
}
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
return renderCopyButton(markdown, COPY_LABEL);
}
@@ -1,16 +1,17 @@
// Control UI component implements the dashboard header element.
import { LitElement, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { pathForTab, titleForTab, type Tab } from "../navigation.js";
import { titleForRoute, type NavigationRouteId } from "../app-navigation.ts";
export class DashboardHeader extends LitElement {
override createRenderRoot() {
return this;
}
@property() tab: Tab = "overview";
@property() routeId?: NavigationRouteId;
@property() basePath = "";
@property() agentLabel = "";
@property() overviewHref = "";
private readonly handleOverviewClick = (event: MouseEvent) => {
if (
@@ -30,7 +31,7 @@ export class DashboardHeader extends LitElement {
};
override render() {
const label = titleForTab(this.tab);
const label = this.routeId ? titleForRoute(this.routeId) : "";
const rawAgentLabel = this.agentLabel.trim();
// Skip the agent crumb when it repeats the brand crumb ("OpenClaw OpenClaw …").
const agentLabel = rawAgentLabel.toLowerCase() === "openclaw" ? "" : rawAgentLabel;
@@ -38,13 +39,17 @@ export class DashboardHeader extends LitElement {
return html`
<div class="dashboard-header">
<div class="dashboard-header__breadcrumb">
<a
class="dashboard-header__breadcrumb-link"
href=${pathForTab("overview", this.basePath)}
@click=${this.handleOverviewClick}
>
OpenClaw
</a>
${this.overviewHref
? html`
<a
class="dashboard-header__breadcrumb-link"
href=${this.overviewHref}
@click=${this.handleOverviewClick}
>
OpenClaw
</a>
`
: html`<span class="dashboard-header__breadcrumb-link">OpenClaw</span>`}
${agentLabel
? html`
<span class="dashboard-header__breadcrumb-segment">
@@ -1,14 +1,14 @@
// Control UI view renders exec approval screen content.
import { html, nothing } from "lit";
import { formatApprovalDisplayPath } from "../../../../src/infra/approval-display-paths.ts";
import { t } from "../../i18n/index.ts";
import type { AppViewState } from "../app-view-state.ts";
import "../components/modal-dialog.ts";
// Control UI component renders exec approval.
import { LitElement, html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { formatApprovalDisplayPath } from "../../../src/infra/approval-display-paths.ts";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
ExecApprovalRequestPayload,
} from "../controllers/exec-approval.ts";
} from "../app/exec-approval.ts";
import "./modal-dialog.ts";
import { t } from "../i18n/index.ts";
const DEFAULT_EXEC_APPROVAL_DECISIONS = [
"allow-once",
@@ -16,6 +16,13 @@ const DEFAULT_EXEC_APPROVAL_DECISIONS = [
"deny",
] as const satisfies readonly ExecApprovalDecision[];
export type ExecApprovalProps = {
queue: readonly ExecApprovalRequest[];
busy: boolean;
error: string | null;
onDecision: (decision: ExecApprovalDecision) => void | Promise<void>;
};
function formatRemaining(ms: number): string {
const remaining = Math.max(0, ms);
const totalSeconds = Math.floor(remaining / 1000);
@@ -158,8 +165,8 @@ function renderUnavailableDecisionWarning(
: html`<div class="exec-approval-warning">${t("execApproval.allowAlwaysUnavailable")}</div>`;
}
export function renderExecApprovalPrompt(state: AppViewState) {
const active = state.execApprovalQueue[0];
function renderExecApprovalPrompt(props: ExecApprovalProps) {
const active = props.queue[0];
if (!active) {
return nothing;
}
@@ -169,7 +176,7 @@ export function renderExecApprovalPrompt(state: AppViewState) {
remainingMs > 0
? t("execApproval.expiresIn", { time: formatRemaining(remainingMs) })
: t("execApproval.expired");
const queueCount = state.execApprovalQueue.length;
const queueCount = props.queue.length;
const isPlugin = active.kind === "plugin";
const title = isPlugin
? (active.pluginTitle ?? t("execApproval.pluginApprovalNeeded"))
@@ -178,8 +185,8 @@ export function renderExecApprovalPrompt(state: AppViewState) {
const descriptionId = "exec-approval-description";
const decisions = resolveApprovalDecisions(active);
const handleCancel = () => {
if (!state.execApprovalBusy && decisions.includes("deny")) {
void state.handleExecApprovalDecision("deny");
if (!props.busy && decisions.includes("deny")) {
void props.onDecision("deny");
}
};
return html`
@@ -198,16 +205,14 @@ export function renderExecApprovalPrompt(state: AppViewState) {
</div>
${isPlugin ? renderPluginBody(active) : renderExecBody(request)}
${renderUnavailableDecisionWarning(active, decisions)}
${state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing}
${props.error ? html`<div class="exec-approval-error">${props.error}</div>` : nothing}
<div class="exec-approval-actions">
${decisions.map(
(decision) => html`
<button
class=${approvalDecisionClass(decision)}
?disabled=${state.execApprovalBusy}
@click=${() => state.handleExecApprovalDecision(decision)}
?disabled=${props.busy}
@click=${() => props.onDecision(decision)}
>
${approvalDecisionLabel(decision)}
</button>
@@ -218,3 +223,24 @@ export function renderExecApprovalPrompt(state: AppViewState) {
</openclaw-modal-dialog>
`;
}
export class ExecApproval extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) props?: ExecApprovalProps;
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
}
override render() {
return this.props ? renderExecApprovalPrompt(this.props) : nothing;
}
}
if (!customElements.get("openclaw-exec-approval")) {
customElements.define("openclaw-exec-approval", ExecApproval);
}

Some files were not shown because too many files have changed in this diff Show More