mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+9
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
@@ -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]),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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__", {
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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", {});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
-1
@@ -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[];
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user