Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
62
ui/src/app/agent-selection.ts
Normal file
62
ui/src/app/agent-selection.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
|
||||
type AgentSelectionGateway = {
|
||||
readonly snapshot: {
|
||||
client: GatewayBrowserClient | null;
|
||||
assistantAgentId: string | null;
|
||||
};
|
||||
subscribe: (listener: (snapshot: AgentSelectionGateway["snapshot"]) => void) => () => void;
|
||||
};
|
||||
|
||||
export type AgentSelectionState = {
|
||||
selectedId: string | null;
|
||||
};
|
||||
|
||||
export type AgentSelectionCapability = {
|
||||
readonly state: AgentSelectionState;
|
||||
set: (agentId: string | null) => void;
|
||||
subscribe: (listener: (state: AgentSelectionState) => void) => () => void;
|
||||
};
|
||||
|
||||
export function createAgentSelectionCapability(
|
||||
gateway: AgentSelectionGateway,
|
||||
): AgentSelectionCapability {
|
||||
let state: AgentSelectionState = {
|
||||
selectedId: gateway.snapshot.assistantAgentId
|
||||
? normalizeAgentId(gateway.snapshot.assistantAgentId)
|
||||
: null,
|
||||
};
|
||||
let client = gateway.snapshot.client;
|
||||
const listeners = new Set<(next: AgentSelectionState) => void>();
|
||||
|
||||
const publish = (selectedId: string | null) => {
|
||||
if (state.selectedId === selectedId) {
|
||||
return;
|
||||
}
|
||||
state = { selectedId };
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
};
|
||||
|
||||
gateway.subscribe((next) => {
|
||||
if (next.client !== client) {
|
||||
client = next.client;
|
||||
publish(next.assistantAgentId ? normalizeAgentId(next.assistantAgentId) : null);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
set(agentId) {
|
||||
publish(agentId?.trim() ? normalizeAgentId(agentId) : null);
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
749
ui/src/app/app-host.ts
Normal file
749
ui/src/app/app-host.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
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 { t } from "../i18n/index.ts";
|
||||
import { copyToClipboard } from "../lib/clipboard.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 { renderDevicePairSetup } from "../pages/nodes/view-pairing.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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal-only document mode (`?view=terminal`): the mobile apps embed the
|
||||
* terminal as a full-screen WebView page instead of the whole Control UI.
|
||||
* Fixed per document load — the apps construct the URL, users never toggle it.
|
||||
*/
|
||||
function isTerminalOnlyView(): boolean {
|
||||
return new URLSearchParams(globalThis.location?.search ?? "").get("view") === "terminal";
|
||||
}
|
||||
|
||||
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();
|
||||
@state() private terminalAvailable = false;
|
||||
@state() private terminalClient: GatewayBrowserClient | null = null;
|
||||
|
||||
private readonly terminalOnly = isTerminalOnlyView();
|
||||
private runtime: ApplicationRuntime | undefined;
|
||||
private context: ApplicationContext<RouteId> | undefined;
|
||||
private readonly contextProvider = new ContextProvider(this, {
|
||||
context: applicationContext,
|
||||
});
|
||||
private stopGatewaySubscription: (() => void) | undefined;
|
||||
private stopConfigSubscription: (() => 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);
|
||||
this.updateTerminalSurface();
|
||||
});
|
||||
if (this.terminalOnly) {
|
||||
// Terminal availability also depends on config.terminalEnabled, which
|
||||
// can arrive after the gateway snapshot; track it for this document mode.
|
||||
this.updateTerminalSurface();
|
||||
this.stopConfigSubscription = this.context.config.subscribe(() => {
|
||||
this.updateTerminalSurface();
|
||||
});
|
||||
}
|
||||
void this.runtime.start().catch((error: unknown) => {
|
||||
console.error("[openclaw] application start failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.stopGatewaySubscription?.();
|
||||
this.stopGatewaySubscription = undefined;
|
||||
this.stopConfigSubscription?.();
|
||||
this.stopConfigSubscription = 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;
|
||||
};
|
||||
|
||||
private updateTerminalSurface() {
|
||||
if (!this.terminalOnly || !this.context) {
|
||||
return;
|
||||
}
|
||||
const snapshot = this.context.gateway.snapshot;
|
||||
this.terminalClient = snapshot.connected ? snapshot.client : null;
|
||||
this.terminalAvailable = isTerminalAvailable(
|
||||
snapshot,
|
||||
this.context.config.current.terminalEnabled ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
// Terminal-only document (`?view=terminal`): the mobile apps embed this as
|
||||
// a full-screen WebView page, so render just the terminal — no shell chrome.
|
||||
if (this.terminalOnly) {
|
||||
return html`
|
||||
<openclaw-terminal-panel
|
||||
.client=${this.terminalClient}
|
||||
.available=${this.terminalAvailable}
|
||||
.themeMode=${resolveTerminalThemeMode()}
|
||||
fullscreen
|
||||
></openclaw-terminal-panel>
|
||||
${this.terminalAvailable
|
||||
? nothing
|
||||
: html`<div class="terminal-view-unavailable">${t("terminal.unavailable")}</div>`}
|
||||
`;
|
||||
}
|
||||
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,
|
||||
devicePairSetupOpen: false,
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
devicePairPendingCount: 0,
|
||||
};
|
||||
@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}
|
||||
.canPairDevice=${this.gatewayConnected &&
|
||||
hasOperatorAdminAccess(context.gateway.snapshot.hello?.auth ?? null)}
|
||||
.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,
|
||||
})}
|
||||
.onPairMobile=${() => void context.overlays.openDevicePairSetup()}
|
||||
.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" : ""} ${activeRoute ===
|
||||
"workboard"
|
||||
? "content--workboard"
|
||||
: ""}"
|
||||
>
|
||||
<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>
|
||||
${renderDevicePairSetup({
|
||||
open: this.overlaySnapshot.devicePairSetupOpen,
|
||||
loading: this.overlaySnapshot.devicePairSetupLoading,
|
||||
error: this.overlaySnapshot.devicePairSetupError,
|
||||
setup: this.overlaySnapshot.devicePairSetup,
|
||||
pendingCount: this.overlaySnapshot.devicePairPendingCount,
|
||||
onRefresh: () => void context.overlays.refreshDevicePairSetup(),
|
||||
onClose: () => context.overlays.closeDevicePairSetup(),
|
||||
onCopy: (setupCode) => void copyToClipboard(setupCode),
|
||||
onManageDevices: () => {
|
||||
context.overlays.closeDevicePairSetup();
|
||||
this.navigate("nodes");
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-app")) {
|
||||
customElements.define("openclaw-app", OpenClawApp);
|
||||
}
|
||||
if (!customElements.get("openclaw-app-shell")) {
|
||||
customElements.define("openclaw-app-shell", OpenClawShell);
|
||||
}
|
||||
118
ui/src/app/assistant-identity.ts
Normal file
118
ui/src/app/assistant-identity.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { normalizeAssistantIdentity, type AssistantIdentity } from "../lib/assistant-identity.ts";
|
||||
import { normalizeOptionalString } from "../lib/string-coerce.ts";
|
||||
import { getSafeLocalStorage } from "../local-storage.ts";
|
||||
|
||||
const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
|
||||
|
||||
export type LocalAssistantIdentity = { avatar: string | null; agentId?: string | null };
|
||||
|
||||
type PersistedLocalAssistantIdentities = {
|
||||
avatars?: Record<string, unknown>;
|
||||
avatar?: unknown;
|
||||
agentId?: unknown;
|
||||
};
|
||||
|
||||
function parseLocalAssistantAvatarMap(raw: string): {
|
||||
avatars: Record<string, string>;
|
||||
legacyAvatar: string | null;
|
||||
} {
|
||||
const parsed = JSON.parse(raw) as PersistedLocalAssistantIdentities;
|
||||
const avatars = Object.create(null) as Record<string, string>;
|
||||
if (parsed.avatars && typeof parsed.avatars === "object" && !Array.isArray(parsed.avatars)) {
|
||||
for (const [agentId, avatar] of Object.entries(parsed.avatars)) {
|
||||
const normalizedAgentId = normalizeOptionalString(agentId);
|
||||
const normalizedAvatar = normalizeOptionalString(avatar);
|
||||
if (normalizedAgentId && normalizedAvatar) {
|
||||
avatars[normalizedAgentId] = normalizedAvatar;
|
||||
}
|
||||
}
|
||||
}
|
||||
const legacyAvatar = normalizeOptionalString(parsed.avatar);
|
||||
const legacyAgentId = normalizeOptionalString(parsed.agentId);
|
||||
if (legacyAvatar && legacyAgentId && !Object.hasOwn(avatars, legacyAgentId)) {
|
||||
avatars[legacyAgentId] = legacyAvatar;
|
||||
}
|
||||
return { avatars, legacyAvatar: legacyAgentId ? null : (legacyAvatar ?? null) };
|
||||
}
|
||||
|
||||
function persistLocalAssistantAvatarMap(storage: Storage | null, avatars: Record<string, string>) {
|
||||
if (Object.keys(avatars).length === 0) {
|
||||
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
|
||||
return;
|
||||
}
|
||||
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatars }));
|
||||
}
|
||||
|
||||
export function loadLocalAssistantIdentity(opts?: {
|
||||
agentId?: string | null;
|
||||
}): LocalAssistantIdentity {
|
||||
const agentId = normalizeOptionalString(opts?.agentId);
|
||||
if (!agentId) {
|
||||
return { avatar: null };
|
||||
}
|
||||
const storage = getSafeLocalStorage();
|
||||
try {
|
||||
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
|
||||
if (!raw) {
|
||||
return { avatar: null };
|
||||
}
|
||||
const { avatars, legacyAvatar } = parseLocalAssistantAvatarMap(raw);
|
||||
if (!Object.hasOwn(avatars, agentId) && legacyAvatar) {
|
||||
// Assign the old global override to the first concrete agent that loads it.
|
||||
avatars[agentId] = legacyAvatar;
|
||||
persistLocalAssistantAvatarMap(storage, avatars);
|
||||
}
|
||||
return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId };
|
||||
} catch {
|
||||
return { avatar: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
|
||||
const agentId = normalizeOptionalString(next.agentId);
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
const storage = getSafeLocalStorage();
|
||||
try {
|
||||
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
|
||||
const avatars = raw
|
||||
? parseLocalAssistantAvatarMap(raw).avatars
|
||||
: (Object.create(null) as Record<string, string>);
|
||||
const avatar = normalizeOptionalString(next.avatar);
|
||||
if (avatar) {
|
||||
avatars[agentId] = avatar;
|
||||
} else {
|
||||
delete avatars[agentId];
|
||||
}
|
||||
persistLocalAssistantAvatarMap(storage, avatars);
|
||||
} catch {
|
||||
// best-effort — quota exceeded or security restrictions should not
|
||||
// prevent in-memory identity updates from being applied
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAssistantIdentity(
|
||||
client: GatewayBrowserClient,
|
||||
sessionKey?: string,
|
||||
): Promise<AssistantIdentity | null> {
|
||||
const result = await client.request<Partial<AssistantIdentity>>(
|
||||
"agent.identity.get",
|
||||
sessionKey?.trim() ? { sessionKey: sessionKey.trim() } : {},
|
||||
);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
const identity = normalizeAssistantIdentity(result);
|
||||
const localAvatar = loadLocalAssistantIdentity({ agentId: identity.agentId }).avatar;
|
||||
return localAvatar
|
||||
? {
|
||||
...identity,
|
||||
avatar: localAvatar,
|
||||
avatarSource: localAvatar,
|
||||
avatarStatus: "data",
|
||||
avatarReason: null,
|
||||
}
|
||||
: identity;
|
||||
}
|
||||
630
ui/src/app/bootstrap.ts
Normal file
630
ui/src/app/bootstrap.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import type { EventLogEntry } from "../api/event-log.ts";
|
||||
import {
|
||||
GatewayBrowserClient,
|
||||
type GatewayEventListener,
|
||||
type GatewayHelloOk,
|
||||
} from "../api/gateway.ts";
|
||||
import {
|
||||
createApplicationRouter,
|
||||
inferBasePathFromPathname,
|
||||
locationForRoute,
|
||||
normalizeBasePath,
|
||||
pathForRoute,
|
||||
routeIdFromPath,
|
||||
startApplicationRouter,
|
||||
type ApplicationRouter,
|
||||
type RouteId,
|
||||
} from "../app-routes.ts";
|
||||
import { createAgentIdentityCapability } from "../lib/agents/identity.ts";
|
||||
import { createAgentCapability } from "../lib/agents/index.ts";
|
||||
import { createChannelCapability } from "../lib/channels/index.ts";
|
||||
import { createRuntimeConfigCapability } from "../lib/config/index.ts";
|
||||
import { createSessionCapability, resolveSessionKey } from "../lib/sessions/index.ts";
|
||||
import { generateUUID } from "../lib/uuid.ts";
|
||||
import { createWorkboardCapability } from "../lib/workboard/capability.ts";
|
||||
import { createAgentSelectionCapability } from "./agent-selection.ts";
|
||||
import { createBrowserHistory } from "./browser.ts";
|
||||
import { createApplicationConfigCapability } from "./config.ts";
|
||||
import type {
|
||||
ApplicationGateway,
|
||||
ApplicationGatewayConnectOptions,
|
||||
ApplicationGatewayConnection,
|
||||
ApplicationNavigationOptions,
|
||||
ApplicationGatewaySnapshot,
|
||||
ApplicationContext,
|
||||
ApplicationNavigationPreferences,
|
||||
ApplicationNavigationPreferencesSnapshot,
|
||||
ApplicationSkillWorkshopRevisionHandoff,
|
||||
ApplicationTheme,
|
||||
} from "./context.ts";
|
||||
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
|
||||
import { createNativeChatDrafts } from "./native-bridge.ts";
|
||||
import { createApplicationOverlays } from "./overlays.ts";
|
||||
import {
|
||||
loadSettings,
|
||||
patchSettings,
|
||||
resolveApplicationStartupSettings,
|
||||
saveSettings,
|
||||
type UiSettings,
|
||||
} from "./settings.ts";
|
||||
import { startThemeTransition } from "./theme-transition.ts";
|
||||
import { resolveTheme, type ThemeMode } from "./theme.ts";
|
||||
import { createWebPushCapability } from "./web-push.ts";
|
||||
|
||||
function normalizeInitialApplicationLocation(
|
||||
location: RouteLocation,
|
||||
basePath: string,
|
||||
sessionKey: string,
|
||||
) {
|
||||
const routeId = routeIdFromPath(location.pathname, basePath);
|
||||
if ((routeId !== null && routeId !== "chat") || !sessionKey.trim()) {
|
||||
return location;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(location.search);
|
||||
if (!search.get("session")?.trim()) {
|
||||
search.set("session", sessionKey);
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
pathname: routeId === null ? pathForRoute("chat", basePath) : location.pathname,
|
||||
search: `?${search.toString()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStartupPresentation(settings: ReturnType<typeof loadSettings>): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
const resolvedTheme = resolveTheme(settings.theme, settings.themeMode);
|
||||
root.dataset.theme = resolvedTheme;
|
||||
root.dataset.themeMode = resolvedTheme.endsWith("light") ? "light" : "dark";
|
||||
root.style.colorScheme = root.dataset.themeMode;
|
||||
root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`);
|
||||
syncCustomThemeStyleTag(settings.customTheme);
|
||||
}
|
||||
|
||||
function createApplicationTheme(
|
||||
initialSettings: UiSettings,
|
||||
): ApplicationTheme & { dispose: () => void } {
|
||||
let settings = initialSettings;
|
||||
let systemThemeCleanup: (() => void) | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const publish = () => {
|
||||
applyStartupPresentation(settings);
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const detachSystemThemeListener = () => {
|
||||
systemThemeCleanup?.();
|
||||
systemThemeCleanup = undefined;
|
||||
};
|
||||
|
||||
const syncSystemThemeListener = () => {
|
||||
detachSystemThemeListener();
|
||||
if (settings.themeMode !== "system" || typeof globalThis.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: light)");
|
||||
const onChange = () => {
|
||||
if (settings.themeMode === "system") {
|
||||
publish();
|
||||
}
|
||||
};
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", onChange);
|
||||
systemThemeCleanup = () => mediaQuery.removeEventListener("change", onChange);
|
||||
} else if (typeof mediaQuery.addListener === "function") {
|
||||
mediaQuery.addListener(onChange);
|
||||
systemThemeCleanup = () => mediaQuery.removeListener(onChange);
|
||||
}
|
||||
};
|
||||
|
||||
syncSystemThemeListener();
|
||||
|
||||
return {
|
||||
get mode() {
|
||||
return settings.themeMode;
|
||||
},
|
||||
setMode(mode: ThemeMode, element) {
|
||||
const currentSettings = loadSettings();
|
||||
const nextSettings = { ...currentSettings, themeMode: mode };
|
||||
const currentTheme = resolveTheme(currentSettings.theme, currentSettings.themeMode);
|
||||
const nextTheme = resolveTheme(nextSettings.theme, nextSettings.themeMode);
|
||||
startThemeTransition({
|
||||
nextTheme,
|
||||
currentTheme,
|
||||
context: { element },
|
||||
applyTheme: () => {
|
||||
settings = patchSettings({ themeMode: mode });
|
||||
publish();
|
||||
syncSystemThemeListener();
|
||||
},
|
||||
});
|
||||
},
|
||||
refresh() {
|
||||
settings = loadSettings();
|
||||
publish();
|
||||
syncSystemThemeListener();
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
detachSystemThemeListener();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationNavigationPreferences(
|
||||
initialSettings: UiSettings,
|
||||
): ApplicationNavigationPreferences {
|
||||
let settings = initialSettings;
|
||||
let snapshot: ApplicationNavigationPreferencesSnapshot = {
|
||||
navCollapsed: settings.navCollapsed,
|
||||
navGroupsCollapsed: settings.navGroupsCollapsed,
|
||||
recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false,
|
||||
};
|
||||
const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>();
|
||||
|
||||
return {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
update(patch) {
|
||||
const nextSnapshot = { ...snapshot, ...patch };
|
||||
if (
|
||||
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
|
||||
nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed &&
|
||||
nextSnapshot.navGroupsCollapsed === snapshot.navGroupsCollapsed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
settings = patchSettings({
|
||||
navCollapsed: nextSnapshot.navCollapsed,
|
||||
navGroupsCollapsed: nextSnapshot.navGroupsCollapsed,
|
||||
recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed,
|
||||
});
|
||||
snapshot = nextSnapshot;
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSkillWorkshopRevisionHandoff(): ApplicationSkillWorkshopRevisionHandoff {
|
||||
let pending: Parameters<ApplicationSkillWorkshopRevisionHandoff["prepare"]>[0] | null = null;
|
||||
return {
|
||||
prepare: (handoff) => {
|
||||
pending = handoff;
|
||||
},
|
||||
consume: (sessionKey) => {
|
||||
if (!pending || pending.sessionKey !== sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const handoff = pending;
|
||||
pending = null;
|
||||
return handoff;
|
||||
},
|
||||
clear: () => {
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationGateway(
|
||||
initialSettings: ReturnType<typeof loadSettings>,
|
||||
initialPassword = "",
|
||||
): ApplicationGateway {
|
||||
let settings = initialSettings;
|
||||
let connection: ApplicationGatewayConnection = {
|
||||
gatewayUrl: settings.gatewayUrl,
|
||||
token: settings.token,
|
||||
password: initialPassword,
|
||||
};
|
||||
let snapshot: ApplicationGatewaySnapshot = {
|
||||
client: null,
|
||||
connected: false,
|
||||
hello: null,
|
||||
assistantAgentId: "main",
|
||||
sessionKey: settings.sessionKey,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
let client: GatewayBrowserClient | null = null;
|
||||
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
|
||||
const eventListeners = new Set<GatewayEventListener>();
|
||||
const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>();
|
||||
let eventLog: EventLogEntry[] = [];
|
||||
let stopClientEvents: (() => void) | undefined;
|
||||
const syncClientEvents = (nextClient: GatewayBrowserClient | null) => {
|
||||
stopClientEvents?.();
|
||||
stopClientEvents = undefined;
|
||||
if (!nextClient || eventListeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
const removers = [...eventListeners].map((listener) => nextClient.addEventListener(listener));
|
||||
stopClientEvents = () => {
|
||||
for (const remove of removers) {
|
||||
remove();
|
||||
}
|
||||
};
|
||||
};
|
||||
const notify = () => {
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
};
|
||||
const setSnapshot = (next: ApplicationGatewaySnapshot) => {
|
||||
snapshot = next;
|
||||
notify();
|
||||
};
|
||||
const publishEventLog = () => {
|
||||
for (const listener of eventLogListeners) {
|
||||
listener(eventLog);
|
||||
}
|
||||
};
|
||||
const recordGatewayEvent = (event: Parameters<GatewayEventListener>[0]) => {
|
||||
eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice(
|
||||
0,
|
||||
250,
|
||||
);
|
||||
publishEventLog();
|
||||
};
|
||||
|
||||
const connect = (overrides: ApplicationGatewayConnectOptions = {}) => {
|
||||
const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides;
|
||||
const nextConnection = { ...connection, ...connectionOverrides };
|
||||
const hasRequestedSessionKey = requestedSessionKey !== undefined;
|
||||
const nextSessionKey = hasRequestedSessionKey
|
||||
? requestedSessionKey.trim()
|
||||
: snapshot.sessionKey;
|
||||
connection = nextConnection;
|
||||
settings = patchSettings({
|
||||
gatewayUrl: nextConnection.gatewayUrl,
|
||||
token: nextConnection.token,
|
||||
...(hasRequestedSessionKey
|
||||
? {
|
||||
sessionKey: nextSessionKey,
|
||||
lastActiveSessionKey: nextSessionKey,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
client?.stop();
|
||||
stopClientEvents?.();
|
||||
stopClientEvents = undefined;
|
||||
|
||||
const nextClient = new GatewayBrowserClient({
|
||||
url: nextConnection.gatewayUrl,
|
||||
token: nextConnection.token.trim() ? nextConnection.token : undefined,
|
||||
password: nextConnection.password.trim() ? nextConnection.password : undefined,
|
||||
clientName: "openclaw-control-ui",
|
||||
clientVersion: "dev",
|
||||
mode: "webchat",
|
||||
instanceId: generateUUID(),
|
||||
onHello: (hello: GatewayHelloOk) => {
|
||||
if (client !== nextClient) {
|
||||
return;
|
||||
}
|
||||
settings = loadSettings();
|
||||
const sessionDefaults = readSessionDefaults(hello);
|
||||
const sessionKey = resolveSessionKey(snapshot.sessionKey, hello);
|
||||
const lastActiveSessionKey = resolveSessionKey(settings.lastActiveSessionKey, hello);
|
||||
if (
|
||||
sessionKey !== settings.sessionKey ||
|
||||
lastActiveSessionKey !== settings.lastActiveSessionKey
|
||||
) {
|
||||
settings = patchSettings({
|
||||
sessionKey,
|
||||
lastActiveSessionKey,
|
||||
});
|
||||
}
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
client: nextClient,
|
||||
connected: true,
|
||||
hello,
|
||||
assistantAgentId: sessionDefaults?.defaultAgentId ?? "main",
|
||||
sessionKey,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
});
|
||||
},
|
||||
onClose: ({ code, reason, error }) => {
|
||||
if (client !== nextClient) {
|
||||
return;
|
||||
}
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
client: nextClient,
|
||||
connected: false,
|
||||
hello: null,
|
||||
lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`,
|
||||
lastErrorCode: error?.code ?? null,
|
||||
});
|
||||
},
|
||||
onGap: ({ expected, received }) => {
|
||||
if (client !== nextClient) {
|
||||
return;
|
||||
}
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
lastError: `event gap detected (expected seq ${expected}, got ${received}); reconnecting`,
|
||||
lastErrorCode: null,
|
||||
});
|
||||
connect();
|
||||
},
|
||||
onEvent: recordGatewayEvent,
|
||||
});
|
||||
client = nextClient;
|
||||
syncClientEvents(nextClient);
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
client: nextClient,
|
||||
connected: false,
|
||||
hello: null,
|
||||
sessionKey: nextSessionKey,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
});
|
||||
nextClient.start();
|
||||
};
|
||||
|
||||
const gateway: ApplicationGateway = {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
get connection() {
|
||||
return connection;
|
||||
},
|
||||
get eventLog() {
|
||||
return eventLog;
|
||||
},
|
||||
connect,
|
||||
setSessionKey: (sessionKey) => {
|
||||
const nextSessionKey = sessionKey.trim();
|
||||
if (!nextSessionKey || nextSessionKey === snapshot.sessionKey) {
|
||||
return;
|
||||
}
|
||||
settings = patchSettings({
|
||||
sessionKey: nextSessionKey,
|
||||
lastActiveSessionKey: nextSessionKey,
|
||||
});
|
||||
setSnapshot({ ...snapshot, sessionKey: nextSessionKey });
|
||||
},
|
||||
start: () => connect(),
|
||||
stop: () => {
|
||||
stopClientEvents?.();
|
||||
stopClientEvents = undefined;
|
||||
client?.stop();
|
||||
client = null;
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
client: null,
|
||||
connected: false,
|
||||
hello: null,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
});
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
subscribeEventLog: (listener) => {
|
||||
eventLogListeners.add(listener);
|
||||
return () => eventLogListeners.delete(listener);
|
||||
},
|
||||
subscribeEvents: (listener) => {
|
||||
eventListeners.add(listener);
|
||||
syncClientEvents(client);
|
||||
return () => {
|
||||
if (eventListeners.delete(listener)) {
|
||||
syncClientEvents(client);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
return gateway;
|
||||
}
|
||||
|
||||
function readSessionDefaults(
|
||||
hello: GatewayHelloOk,
|
||||
): { defaultAgentId?: string | null } | undefined {
|
||||
const snapshot = hello.snapshot;
|
||||
if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) {
|
||||
return undefined;
|
||||
}
|
||||
const defaults = snapshot.sessionDefaults;
|
||||
return defaults && typeof defaults === "object"
|
||||
? (defaults as { defaultAgentId?: string | null })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export type ApplicationRuntime = {
|
||||
readonly context: ApplicationContext<RouteId>;
|
||||
readonly router: ApplicationRouter;
|
||||
readonly pendingGatewayConnection: {
|
||||
readonly gatewayUrl: string;
|
||||
readonly token: string;
|
||||
} | null;
|
||||
readonly confirmPendingGatewayConnection: () => void;
|
||||
readonly cancelPendingGatewayConnection: () => void;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export function bootstrapApplication(): ApplicationRuntime {
|
||||
const initialSettings = loadSettings();
|
||||
const history = createBrowserHistory();
|
||||
const startup = resolveApplicationStartupSettings(initialSettings, history.location());
|
||||
if (startup.changed) {
|
||||
saveSettings(startup.settings);
|
||||
}
|
||||
const basePath = normalizeBasePath(
|
||||
inferBasePathFromPathname(startup.location.pathname || globalThis.location?.pathname || "/"),
|
||||
);
|
||||
const initialLocation = normalizeInitialApplicationLocation(
|
||||
startup.location,
|
||||
basePath,
|
||||
startup.settings.sessionKey,
|
||||
);
|
||||
const currentLocation = history.location();
|
||||
if (
|
||||
currentLocation.pathname !== initialLocation.pathname ||
|
||||
currentLocation.search !== initialLocation.search ||
|
||||
currentLocation.hash !== initialLocation.hash
|
||||
) {
|
||||
history.replace(initialLocation);
|
||||
}
|
||||
|
||||
const settings = startup.settings;
|
||||
const gateway = createApplicationGateway(settings, startup.password ?? "");
|
||||
const agents = createAgentCapability(gateway);
|
||||
const agentIdentity = createAgentIdentityCapability(gateway);
|
||||
const agentSelection = createAgentSelectionCapability(gateway);
|
||||
const channels = createChannelCapability(gateway);
|
||||
const config = createApplicationConfigCapability({
|
||||
basePath,
|
||||
auth: {
|
||||
settings: { token: settings.token },
|
||||
password: startup.password ?? "",
|
||||
},
|
||||
});
|
||||
const sessions = createSessionCapability(gateway);
|
||||
const workboard = createWorkboardCapability();
|
||||
const runtimeConfig = createRuntimeConfigCapability(gateway);
|
||||
const overlays = createApplicationOverlays(gateway);
|
||||
const navigation = createApplicationNavigationPreferences(settings);
|
||||
const theme = createApplicationTheme(settings);
|
||||
const nativeChatDrafts = createNativeChatDrafts();
|
||||
const webPush = createWebPushCapability(gateway);
|
||||
const skillWorkshopRevision = createSkillWorkshopRevisionHandoff();
|
||||
applyStartupPresentation(settings);
|
||||
const router = createApplicationRouter();
|
||||
let pendingGatewayConnection =
|
||||
startup.pendingGatewayUrl !== null
|
||||
? {
|
||||
gatewayUrl: startup.pendingGatewayUrl,
|
||||
token: startup.pendingGatewayToken ?? "",
|
||||
}
|
||||
: null;
|
||||
let lastConfigRefreshClient: GatewayBrowserClient | null = null;
|
||||
const stopConfigRefresh = gateway.subscribe((snapshot) => {
|
||||
if (!snapshot.connected || !snapshot.client) {
|
||||
lastConfigRefreshClient = null;
|
||||
return;
|
||||
}
|
||||
if (lastConfigRefreshClient === snapshot.client) {
|
||||
return;
|
||||
}
|
||||
lastConfigRefreshClient = snapshot.client;
|
||||
void config.refresh({
|
||||
auth: {
|
||||
hello: snapshot.hello,
|
||||
settings: { token: gateway.connection.token },
|
||||
password: gateway.connection.password,
|
||||
},
|
||||
});
|
||||
});
|
||||
const routeLocation = (routeId: RouteId, options?: ApplicationNavigationOptions) => {
|
||||
const location = locationForRoute(routeId, basePath);
|
||||
if (options?.search !== undefined || options?.hash !== undefined) {
|
||||
return {
|
||||
...location,
|
||||
search: options?.search ?? "",
|
||||
hash: options?.hash ?? "",
|
||||
};
|
||||
}
|
||||
return location;
|
||||
};
|
||||
const confirmPendingGatewayConnection = () => {
|
||||
const pending = pendingGatewayConnection;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pendingGatewayConnection = null;
|
||||
gateway.connect({
|
||||
gatewayUrl: pending.gatewayUrl,
|
||||
token: pending.token,
|
||||
});
|
||||
};
|
||||
const cancelPendingGatewayConnection = () => {
|
||||
pendingGatewayConnection = null;
|
||||
};
|
||||
const context: ApplicationContext<RouteId> = {
|
||||
basePath,
|
||||
gateway,
|
||||
agents,
|
||||
agentIdentity,
|
||||
agentSelection,
|
||||
channels,
|
||||
config,
|
||||
runtimeConfig,
|
||||
sessions,
|
||||
workboard,
|
||||
overlays,
|
||||
navigation,
|
||||
theme,
|
||||
nativeChatDrafts,
|
||||
webPush,
|
||||
skillWorkshopRevision,
|
||||
navigate: (routeId, options) => {
|
||||
void router
|
||||
.navigate(routeId, context, { history: "push" }, routeLocation(routeId, options))
|
||||
.catch((error: unknown) => {
|
||||
console.error("[openclaw] route navigation failed", error);
|
||||
});
|
||||
},
|
||||
replace: (routeId, options) => {
|
||||
void router
|
||||
.navigate(routeId, context, { history: "replace" }, routeLocation(routeId, options))
|
||||
.catch((error: unknown) => {
|
||||
console.error("[openclaw] route replacement failed", error);
|
||||
});
|
||||
},
|
||||
preload: (routeId) => router.preloadRoute(routeId, context),
|
||||
};
|
||||
return {
|
||||
context,
|
||||
router,
|
||||
get pendingGatewayConnection() {
|
||||
return pendingGatewayConnection;
|
||||
},
|
||||
confirmPendingGatewayConnection,
|
||||
cancelPendingGatewayConnection,
|
||||
start: async () => {
|
||||
void config.refresh({ skipWithoutAuthCandidate: true });
|
||||
const routerStart = startApplicationRouter(router, history, basePath, context);
|
||||
gateway.start();
|
||||
await routerStart;
|
||||
},
|
||||
stop: () => {
|
||||
stopConfigRefresh();
|
||||
router.stop();
|
||||
gateway.stop();
|
||||
agents.dispose();
|
||||
channels.dispose();
|
||||
sessions.dispose();
|
||||
workboard.dispose();
|
||||
runtimeConfig.dispose();
|
||||
overlays.dispose();
|
||||
theme.dispose();
|
||||
nativeChatDrafts.dispose();
|
||||
webPush.dispose();
|
||||
skillWorkshopRevision.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
53
ui/src/app/browser.ts
Normal file
53
ui/src/app/browser.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { RouteLocation, RouterHistory } from "@openclaw/uirouter";
|
||||
|
||||
function readLocation(): RouteLocation {
|
||||
return {
|
||||
pathname: window.location.pathname,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
};
|
||||
}
|
||||
|
||||
function writeLocation(location: RouteLocation) {
|
||||
return `${location.pathname}${location.search}${location.hash}`;
|
||||
}
|
||||
|
||||
export function createBrowserHistory(): RouterHistory {
|
||||
const listeners = new Set<(location: RouteLocation) => void>();
|
||||
let stopPopState: (() => void) | undefined;
|
||||
|
||||
const ensurePopStateListener = () => {
|
||||
if (stopPopState) {
|
||||
return;
|
||||
}
|
||||
const onPopState = () => {
|
||||
const location = readLocation();
|
||||
for (const listener of listeners) {
|
||||
listener(location);
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
stopPopState = () => window.removeEventListener("popstate", onPopState);
|
||||
};
|
||||
|
||||
const releasePopStateListener = () => {
|
||||
if (listeners.size === 0) {
|
||||
stopPopState?.();
|
||||
stopPopState = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
location: readLocation,
|
||||
push: (location) => window.history.pushState({}, "", writeLocation(location)),
|
||||
replace: (location) => window.history.replaceState({}, "", writeLocation(location)),
|
||||
listen: (listener) => {
|
||||
listeners.add(listener);
|
||||
ensurePopStateListener();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
releasePopStateListener();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
248
ui/src/app/config.ts
Normal file
248
ui/src/app/config.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { normalizeRouteBasePath } from "@openclaw/uirouter";
|
||||
import {
|
||||
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
|
||||
CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE,
|
||||
type ControlUiBootstrapConfig,
|
||||
type ControlUiEmbedSandboxMode,
|
||||
} from "../../../src/gateway/control-ui-contract.js";
|
||||
import { normalizeAssistantIdentity } from "../lib/assistant-identity.ts";
|
||||
import { setUiTimeFormatPreference } from "../lib/format.ts";
|
||||
import { resolveControlUiAuthCandidates } from "./control-ui-auth.ts";
|
||||
|
||||
type ApplicationConfigAuthSource = {
|
||||
hello?: { auth?: { deviceToken?: string | null } | null } | null;
|
||||
settings?: { token?: string | null } | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
const SEAM_COLOR_CSS_VARIABLES = [
|
||||
"--ring",
|
||||
"--accent",
|
||||
"--accent-hover",
|
||||
"--accent-muted",
|
||||
"--accent-subtle",
|
||||
"--accent-glow",
|
||||
"--primary",
|
||||
"--focus",
|
||||
"--focus-ring",
|
||||
"--focus-glow",
|
||||
] as const;
|
||||
|
||||
export type ApplicationConfig = {
|
||||
assistantIdentity: {
|
||||
agentId: string | null;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
avatarSource: string | null;
|
||||
avatarStatus: "none" | "local" | "remote" | "data" | null;
|
||||
avatarReason: string | null;
|
||||
};
|
||||
serverVersion: string | null;
|
||||
localMediaPreviewRoots: string[];
|
||||
embedSandboxMode: ControlUiEmbedSandboxMode;
|
||||
allowExternalEmbedUrls: boolean;
|
||||
chatMessageMaxWidth: string | null;
|
||||
terminalEnabled: boolean;
|
||||
};
|
||||
|
||||
export type ApplicationConfigCapability = {
|
||||
readonly current: ApplicationConfig;
|
||||
refresh: (options?: {
|
||||
auth?: ApplicationConfigAuthSource;
|
||||
skipWithoutAuthCandidate?: boolean;
|
||||
}) => Promise<void>;
|
||||
subscribe: (listener: (config: ApplicationConfig) => void) => () => void;
|
||||
};
|
||||
|
||||
function readDocumentTerminalEnabled(): boolean | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const value = document.documentElement.getAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE);
|
||||
return value === "true" ? true : value === "false" ? false : null;
|
||||
}
|
||||
|
||||
export const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = {
|
||||
assistantIdentity: {
|
||||
agentId: null,
|
||||
name: "Assistant",
|
||||
avatar: null,
|
||||
avatarSource: null,
|
||||
avatarStatus: null,
|
||||
avatarReason: null,
|
||||
},
|
||||
serverVersion: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "strict",
|
||||
allowExternalEmbedUrls: false,
|
||||
chatMessageMaxWidth: null,
|
||||
terminalEnabled: readDocumentTerminalEnabled() ?? false,
|
||||
};
|
||||
|
||||
function normalizeSeamColor(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const hex = value.trim().replace(/^#/, "");
|
||||
return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
|
||||
}
|
||||
|
||||
function applyControlUiSeamColor(value: unknown): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
const color = normalizeSeamColor(value);
|
||||
if (!color) {
|
||||
for (const property of SEAM_COLOR_CSS_VARIABLES) {
|
||||
root.style.removeProperty(property);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
root.style.setProperty("--ring", color);
|
||||
root.style.setProperty("--accent", color);
|
||||
root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)");
|
||||
root.style.setProperty("--accent-muted", color);
|
||||
root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)");
|
||||
root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)");
|
||||
root.style.setProperty("--primary", color);
|
||||
root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)");
|
||||
root.style.setProperty(
|
||||
"--focus-ring",
|
||||
"0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)",
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--focus-glow",
|
||||
"0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)",
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): ApplicationConfig {
|
||||
const identity = normalizeAssistantIdentity({
|
||||
agentId: parsed.assistantAgentId ?? null,
|
||||
name: parsed.assistantName,
|
||||
avatar: parsed.assistantAvatar ?? null,
|
||||
avatarSource: parsed.assistantAvatarSource ?? null,
|
||||
avatarStatus: parsed.assistantAvatarStatus ?? null,
|
||||
avatarReason: parsed.assistantAvatarReason ?? null,
|
||||
});
|
||||
return {
|
||||
assistantIdentity: {
|
||||
agentId: identity.agentId ?? null,
|
||||
name: identity.name,
|
||||
avatar: identity.avatar,
|
||||
avatarSource: identity.avatarSource ?? null,
|
||||
avatarStatus: identity.avatarStatus ?? null,
|
||||
avatarReason: identity.avatarReason ?? null,
|
||||
},
|
||||
serverVersion: parsed.serverVersion ?? null,
|
||||
localMediaPreviewRoots: Array.isArray(parsed.localMediaPreviewRoots)
|
||||
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")
|
||||
: [],
|
||||
embedSandboxMode:
|
||||
parsed.embedSandbox === "trusted"
|
||||
? "trusted"
|
||||
: parsed.embedSandbox === "strict"
|
||||
? "strict"
|
||||
: "scripts",
|
||||
allowExternalEmbedUrls: parsed.allowExternalEmbedUrls === true,
|
||||
chatMessageMaxWidth:
|
||||
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
|
||||
? parsed.chatMessageMaxWidth
|
||||
: null,
|
||||
terminalEnabled: parsed.terminalEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadApplicationConfig(params: {
|
||||
basePath: string;
|
||||
auth?: ApplicationConfigAuthSource;
|
||||
skipWithoutAuthCandidate?: boolean;
|
||||
}): Promise<ApplicationConfig | null> {
|
||||
if (typeof window === "undefined" || typeof fetch !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePath = normalizeRouteBasePath(params.basePath);
|
||||
const url = basePath
|
||||
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
|
||||
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
||||
|
||||
try {
|
||||
const resolvedUrl = new URL(url, window.location.origin);
|
||||
const sameOrigin = resolvedUrl.origin === window.location.origin;
|
||||
const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(params.auth ?? {}) : [];
|
||||
if (params.skipWithoutAuthCandidate && sameOrigin && authCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const attempts = authCandidates.length > 0 ? authCandidates : [""];
|
||||
let res: Response | null = null;
|
||||
for (const candidate of attempts) {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (candidate) {
|
||||
headers.Authorization = `Bearer ${candidate}`;
|
||||
}
|
||||
res = await fetch(url, { method: "GET", headers, credentials: "same-origin" });
|
||||
if (res.ok) {
|
||||
break;
|
||||
}
|
||||
if (res.status !== 401 && res.status !== 403) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
return null;
|
||||
}
|
||||
const parsed = (await res.json()) as ControlUiBootstrapConfig;
|
||||
setUiTimeFormatPreference(parsed.timeFormat);
|
||||
applyControlUiSeamColor(parsed.seamColor);
|
||||
return normalizeApplicationConfig(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApplicationConfigCapability(params: {
|
||||
basePath: string;
|
||||
auth?: ApplicationConfigAuthSource;
|
||||
}): ApplicationConfigCapability {
|
||||
let current = DEFAULT_APPLICATION_CONFIG;
|
||||
let refreshVersion = 0;
|
||||
const listeners = new Set<(config: ApplicationConfig) => void>();
|
||||
|
||||
const publish = (next: ApplicationConfig) => {
|
||||
current = next;
|
||||
for (const listener of listeners) {
|
||||
listener(current);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
async refresh(options) {
|
||||
const version = ++refreshVersion;
|
||||
const next = await loadApplicationConfig({
|
||||
basePath: params.basePath,
|
||||
auth: options?.auth ?? params.auth,
|
||||
skipWithoutAuthCandidate: options?.skipWithoutAuthCandidate,
|
||||
});
|
||||
if (next && version === refreshVersion) {
|
||||
const documentTerminalEnabled = readDocumentTerminalEnabled();
|
||||
if (documentTerminalEnabled !== null && next.terminalEnabled !== documentTerminalEnabled) {
|
||||
// CSP headers cannot change on a live document. Reload in either
|
||||
// direction so the document and accepted terminal state stay aligned.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
publish(next);
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
82
ui/src/app/context.ts
Normal file
82
ui/src/app/context.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import type { RouteId } from "../app-route-paths.ts";
|
||||
import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
|
||||
import type { AgentCapability } from "../lib/agents/index.ts";
|
||||
import type { ChannelCapability } from "../lib/channels/index.ts";
|
||||
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
|
||||
import type { SessionCapability } from "../lib/sessions/index.ts";
|
||||
import type { WorkboardCapability } from "../lib/workboard/capability.ts";
|
||||
import type { AgentSelectionCapability } from "./agent-selection.ts";
|
||||
import type { ApplicationConfigCapability } from "./config.ts";
|
||||
import type { ApplicationGateway } from "./gateway.ts";
|
||||
import type { NativeChatDrafts } from "./native-bridge.ts";
|
||||
import type { ApplicationOverlays } from "./overlays.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import type { WebPushCapability } from "./web-push.ts";
|
||||
|
||||
export type {
|
||||
ApplicationGateway,
|
||||
ApplicationGatewayConnection,
|
||||
ApplicationGatewayConnectOptions,
|
||||
ApplicationGatewaySnapshot,
|
||||
} from "./gateway.ts";
|
||||
|
||||
export type ApplicationTheme = {
|
||||
readonly mode: ThemeMode;
|
||||
setMode: (mode: ThemeMode, element?: HTMLElement | null) => void;
|
||||
refresh: () => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
export type ApplicationNavigationPreferencesSnapshot = {
|
||||
navCollapsed: boolean;
|
||||
navGroupsCollapsed: Record<string, boolean>;
|
||||
recentSessionsCollapsed: boolean;
|
||||
};
|
||||
|
||||
export type ApplicationNavigationPreferences = {
|
||||
readonly snapshot: ApplicationNavigationPreferencesSnapshot;
|
||||
update: (patch: Partial<ApplicationNavigationPreferencesSnapshot>) => void;
|
||||
subscribe: (listener: (snapshot: ApplicationNavigationPreferencesSnapshot) => void) => () => void;
|
||||
};
|
||||
|
||||
export type ApplicationNavigationOptions = Partial<Pick<RouteLocation, "search" | "hash">>;
|
||||
|
||||
export type SkillWorkshopRevisionHandoff = {
|
||||
sessionKey: string;
|
||||
instructions: string;
|
||||
proposalId: string;
|
||||
proposalAgentId: string;
|
||||
};
|
||||
|
||||
export type ApplicationSkillWorkshopRevisionHandoff = {
|
||||
prepare: (handoff: SkillWorkshopRevisionHandoff) => void;
|
||||
consume: (sessionKey: string) => SkillWorkshopRevisionHandoff | null;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export type ApplicationContext<TRouteId extends string = string> = {
|
||||
readonly basePath: string;
|
||||
readonly gateway: ApplicationGateway;
|
||||
readonly agents: AgentCapability;
|
||||
readonly agentIdentity: AgentIdentityCapability;
|
||||
readonly agentSelection: AgentSelectionCapability;
|
||||
readonly channels: ChannelCapability;
|
||||
readonly config: ApplicationConfigCapability;
|
||||
readonly runtimeConfig: RuntimeConfigCapability;
|
||||
readonly sessions: SessionCapability;
|
||||
readonly workboard: WorkboardCapability;
|
||||
readonly overlays: ApplicationOverlays;
|
||||
readonly navigation: ApplicationNavigationPreferences;
|
||||
readonly theme: ApplicationTheme;
|
||||
readonly nativeChatDrafts: NativeChatDrafts;
|
||||
readonly webPush: WebPushCapability;
|
||||
readonly skillWorkshopRevision: ApplicationSkillWorkshopRevisionHandoff;
|
||||
readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
|
||||
readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
|
||||
readonly preload: (routeId: TRouteId) => Promise<void>;
|
||||
};
|
||||
|
||||
export const applicationContext =
|
||||
createContext<ApplicationContext<RouteId>>("openclaw.application");
|
||||
50
ui/src/app/control-ui-auth.ts
Normal file
50
ui/src/app/control-ui-auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Control UI module implements control ui auth behavior.
|
||||
import { normalizeOptionalString, uniqueStrings } from "../lib/string-coerce.ts";
|
||||
|
||||
type ControlUiAuthSource = {
|
||||
hello?: { auth?: { deviceToken?: string | null } | null } | null;
|
||||
settings?: { token?: string | null } | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
// The gateway's shared-secret auth contract accepts either `token` or
|
||||
// `password` as the Bearer credential on authenticated control-UI routes.
|
||||
// Passing the password through the Authorization header is the intended
|
||||
// server-side contract for `gateway.auth.mode="password"`. Callers that need
|
||||
// resilience to stale credentials should use `resolveControlUiAuthCandidates`
|
||||
// below to retry with the alternate credential on 401.
|
||||
function sanitizeHeaderToken(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
// Reject tokens that would smuggle CR/LF into the HTTP header.
|
||||
return /[\r\n]/.test(value) ? null : value;
|
||||
}
|
||||
|
||||
export function resolveControlUiAuthToken(source: ControlUiAuthSource): string | null {
|
||||
return (
|
||||
sanitizeHeaderToken(normalizeOptionalString(source.hello?.auth?.deviceToken) ?? null) ??
|
||||
sanitizeHeaderToken(normalizeOptionalString(source.settings?.token) ?? null) ??
|
||||
sanitizeHeaderToken(normalizeOptionalString(source.password) ?? null) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string | null {
|
||||
const token = resolveControlUiAuthToken(source);
|
||||
return token ? `Bearer ${token}` : null;
|
||||
}
|
||||
|
||||
// Ordered list of non-empty, header-safe shared-secret candidates. Used by
|
||||
// call sites that can retry a single request against an alternate credential
|
||||
// when the first returns 401 — for example, recovering from a stale
|
||||
// `settings.token` when the live session is authenticated via `password`.
|
||||
export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] {
|
||||
return uniqueStrings(
|
||||
[
|
||||
normalizeOptionalString(source.hello?.auth?.deviceToken),
|
||||
normalizeOptionalString(source.settings?.token),
|
||||
normalizeOptionalString(source.password),
|
||||
].flatMap((raw) => sanitizeHeaderToken(raw ?? null) ?? []),
|
||||
);
|
||||
}
|
||||
36
ui/src/app/control-ui-chunking.test.ts
Normal file
36
ui/src/app/control-ui-chunking.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { controlUiManualChunk, normalizeModuleId } from "../../config/control-ui-chunking.ts";
|
||||
|
||||
describe("Control UI build chunking", () => {
|
||||
it("groups stable runtime dependencies into bounded chunks", () => {
|
||||
expect(controlUiManualChunk("/repo/ui/node_modules/lit/index.js")).toBe("lit-runtime");
|
||||
expect(controlUiManualChunk("/repo/ui/node_modules/lit-html/directives/repeat.js")).toBe(
|
||||
"lit-runtime",
|
||||
);
|
||||
expect(controlUiManualChunk("/repo/ui/node_modules/highlight.js/lib/core.js")).toBe(
|
||||
"markdown-runtime",
|
||||
);
|
||||
expect(
|
||||
controlUiManualChunk("/tmp/openclaw-pnpm-node-modules/dompurify/dist/purify.es.mjs"),
|
||||
).toBe("markdown-runtime");
|
||||
expect(controlUiManualChunk("/tmp/openclaw-pnpm-node-modules/zod/v4/core/schemas.js")).toBe(
|
||||
"config-runtime",
|
||||
);
|
||||
expect(controlUiManualChunk("/tmp/openclaw-pnpm-node-modules/json5/dist/index.js")).toBe(
|
||||
"config-runtime",
|
||||
);
|
||||
expect(controlUiManualChunk("/tmp/openclaw-pnpm-node-modules/@noble/ed25519/index.js")).toBe(
|
||||
"gateway-runtime",
|
||||
);
|
||||
expect(controlUiManualChunk("/repo/ui/src/app/app-host.ts")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes Windows module paths before package matching", () => {
|
||||
expect(normalizeModuleId(String.raw`C:\repo\ui\node_modules\highlight.js\lib\core.js`)).toBe(
|
||||
"C:/repo/ui/node_modules/highlight.js/lib/core.js",
|
||||
);
|
||||
expect(controlUiManualChunk(String.raw`C:\repo\ui\node_modules\highlight.js\lib\core.js`)).toBe(
|
||||
"markdown-runtime",
|
||||
);
|
||||
});
|
||||
});
|
||||
311
ui/src/app/custom-theme.test.ts
Normal file
311
ui/src/app/custom-theme.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// Control UI tests cover custom theme behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createImportedCustomThemeFixture as createImportedTheme,
|
||||
createTweakcnThemePayload as createTweakcnPayload,
|
||||
} from "../test-helpers/custom-theme.ts";
|
||||
import {
|
||||
buildCustomThemeStyles,
|
||||
importCustomThemeFromUrl,
|
||||
normalizeImportedCustomTheme,
|
||||
normalizeTweakcnThemeUrl,
|
||||
parseImportedCustomTheme,
|
||||
syncCustomThemeStyleTag,
|
||||
} from "./custom-theme.ts";
|
||||
import type { ImportedCustomTheme } from "./custom-theme.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function createResponse(
|
||||
body: string,
|
||||
options: {
|
||||
body?: ReadableStream<Uint8Array> | null;
|
||||
headers?: HeadersInit;
|
||||
status?: number;
|
||||
url?: string;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
ok: (options.status ?? 200) >= 200 && (options.status ?? 200) < 300,
|
||||
status: options.status ?? 200,
|
||||
headers: new Headers(options.headers),
|
||||
body:
|
||||
options.body === undefined
|
||||
? new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
: options.body,
|
||||
text: vi.fn(async () => body),
|
||||
url: options.url ?? "",
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function firstFetchCall(
|
||||
fetchImpl: typeof fetch,
|
||||
): [string, { headers?: unknown; redirect?: unknown; signal?: unknown }] {
|
||||
const call = vi.mocked(fetchImpl).mock.calls[0] as
|
||||
| [string, { headers?: unknown; redirect?: unknown; signal?: unknown }]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("expected fetch call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("custom theme import helpers", () => {
|
||||
it("normalizes tweakcn share links and raw registry links", () => {
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
expect(normalizeTweakcnThemeUrl("/r/themes/cmlhfpjhw000004l4f4ax3m7z")).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
expect(normalizeTweakcnThemeUrl("cmlhfpjhw000004l4f4ax3m7z")).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts theme ids from copied tweakcn editor URLs and pasted text", () => {
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("https://tweakcn.com/editor/theme?theme=cmlhfpjhw000004l4f4ax3m7z"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("Theme link: https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
expect(
|
||||
normalizeTweakcnThemeUrl("https://tweakcn.com/editor/theme?theme=amethyst-haze"),
|
||||
).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/amethyst-haze",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/amethyst-haze",
|
||||
themeId: "amethyst-haze",
|
||||
});
|
||||
expect(normalizeTweakcnThemeUrl("amethyst-haze")).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/amethyst-haze",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/amethyst-haze",
|
||||
themeId: "amethyst-haze",
|
||||
});
|
||||
expect(normalizeTweakcnThemeUrl("https://tweakcn.com/r/themes/claude")).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/claude",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/claude",
|
||||
themeId: "claude",
|
||||
});
|
||||
expect(normalizeTweakcnThemeUrl("twitter")).toEqual({
|
||||
sourceUrl: "https://tweakcn.com/themes/twitter",
|
||||
fetchUrl: "https://tweakcn.com/r/themes/twitter",
|
||||
themeId: "twitter",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a tweakcn payload into a normalized imported theme record", () => {
|
||||
const imported = createImportedTheme();
|
||||
|
||||
expect(imported.label).toBe("Light Green");
|
||||
expect(imported.sourceUrl).toBe("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z");
|
||||
expect(imported.light.bg).toBe("oklch(0.98 0.01 120)");
|
||||
expect(imported.dark.bg).toBe("oklch(0.12 0.04 265)");
|
||||
expect(imported.light["font-body"]).toBe("Inter, system-ui, sans-serif");
|
||||
expect(imported.dark["accent-hover"]).toBe("color-mix(in srgb, var(--accent) 82%, white 18%)");
|
||||
});
|
||||
|
||||
it("fetches tweakcn themes with bounded no-redirect requests", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()));
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
const imported = await importCustomThemeFromUrl(
|
||||
"https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
expect(imported.label).toBe("Light Green");
|
||||
const fetchMock = vi.mocked(fetchImpl);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [fetchUrl, fetchOptions] = firstFetchCall(fetchImpl);
|
||||
expect(fetchUrl).toBe("https://tweakcn.com/r/themes/cmlhfpjhw000004l4f4ax3m7z");
|
||||
expect(fetchOptions.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(fetchOptions).toEqual({
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: fetchOptions.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversized tweakcn theme responses before parsing", async () => {
|
||||
const response = createResponse("{}", {
|
||||
headers: { "content-length": "200001" },
|
||||
});
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("rejects tweakcn theme responses without a bounded body stream", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()), { body: null });
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("unreadable theme payload");
|
||||
expect(response["text"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirected tweakcn import responses", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()), {
|
||||
url: "https://example.com/r/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
});
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("Unexpected redirect");
|
||||
});
|
||||
|
||||
it("rejects CSS tokens that can escape variables or trigger external requests", () => {
|
||||
const payload = createTweakcnPayload();
|
||||
payload.cssVars.light.background = 'url("https://example.com/track")';
|
||||
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
|
||||
payload.cssVars.light.background = "oklch(0.98 0.01 120)/*";
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
|
||||
payload.cssVars.light.background = 'image-set("https://example.com/pixel.png" 1x)';
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
|
||||
payload.cssVars.light.background = "oklch(0.98 0.01 120)";
|
||||
payload.cssVars.theme["font-sans"] = "var(--attacker-font)";
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
});
|
||||
|
||||
it("validates imported font families without regex backtracking", () => {
|
||||
const payload = createTweakcnPayload();
|
||||
payload.cssVars.theme["font-sans"] =
|
||||
'"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
|
||||
expect(
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}).light["font-body"],
|
||||
).toBe('"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif');
|
||||
|
||||
payload.cssVars.theme["font-sans"] = `${"Inter, ".repeat(20)}@bad`;
|
||||
expect(() =>
|
||||
normalizeImportedCustomTheme(payload, {
|
||||
sourceUrl: "https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z",
|
||||
themeId: "cmlhfpjhw000004l4f4ax3m7z",
|
||||
}),
|
||||
).toThrow("Unsupported tweakcn token");
|
||||
});
|
||||
|
||||
it("builds stable CSS blocks for custom dark and light themes", () => {
|
||||
const css = buildCustomThemeStyles(createImportedTheme());
|
||||
const selectorAndBackgroundLines = css
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith(":root") || line.trim().startsWith("--bg:"));
|
||||
|
||||
expect(selectorAndBackgroundLines).toEqual([
|
||||
':root[data-theme="custom"] {',
|
||||
" --bg: oklch(0.12 0.04 265);",
|
||||
':root[data-theme="custom-light"] {',
|
||||
" --bg: oklch(0.98 0.01 120);",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when stored custom theme tokens are missing", () => {
|
||||
const theme = { ...createImportedTheme(), light: undefined } as unknown as ImportedCustomTheme;
|
||||
|
||||
expect(() => buildCustomThemeStyles(theme)).toThrow(
|
||||
"Stored custom theme is missing required tokens.",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses stored imported themes and rejects malformed records", () => {
|
||||
const imported = createImportedTheme();
|
||||
|
||||
const parsed = parseImportedCustomTheme(imported);
|
||||
if (!parsed) {
|
||||
throw new Error("Expected imported custom theme to parse");
|
||||
}
|
||||
expect(parsed.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z");
|
||||
expect(parseImportedCustomTheme({ ...imported, themeId: "claude" })?.themeId).toBe("claude");
|
||||
expect(parseImportedCustomTheme({ ...imported, light: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it("syncs the managed custom theme style tag in the document head", () => {
|
||||
const appendChild = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const style = { id: "", textContent: "", remove } as unknown as HTMLStyleElement;
|
||||
const documentStub = {
|
||||
head: { appendChild },
|
||||
createElement: vi.fn(() => style),
|
||||
getElementById: vi.fn(() => null),
|
||||
} as unknown as Document;
|
||||
vi.stubGlobal("document", documentStub);
|
||||
|
||||
const theme = createImportedTheme();
|
||||
syncCustomThemeStyleTag(theme);
|
||||
|
||||
expect(appendChild).toHaveBeenCalledWith(style);
|
||||
expect(style.id).toBe("openclaw-custom-theme");
|
||||
expect(style.textContent).toBe(buildCustomThemeStyles(theme));
|
||||
|
||||
vi.stubGlobal("document", {
|
||||
head: documentStub.head,
|
||||
createElement: documentStub["createElement"],
|
||||
getElementById: vi.fn(() => style),
|
||||
} as unknown as Document);
|
||||
|
||||
syncCustomThemeStyleTag(null);
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
639
ui/src/app/custom-theme.ts
Normal file
639
ui/src/app/custom-theme.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
// Control UI module implements custom theme behavior.
|
||||
import { z } from "zod";
|
||||
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}$/;
|
||||
const CUSTOM_THEME_STYLE_ID = "openclaw-custom-theme";
|
||||
const MAX_TWEAKCN_THEME_BYTES = 200_000;
|
||||
const MAX_CSS_TOKEN_LENGTH = 240;
|
||||
const TWEAKCN_FETCH_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_FONT_BODY =
|
||||
'"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
const DEFAULT_MONO =
|
||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace';
|
||||
const FORBIDDEN_CSS_VALUE_PARTS = [
|
||||
"url(",
|
||||
"image(",
|
||||
"image-set(",
|
||||
"-webkit-image-set(",
|
||||
"cross-fade(",
|
||||
"element(",
|
||||
"-moz-element(",
|
||||
"paint(",
|
||||
"@import",
|
||||
"expression(",
|
||||
] as const;
|
||||
const SAFE_COLOR_KEYWORDS = new Set(["black", "white", "transparent", "currentcolor"]);
|
||||
const SAFE_COLOR_FUNCTION_PATTERN =
|
||||
/^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([a-z0-9+\-.,/%\s]+\)$/i;
|
||||
const SAFE_HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const SAFE_FONT_FAMILY_PUNCTUATION = new Set([",", "'", '"', ".", "_", "-"]);
|
||||
|
||||
const MODE_TOKEN_ORDER = [
|
||||
"bg",
|
||||
"bg-accent",
|
||||
"bg-elevated",
|
||||
"bg-hover",
|
||||
"bg-muted",
|
||||
"bg-content",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"card-highlight",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"panel",
|
||||
"panel-strong",
|
||||
"panel-hover",
|
||||
"chrome",
|
||||
"chrome-strong",
|
||||
"text",
|
||||
"text-strong",
|
||||
"chat-text",
|
||||
"muted",
|
||||
"muted-strong",
|
||||
"muted-foreground",
|
||||
"border",
|
||||
"border-strong",
|
||||
"border-hover",
|
||||
"input",
|
||||
"ring",
|
||||
"accent",
|
||||
"accent-hover",
|
||||
"accent-muted",
|
||||
"accent-subtle",
|
||||
"accent-foreground",
|
||||
"accent-glow",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"accent-2",
|
||||
"accent-2-muted",
|
||||
"accent-2-subtle",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"danger",
|
||||
"danger-muted",
|
||||
"danger-subtle",
|
||||
"focus",
|
||||
"focus-ring",
|
||||
"focus-glow",
|
||||
"font-body",
|
||||
"font-display",
|
||||
"mono",
|
||||
"grid-line",
|
||||
] as const;
|
||||
|
||||
type ModeTokenName = (typeof MODE_TOKEN_ORDER)[number];
|
||||
type ThemeTokenMap = Record<ModeTokenName, string>;
|
||||
|
||||
const REQUIRED_TWEAKCN_MODE_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
] as const;
|
||||
type RequiredTweakcnModeVar = (typeof REQUIRED_TWEAKCN_MODE_VARS)[number];
|
||||
|
||||
export type ImportedCustomTheme = {
|
||||
sourceUrl: string;
|
||||
themeId: string;
|
||||
label: string;
|
||||
importedAt: string;
|
||||
light: ThemeTokenMap;
|
||||
dark: ThemeTokenMap;
|
||||
};
|
||||
|
||||
const cssTokenSchema = z.string().max(MAX_CSS_TOKEN_LENGTH);
|
||||
|
||||
function createStringShape<const T extends readonly string[]>(keys: T) {
|
||||
return Object.fromEntries(keys.map((key) => [key, cssTokenSchema])) as Record<
|
||||
T[number],
|
||||
typeof cssTokenSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const tweakcnThemeSchema = z.object({
|
||||
name: z.string().max(80).optional(),
|
||||
cssVars: z.object({
|
||||
theme: z
|
||||
.object({
|
||||
"font-sans": cssTokenSchema.optional(),
|
||||
"font-mono": cssTokenSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
light: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
dark: z.object(createStringShape(REQUIRED_TWEAKCN_MODE_VARS)),
|
||||
}),
|
||||
});
|
||||
|
||||
const importedCustomThemeSchema = z.object({
|
||||
sourceUrl: z.string(),
|
||||
themeId: z.string(),
|
||||
label: z.string(),
|
||||
importedAt: z.string(),
|
||||
light: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
dark: z.object(createStringShape(MODE_TOKEN_ORDER)),
|
||||
});
|
||||
|
||||
type TweakcnThemePayload = z.infer<typeof tweakcnThemeSchema>;
|
||||
|
||||
type TweakcnThemeResolution = {
|
||||
sourceUrl: string;
|
||||
fetchUrl: string;
|
||||
themeId: string;
|
||||
};
|
||||
|
||||
function requireThemeId(value: string) {
|
||||
if (!THEME_ID_PATTERN.test(value)) {
|
||||
throw new Error("Unsupported tweakcn link. Expected a theme share URL.");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThemeIdFromPath(pathname: string): string | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 2 && segments[0] === "themes") {
|
||||
requireThemeId(segments[1]);
|
||||
return segments[1];
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "r" && segments[1] === "themes") {
|
||||
requireThemeId(segments[2]);
|
||||
return segments[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePastedThemeInput(input: string): string {
|
||||
const normalized = normalizeOptionalString(input);
|
||||
if (!normalized) {
|
||||
throw new Error("Paste a tweakcn theme link to import.");
|
||||
}
|
||||
const inputValue = normalized.replace(/[.,;:]+$/, "");
|
||||
if (THEME_ID_PATTERN.test(inputValue)) {
|
||||
return `https://tweakcn.com/themes/${inputValue}`;
|
||||
}
|
||||
if (inputValue.startsWith("/themes/") || inputValue.startsWith("/r/themes/")) {
|
||||
return `https://tweakcn.com${inputValue}`;
|
||||
}
|
||||
if (/^(?:www\.)?tweakcn\.com\//i.test(inputValue)) {
|
||||
return `https://${inputValue}`;
|
||||
}
|
||||
const embeddedUrl = inputValue
|
||||
.match(/https?:\/\/(?:www\.)?tweakcn\.com\/[^\s<>"')]+/i)?.[0]
|
||||
?.replace(/[.,;:]+$/, "");
|
||||
return embeddedUrl ?? inputValue;
|
||||
}
|
||||
|
||||
function normalizeThemeIdFromUrl(parsed: URL): string {
|
||||
const pathThemeId = normalizeThemeIdFromPath(parsed.pathname);
|
||||
if (pathThemeId) {
|
||||
return pathThemeId;
|
||||
}
|
||||
const queryThemeId =
|
||||
parsed.searchParams.get("theme") ??
|
||||
parsed.searchParams.get("themeId") ??
|
||||
parsed.searchParams.get("id");
|
||||
if (queryThemeId) {
|
||||
requireThemeId(queryThemeId);
|
||||
return queryThemeId;
|
||||
}
|
||||
throw new Error("Unsupported tweakcn link. Expected a theme share URL.");
|
||||
}
|
||||
|
||||
function requireSafeCssValue(value: unknown, label: string) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
if (normalized.length > MAX_CSS_TOKEN_LENGTH) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (FORBIDDEN_CSS_VALUE_PARTS.some((part) => lowered.includes(part))) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
if (normalized.includes("/*") || normalized.includes("*/") || normalized.includes("\\")) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
for (const char of normalized) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (
|
||||
code < 0x20 ||
|
||||
code === 0x7f ||
|
||||
char === "{" ||
|
||||
char === "}" ||
|
||||
char === ";" ||
|
||||
char === "<" ||
|
||||
char === ">" ||
|
||||
char === "`"
|
||||
) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSafeExternalColorValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (
|
||||
SAFE_COLOR_KEYWORDS.has(lowered) ||
|
||||
SAFE_HEX_COLOR_PATTERN.test(normalized) ||
|
||||
SAFE_COLOR_FUNCTION_PATTERN.test(normalized)
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
|
||||
function isSafeFontFamilyCharacter(char: string) {
|
||||
const code = char.charCodeAt(0);
|
||||
return (
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
(code >= 0x41 && code <= 0x5a) ||
|
||||
(code >= 0x61 && code <= 0x7a) ||
|
||||
char === " " ||
|
||||
SAFE_FONT_FAMILY_PUNCTUATION.has(char)
|
||||
);
|
||||
}
|
||||
|
||||
function requireSafeFontFamilyValue(value: unknown, label: string) {
|
||||
const normalized = requireSafeCssValue(value, label);
|
||||
if (
|
||||
normalized.includes("(") ||
|
||||
normalized.includes(")") ||
|
||||
!Array.from(normalized).every(isSafeFontFamilyCharacter)
|
||||
) {
|
||||
throw new Error(`Unsupported tweakcn token: ${label}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSafeExternalModeValue(value: unknown, label: string) {
|
||||
if (label === "font-sans" || label === "font-mono") {
|
||||
return requireSafeFontFamilyValue(value, label);
|
||||
}
|
||||
return requireSafeExternalColorValue(value, label);
|
||||
}
|
||||
|
||||
function makeTokenMap(entries: Array<[ModeTokenName, string]>): ThemeTokenMap {
|
||||
return Object.fromEntries(entries) as ThemeTokenMap;
|
||||
}
|
||||
|
||||
function normalizeStoredTokenMap(value: Record<string, string> | undefined): ThemeTokenMap | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries: Array<[ModeTokenName, string]> = [];
|
||||
for (const key of MODE_TOKEN_ORDER) {
|
||||
const normalized =
|
||||
key === "font-body" || key === "font-display" || key === "mono"
|
||||
? requireSafeFontFamilyValue(value[key], key)
|
||||
: requireSafeCssValue(value[key], key);
|
||||
entries.push([key, normalized]);
|
||||
}
|
||||
return makeTokenMap(entries);
|
||||
}
|
||||
|
||||
function resolveModeVar(
|
||||
theme: Record<string, string | undefined>,
|
||||
shared: Record<string, string | undefined> | undefined,
|
||||
key: string,
|
||||
fallback?: string,
|
||||
) {
|
||||
const themeValue = normalizeOptionalString(theme[key]);
|
||||
if (themeValue) {
|
||||
return requireSafeExternalModeValue(themeValue, key);
|
||||
}
|
||||
const sharedValue = normalizeOptionalString(shared?.[key]);
|
||||
if (sharedValue) {
|
||||
return requireSafeExternalModeValue(sharedValue, key);
|
||||
}
|
||||
if (fallback != null) {
|
||||
return key === "font-sans" || key === "font-mono"
|
||||
? requireSafeFontFamilyValue(fallback, key)
|
||||
: requireSafeCssValue(fallback, key);
|
||||
}
|
||||
throw new Error(`tweakcn theme is missing required token: ${key}`);
|
||||
}
|
||||
|
||||
function normalizeModeTokenMap(
|
||||
mode: "light" | "dark",
|
||||
theme: Record<RequiredTweakcnModeVar, string>,
|
||||
shared: Record<string, string | undefined> | undefined,
|
||||
): ThemeTokenMap {
|
||||
const isLight = mode === "light";
|
||||
const contrastTarget = isLight ? "black" : "white";
|
||||
const background = resolveModeVar(theme, shared, "background");
|
||||
const foreground = resolveModeVar(theme, shared, "foreground");
|
||||
const card = resolveModeVar(theme, shared, "card");
|
||||
const cardForeground = resolveModeVar(theme, shared, "card-foreground");
|
||||
const popover = resolveModeVar(theme, shared, "popover");
|
||||
const popoverForeground = resolveModeVar(theme, shared, "popover-foreground");
|
||||
const primary = resolveModeVar(theme, shared, "primary");
|
||||
const primaryForeground = resolveModeVar(theme, shared, "primary-foreground");
|
||||
const secondary = resolveModeVar(theme, shared, "secondary");
|
||||
const secondaryForeground = resolveModeVar(theme, shared, "secondary-foreground");
|
||||
const muted = resolveModeVar(theme, shared, "muted");
|
||||
const mutedForeground = resolveModeVar(theme, shared, "muted-foreground");
|
||||
const accent = resolveModeVar(theme, shared, "accent");
|
||||
const accentForeground = resolveModeVar(theme, shared, "accent-foreground");
|
||||
const destructive = resolveModeVar(theme, shared, "destructive");
|
||||
const destructiveForeground = resolveModeVar(theme, shared, "destructive-foreground");
|
||||
const border = resolveModeVar(theme, shared, "border");
|
||||
const input = resolveModeVar(theme, shared, "input");
|
||||
const ring = resolveModeVar(theme, shared, "ring");
|
||||
const fontBody = resolveModeVar(theme, shared, "font-sans", DEFAULT_FONT_BODY);
|
||||
const mono = resolveModeVar(theme, shared, "font-mono", DEFAULT_MONO);
|
||||
|
||||
return makeTokenMap([
|
||||
["bg", background],
|
||||
["bg-accent", "color-mix(in srgb, var(--bg) 88%, var(--card) 12%)"],
|
||||
["bg-elevated", card],
|
||||
["bg-hover", "color-mix(in srgb, var(--muted) 68%, var(--bg) 32%)"],
|
||||
["bg-muted", muted],
|
||||
["bg-content", "color-mix(in srgb, var(--bg) 92%, var(--card) 8%)"],
|
||||
["card", card],
|
||||
["card-foreground", cardForeground],
|
||||
["card-highlight", `color-mix(in srgb, var(--text) ${isLight ? "3" : "5"}%, transparent)`],
|
||||
["popover", popover],
|
||||
["popover-foreground", popoverForeground],
|
||||
["panel", background],
|
||||
["panel-strong", card],
|
||||
["panel-hover", "color-mix(in srgb, var(--card) 76%, var(--muted) 24%)"],
|
||||
["chrome", "color-mix(in srgb, var(--bg) 96%, transparent)"],
|
||||
["chrome-strong", "color-mix(in srgb, var(--bg) 98%, transparent)"],
|
||||
["text", foreground],
|
||||
["text-strong", foreground],
|
||||
["chat-text", foreground],
|
||||
["muted", mutedForeground],
|
||||
["muted-strong", "color-mix(in srgb, var(--muted) 84%, var(--text) 16%)"],
|
||||
["muted-foreground", mutedForeground],
|
||||
["border", border],
|
||||
["border-strong", "color-mix(in srgb, var(--border) 72%, var(--text) 28%)"],
|
||||
["border-hover", "color-mix(in srgb, var(--border) 55%, var(--text) 45%)"],
|
||||
["input", input],
|
||||
["ring", ring],
|
||||
["accent", accent],
|
||||
["accent-hover", `color-mix(in srgb, var(--accent) 82%, ${contrastTarget} 18%)`],
|
||||
["accent-muted", accent],
|
||||
["accent-subtle", `color-mix(in srgb, var(--accent) ${isLight ? "10" : "16"}%, transparent)`],
|
||||
["accent-foreground", accentForeground],
|
||||
["accent-glow", `color-mix(in srgb, var(--accent) ${isLight ? "18" : "30"}%, transparent)`],
|
||||
["primary", primary],
|
||||
["primary-foreground", primaryForeground],
|
||||
["secondary", secondary],
|
||||
["secondary-foreground", secondaryForeground],
|
||||
["accent-2", primary],
|
||||
["accent-2-muted", "color-mix(in srgb, var(--accent-2) 72%, transparent)"],
|
||||
[
|
||||
"accent-2-subtle",
|
||||
`color-mix(in srgb, var(--accent-2) ${isLight ? "8" : "12"}%, transparent)`,
|
||||
],
|
||||
["destructive", destructive],
|
||||
["destructive-foreground", destructiveForeground],
|
||||
["danger", destructive],
|
||||
["danger-muted", "color-mix(in srgb, var(--danger) 75%, transparent)"],
|
||||
["danger-subtle", `color-mix(in srgb, var(--danger) ${isLight ? "8" : "12"}%, transparent)`],
|
||||
["focus", `color-mix(in srgb, var(--ring) ${isLight ? "14" : "22"}%, transparent)`],
|
||||
[
|
||||
"focus-ring",
|
||||
`0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) ${isLight ? "70" : "80"}%, transparent)`,
|
||||
],
|
||||
["focus-glow", "0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)"],
|
||||
["font-body", fontBody],
|
||||
["font-display", fontBody],
|
||||
["mono", mono],
|
||||
["grid-line", `color-mix(in srgb, var(--text) ${isLight ? "4" : "3"}%, transparent)`],
|
||||
]);
|
||||
}
|
||||
|
||||
function describeThemeLabel(value: string | undefined) {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
return "Custom";
|
||||
}
|
||||
return normalized.slice(0, 80);
|
||||
}
|
||||
|
||||
export function normalizeTweakcnThemeUrl(input: string): TweakcnThemeResolution {
|
||||
const normalized = normalizePastedThemeInput(input);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error("Paste a full tweakcn URL.");
|
||||
}
|
||||
if (!TWEAKCN_HOSTS.has(parsed.hostname)) {
|
||||
throw new Error("Only tweakcn.com theme links are supported.");
|
||||
}
|
||||
const themeId = normalizeThemeIdFromUrl(parsed);
|
||||
return {
|
||||
themeId,
|
||||
sourceUrl: `https://tweakcn.com/themes/${themeId}`,
|
||||
fetchUrl: `https://tweakcn.com/r/themes/${themeId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseImportedCustomTheme(value: unknown): ImportedCustomTheme | null {
|
||||
const parsed = importedCustomThemeSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
requireThemeId(parsed.data.themeId);
|
||||
const light = normalizeStoredTokenMap(parsed.data.light);
|
||||
const dark = normalizeStoredTokenMap(parsed.data.dark);
|
||||
if (!light || !dark) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceUrl: parsed.data.sourceUrl,
|
||||
themeId: parsed.data.themeId,
|
||||
label: describeThemeLabel(parsed.data.label),
|
||||
importedAt: parsed.data.importedAt,
|
||||
light,
|
||||
dark,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeImportedCustomTheme(
|
||||
payload: unknown,
|
||||
resolution: Pick<TweakcnThemeResolution, "sourceUrl" | "themeId">,
|
||||
): ImportedCustomTheme {
|
||||
const parsed = tweakcnThemeSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error("tweakcn returned an invalid theme payload.");
|
||||
}
|
||||
const data: TweakcnThemePayload = parsed.data;
|
||||
const shared = data.cssVars.theme;
|
||||
return {
|
||||
sourceUrl: resolution.sourceUrl,
|
||||
themeId: resolution.themeId,
|
||||
label: describeThemeLabel(data.name),
|
||||
importedAt: new Date().toISOString(),
|
||||
light: normalizeModeTokenMap("light", data.cssVars.light, shared),
|
||||
dark: normalizeModeTokenMap("dark", data.cssVars.dark, shared),
|
||||
};
|
||||
}
|
||||
|
||||
function assertTweakcnResponseUrl(value: string | undefined) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Unexpected tweakcn import response URL.");
|
||||
}
|
||||
if (parsed.protocol !== "https:" || !TWEAKCN_HOSTS.has(parsed.hostname)) {
|
||||
throw new Error("Unexpected redirect during tweakcn import.");
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function readResponseTextWithLimit(response: Response): Promise<string> {
|
||||
const contentLength = parseContentLength(response.headers);
|
||||
if (contentLength != null && contentLength > MAX_TWEAKCN_THEME_BYTES) {
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("tweakcn returned an unreadable theme payload.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
break;
|
||||
}
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > MAX_TWEAKCN_THEME_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponseWithLimit(response: Response): Promise<unknown> {
|
||||
const text = await readResponseTextWithLimit(response);
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new Error("tweakcn returned invalid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function importCustomThemeFromUrl(
|
||||
input: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ImportedCustomTheme> {
|
||||
const resolution = normalizeTweakcnThemeUrl(input);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TWEAKCN_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(resolution.fetchUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
assertTweakcnResponseUrl(response.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`tweakcn import failed (${response.status}).`);
|
||||
}
|
||||
const payload = await readJsonResponseWithLimit(response);
|
||||
return normalizeImportedCustomTheme(payload, resolution);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("tweakcn import timed out.", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCustomThemeStyles(theme: ImportedCustomTheme) {
|
||||
const light = normalizeStoredTokenMap(theme.light);
|
||||
const dark = normalizeStoredTokenMap(theme.dark);
|
||||
if (!light || !dark) {
|
||||
throw new Error("Stored custom theme is missing required tokens.");
|
||||
}
|
||||
const renderDeclarations = (modeTokens: ThemeTokenMap) =>
|
||||
MODE_TOKEN_ORDER.map((key) => ` --${key}: ${modeTokens[key]};`).join("\n");
|
||||
return [
|
||||
`:root[data-theme="custom"] {`,
|
||||
renderDeclarations(dark),
|
||||
`}`,
|
||||
`:root[data-theme="custom-light"] {`,
|
||||
renderDeclarations(light),
|
||||
`}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function syncCustomThemeStyleTag(theme: ImportedCustomTheme | null | undefined) {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
let style = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!theme) {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
let cssText;
|
||||
try {
|
||||
cssText = buildCustomThemeStyles(theme);
|
||||
} catch {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
if (!cssText) {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = CUSTOM_THEME_STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = cssText;
|
||||
}
|
||||
458
ui/src/app/exec-approval.test.ts
Normal file
458
ui/src/app/exec-approval.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
// Control UI tests cover exec approval behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addExecApproval,
|
||||
isStaleApprovalResolutionError,
|
||||
parseExecApprovalRequested,
|
||||
parsePluginApprovalRequested,
|
||||
clearResolvedExecApprovalPrompt,
|
||||
refreshPendingApprovalQueue,
|
||||
type ExecApprovalPromptState,
|
||||
type ExecApprovalRequest,
|
||||
} from "./exec-approval.ts";
|
||||
|
||||
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
function createExecApproval(overrides: Partial<ExecApprovalRequest> = {}): ExecApprovalRequest {
|
||||
return {
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
request: { command: "echo hello" },
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPromptState(
|
||||
request: RequestFn,
|
||||
queue: ExecApprovalRequest[] = [createExecApproval()],
|
||||
): ExecApprovalPromptState {
|
||||
return {
|
||||
client: { request },
|
||||
execApprovalQueue: queue,
|
||||
execApprovalBusy: false,
|
||||
execApprovalError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayError(message: string, details?: unknown): Error {
|
||||
const err = new Error(message);
|
||||
Object.defineProperty(err, "gatewayCode", {
|
||||
value: "INVALID_REQUEST",
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(err, "details", {
|
||||
value: details,
|
||||
enumerable: true,
|
||||
});
|
||||
return err;
|
||||
}
|
||||
|
||||
describe("parseExecApprovalRequested", () => {
|
||||
it("returns entries with kind 'exec'", () => {
|
||||
const result = parseExecApprovalRequested({
|
||||
id: "exec-1",
|
||||
request: { command: "rm -rf /" },
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
expect(result?.kind).toBe("exec");
|
||||
expect(result?.request.command).toBe("rm -rf /");
|
||||
});
|
||||
|
||||
it("preserves allowed approval decisions", () => {
|
||||
const result = parseExecApprovalRequested({
|
||||
id: "exec-1",
|
||||
request: {
|
||||
command: "pwd",
|
||||
allowedDecisions: ["allow-once", "bad", "deny", "allow-always"],
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
|
||||
expect(result?.request.allowedDecisions).toEqual(["allow-once", "deny", "allow-always"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginApprovalRequested", () => {
|
||||
// Matches the actual gateway broadcast shape: title/description/severity/pluginId
|
||||
// are nested inside payload.request (PluginApprovalRequestPayload)
|
||||
const validPayload = {
|
||||
id: "plugin-1",
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 120_000,
|
||||
request: {
|
||||
title: "Dangerous command detected",
|
||||
description: "chmod 777 script.sh modifies file permissions",
|
||||
severity: "high",
|
||||
pluginId: "sage",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "sess-1",
|
||||
},
|
||||
};
|
||||
|
||||
it("parses a valid payload", () => {
|
||||
const result = parsePluginApprovalRequested(validPayload);
|
||||
expect(result?.kind).toBe("plugin");
|
||||
expect(result?.pluginTitle).toBe("Dangerous command detected");
|
||||
expect(result?.pluginDescription).toBe("chmod 777 script.sh modifies file permissions");
|
||||
expect(result?.pluginSeverity).toBe("high");
|
||||
expect(result?.pluginId).toBe("sage");
|
||||
expect(result?.request.command).toBe("Dangerous command detected");
|
||||
expect(result?.request.agentId).toBe("agent-1");
|
||||
expect(result?.request.sessionKey).toBe("sess-1");
|
||||
expect(result?.createdAtMs).toBe(1000);
|
||||
expect(result?.expiresAtMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns null when title is missing from request", () => {
|
||||
const {
|
||||
request: { title: _, ...restRequest },
|
||||
...rest
|
||||
} = validPayload;
|
||||
expect(parsePluginApprovalRequested({ ...rest, request: restRequest })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when request is missing entirely", () => {
|
||||
const { request: _, ...noRequest } = validPayload;
|
||||
expect(parsePluginApprovalRequested(noRequest)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when id is missing", () => {
|
||||
const { id: _, ...noId } = validPayload;
|
||||
expect(parsePluginApprovalRequested(noId)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when timestamps are missing", () => {
|
||||
const { createdAtMs: _, expiresAtMs: __, ...noTimestamps } = validPayload;
|
||||
expect(parsePluginApprovalRequested(noTimestamps)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null payload", () => {
|
||||
expect(parsePluginApprovalRequested(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object payload", () => {
|
||||
expect(parsePluginApprovalRequested("not an object")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing optional fields gracefully", () => {
|
||||
const minimal = {
|
||||
id: "plugin-2",
|
||||
createdAtMs: 500,
|
||||
expiresAtMs: 60_000,
|
||||
request: { title: "Alert" },
|
||||
};
|
||||
const result = parsePluginApprovalRequested(minimal);
|
||||
expect(result?.kind).toBe("plugin");
|
||||
expect(result?.pluginTitle).toBe("Alert");
|
||||
expect(result?.pluginDescription).toBeNull();
|
||||
expect(result?.pluginSeverity).toBeNull();
|
||||
expect(result?.pluginId).toBeNull();
|
||||
expect(result?.request.agentId).toBeNull();
|
||||
expect(result?.request.sessionKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseExecApprovalRequested command spans", () => {
|
||||
it("preserves command text spacing for span offsets", () => {
|
||||
const parsed = parseExecApprovalRequested({
|
||||
id: "approval-spaces-1",
|
||||
request: { command: " python -c 'print(1)'" },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
|
||||
expect(parsed?.request.command).toBe(" python -c 'print(1)'");
|
||||
});
|
||||
|
||||
it("rejects whitespace-only command text", () => {
|
||||
expect(
|
||||
parseExecApprovalRequested({
|
||||
id: "approval-blank-1",
|
||||
request: { command: " " },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves valid command spans from exec approval events", () => {
|
||||
const parsed = parseExecApprovalRequested({
|
||||
id: "approval-explain-1",
|
||||
request: {
|
||||
command: "ls | grep stuff",
|
||||
commandSpans: [
|
||||
{ startIndex: 0, endIndex: 2 },
|
||||
{ startIndex: 5, endIndex: 9 },
|
||||
{ startIndex: 10, endIndex: 15 },
|
||||
{ startIndex: 16, endIndex: 20 },
|
||||
{ startIndex: -1, endIndex: 2 },
|
||||
{ startIndex: 8, endIndex: 8 },
|
||||
],
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
|
||||
expect(parsed?.request.commandSpans).toEqual([
|
||||
{ startIndex: 0, endIndex: 2 },
|
||||
{ startIndex: 5, endIndex: 9 },
|
||||
{ startIndex: 10, endIndex: 15 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStaleApprovalResolutionError", () => {
|
||||
it("detects already-resolved approval errors", () => {
|
||||
expect(
|
||||
isStaleApprovalResolutionError(
|
||||
createGatewayError("approval already resolved", {
|
||||
reason: "APPROVAL_ALREADY_RESOLVED",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects unknown or expired approval errors", () => {
|
||||
expect(
|
||||
isStaleApprovalResolutionError(createGatewayError("unknown or expired approval id")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects missing approval errors", () => {
|
||||
expect(
|
||||
isStaleApprovalResolutionError(
|
||||
createGatewayError("approval not found", {
|
||||
reason: "APPROVAL_NOT_FOUND",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unrelated approval resolve errors", () => {
|
||||
expect(isStaleApprovalResolutionError(createGatewayError("gateway unavailable"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearResolvedExecApprovalPrompt", () => {
|
||||
it("does not clear the active prompt error when another approval resolves", () => {
|
||||
const active = createExecApproval({ id: "approval-active", createdAtMs: 2 });
|
||||
const queued = createExecApproval({ id: "approval-queued", createdAtMs: 1 });
|
||||
const state = createPromptState(
|
||||
vi.fn<RequestFn>(async () => ({})),
|
||||
[active, queued],
|
||||
);
|
||||
state.execApprovalError = "Approval failed: Error: gateway unavailable";
|
||||
|
||||
clearResolvedExecApprovalPrompt(state, "approval-queued");
|
||||
|
||||
expect(state.execApprovalQueue.map((entry) => entry.id)).toEqual(["approval-active"]);
|
||||
expect(state.execApprovalError).toBe("Approval failed: Error: gateway unavailable");
|
||||
});
|
||||
|
||||
it("clears the active prompt error when the active approval resolves", () => {
|
||||
const state = createPromptState(vi.fn<RequestFn>(async () => ({})));
|
||||
state.execApprovalError = "Approval failed: Error: gateway unavailable";
|
||||
|
||||
clearResolvedExecApprovalPrompt(state, "approval-1");
|
||||
|
||||
expect(state.execApprovalQueue).toEqual([]);
|
||||
expect(state.execApprovalError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshPendingApprovalQueue", () => {
|
||||
it("keeps approvals received while a refresh is in flight", async () => {
|
||||
let resolveExecList: (value: unknown[]) => void = () => {};
|
||||
const execApprovalList = new Promise<unknown[]>((resolve) => {
|
||||
resolveExecList = resolve;
|
||||
});
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return execApprovalList;
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return [];
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createPromptState(request, []);
|
||||
|
||||
const refreshPromise = refreshPendingApprovalQueue(state);
|
||||
state.execApprovalQueue = addExecApproval(
|
||||
state.execApprovalQueue,
|
||||
createExecApproval({ id: "approval-arrived-during-refresh", createdAtMs: 2000 }),
|
||||
);
|
||||
resolveExecList([]);
|
||||
await refreshPromise;
|
||||
|
||||
expect(state.execApprovalQueue.map((entry) => entry.id)).toEqual([
|
||||
"approval-arrived-during-refresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not requeue approvals resolved while a refresh is in flight", async () => {
|
||||
let resolveExecList: (value: unknown[]) => void = () => {};
|
||||
const execApprovalList = new Promise<unknown[]>((resolve) => {
|
||||
resolveExecList = resolve;
|
||||
});
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return execApprovalList;
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return [];
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const resolvingApproval = createExecApproval({ id: "approval-resolving" });
|
||||
const state = createPromptState(request, [resolvingApproval]);
|
||||
|
||||
const refreshPromise = refreshPendingApprovalQueue(state);
|
||||
clearResolvedExecApprovalPrompt(state, "approval-resolving");
|
||||
resolveExecList([resolvingApproval]);
|
||||
await refreshPromise;
|
||||
|
||||
expect(state.execApprovalQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not requeue new approvals resolved before refresh completes", async () => {
|
||||
let resolveExecList: (value: unknown[]) => void = () => {};
|
||||
let resolvePluginList: (value: unknown[]) => void = () => {};
|
||||
const execApprovalList = new Promise<unknown[]>((resolve) => {
|
||||
resolveExecList = resolve;
|
||||
});
|
||||
const pluginApprovalList = new Promise<unknown[]>((resolve) => {
|
||||
resolvePluginList = resolve;
|
||||
});
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return execApprovalList;
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return pluginApprovalList;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createPromptState(request, []);
|
||||
const transientApproval = createExecApproval({ id: "approval-transient" });
|
||||
|
||||
const refreshPromise = refreshPendingApprovalQueue(state);
|
||||
state.execApprovalQueue = addExecApproval(state.execApprovalQueue, transientApproval);
|
||||
resolveExecList([transientApproval]);
|
||||
clearResolvedExecApprovalPrompt(state, "approval-transient");
|
||||
resolvePluginList([]);
|
||||
await refreshPromise;
|
||||
|
||||
expect(state.execApprovalQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes refreshed approvals after their expiry", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-25T00:00:00.000Z"));
|
||||
try {
|
||||
const expiresAtMs = Date.now() + 1_000;
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "approval-refreshed-1",
|
||||
request: { command: "pnpm check:changed" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return [];
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createPromptState(request, []);
|
||||
|
||||
await refreshPendingApprovalQueue(state);
|
||||
expect(state.execApprovalQueue.map((entry) => entry.id)).toEqual(["approval-refreshed-1"]);
|
||||
|
||||
vi.advanceTimersByTime(1_500);
|
||||
|
||||
expect(state.execApprovalQueue).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears active prompt errors when expiry advances the queue", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-25T00:00:00.000Z"));
|
||||
try {
|
||||
const activeExpiresAtMs = Date.now() + 1_000;
|
||||
const queuedExpiresAtMs = Date.now() + 60_000;
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "approval-active-expiring",
|
||||
request: { command: "pnpm check:changed" },
|
||||
createdAtMs: Date.now() + 1,
|
||||
expiresAtMs: activeExpiresAtMs,
|
||||
},
|
||||
{
|
||||
id: "approval-queued",
|
||||
request: { command: "pnpm test" },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: queuedExpiresAtMs,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return [];
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createPromptState(request, []);
|
||||
|
||||
await refreshPendingApprovalQueue(state);
|
||||
state.execApprovalError = "Approval failed: Error: gateway unavailable";
|
||||
|
||||
vi.advanceTimersByTime(1_500);
|
||||
|
||||
expect(state.execApprovalQueue.map((entry) => entry.id)).toEqual(["approval-queued"]);
|
||||
expect(state.execApprovalError).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not requeue expired approvals returned by refresh lists", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-25T00:00:00.000Z"));
|
||||
try {
|
||||
const request = vi.fn<RequestFn>(async (method) => {
|
||||
if (method === "exec.approval.list") {
|
||||
return [
|
||||
{
|
||||
id: "approval-expired-1",
|
||||
request: { command: "pnpm check:changed" },
|
||||
createdAtMs: Date.now() - 2_000,
|
||||
expiresAtMs: Date.now() - 1_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (method === "plugin.approval.list") {
|
||||
return [];
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createPromptState(request, []);
|
||||
|
||||
await refreshPendingApprovalQueue(state);
|
||||
|
||||
expect(state.execApprovalQueue).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
423
ui/src/app/exec-approval.ts
Normal file
423
ui/src/app/exec-approval.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
// Application-owned approval parsing and queue state.
|
||||
import { normalizeOptionalString } from "../lib/string-coerce.ts";
|
||||
|
||||
export type ExecApprovalRequestPayload = {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
commandSpans?: readonly {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}[];
|
||||
allowedDecisions?: readonly ExecApprovalDecision[];
|
||||
};
|
||||
|
||||
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
kind: "exec" | "plugin";
|
||||
request: ExecApprovalRequestPayload;
|
||||
pluginTitle?: string;
|
||||
pluginDescription?: string | null;
|
||||
pluginSeverity?: string | null;
|
||||
pluginId?: string | null;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolved = {
|
||||
id: string;
|
||||
decision?: string | null;
|
||||
resolvedBy?: string | null;
|
||||
ts?: number | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalPromptState = {
|
||||
client: {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
} | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalBusy: boolean;
|
||||
execApprovalError: string | null;
|
||||
execApprovalRefreshes?: Set<{ removedIds: Set<string> }>;
|
||||
execApprovalExpiryTimers?: Map<string, ReturnType<typeof globalThis.setTimeout>>;
|
||||
execApprovalExpired?: () => void;
|
||||
};
|
||||
|
||||
const APPROVAL_ALREADY_RESOLVED = "APPROVAL_ALREADY_RESOLVED";
|
||||
const APPROVAL_NOT_FOUND = "APPROVAL_NOT_FOUND";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function parseCommandSpans(
|
||||
value: unknown,
|
||||
commandLength: number,
|
||||
):
|
||||
| {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}[]
|
||||
| undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const spans = value.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
} => {
|
||||
if (!isRecord(item)) {
|
||||
return false;
|
||||
}
|
||||
const { startIndex, endIndex } = item;
|
||||
return (
|
||||
Number.isSafeInteger(startIndex) &&
|
||||
Number.isSafeInteger(endIndex) &&
|
||||
typeof startIndex === "number" &&
|
||||
typeof endIndex === "number" &&
|
||||
startIndex >= 0 &&
|
||||
endIndex > startIndex &&
|
||||
endIndex <= commandLength
|
||||
);
|
||||
},
|
||||
);
|
||||
return spans.length > 0 ? spans : undefined;
|
||||
}
|
||||
|
||||
function parseAllowedDecisions(value: unknown): ExecApprovalDecision[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const decisions = value.filter(
|
||||
(decision): decision is ExecApprovalDecision =>
|
||||
decision === "allow-once" || decision === "allow-always" || decision === "deny",
|
||||
);
|
||||
return decisions.length > 0 ? decisions : undefined;
|
||||
}
|
||||
|
||||
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = normalizeOptionalString(payload.id) ?? "";
|
||||
const request = payload.request;
|
||||
if (!id || !isRecord(request)) {
|
||||
return null;
|
||||
}
|
||||
const command = typeof request.command === "string" ? request.command : "";
|
||||
if (command.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
|
||||
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
|
||||
if (!createdAtMs || !expiresAtMs) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind: "exec",
|
||||
request: {
|
||||
command,
|
||||
cwd: typeof request.cwd === "string" ? request.cwd : null,
|
||||
host: typeof request.host === "string" ? request.host : null,
|
||||
security: typeof request.security === "string" ? request.security : null,
|
||||
ask: typeof request.ask === "string" ? request.ask : null,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : null,
|
||||
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
|
||||
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
|
||||
commandSpans: parseCommandSpans(request.commandSpans, command.length),
|
||||
allowedDecisions: parseAllowedDecisions(request.allowedDecisions),
|
||||
},
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = normalizeOptionalString(payload.id) ?? "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
decision: typeof payload.decision === "string" ? payload.decision : null,
|
||||
resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null,
|
||||
ts: typeof payload.ts === "number" ? payload.ts : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePluginApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = normalizeOptionalString(payload.id) ?? "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
|
||||
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
|
||||
if (!createdAtMs || !expiresAtMs) {
|
||||
return null;
|
||||
}
|
||||
// title, description, severity, pluginId, agentId, sessionKey live inside payload.request
|
||||
const request = isRecord(payload.request) ? payload.request : {};
|
||||
const title = normalizeOptionalString(request.title) ?? "";
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
const description = typeof request.description === "string" ? request.description : null;
|
||||
const severity = typeof request.severity === "string" ? request.severity : null;
|
||||
const pluginId = typeof request.pluginId === "string" ? request.pluginId : null;
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: "plugin",
|
||||
request: {
|
||||
command: title,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : null,
|
||||
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
|
||||
allowedDecisions: parseAllowedDecisions(request.allowedDecisions),
|
||||
},
|
||||
pluginTitle: title,
|
||||
pluginDescription: description,
|
||||
pluginSeverity: severity,
|
||||
pluginId,
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
|
||||
const now = Date.now();
|
||||
return queue.filter((entry) => entry.expiresAtMs > now);
|
||||
}
|
||||
|
||||
export function addExecApproval(
|
||||
queue: ExecApprovalRequest[],
|
||||
entry: ExecApprovalRequest,
|
||||
): ExecApprovalRequest[] {
|
||||
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
|
||||
next.unshift(entry);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeExecApproval(
|
||||
queue: ExecApprovalRequest[],
|
||||
id: string,
|
||||
): ExecApprovalRequest[] {
|
||||
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
|
||||
}
|
||||
|
||||
function readGatewayErrorCode(err: unknown): string | null {
|
||||
if (!isRecord(err)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeOptionalString(err.gatewayCode) ?? null;
|
||||
}
|
||||
|
||||
function readGatewayErrorReason(err: unknown): string | null {
|
||||
if (!isRecord(err)) {
|
||||
return null;
|
||||
}
|
||||
const { details } = err;
|
||||
if (!isRecord(details)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeOptionalString(details.reason) ?? null;
|
||||
}
|
||||
|
||||
export function isStaleApprovalResolutionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const gatewayCode = readGatewayErrorCode(err);
|
||||
const reason = readGatewayErrorReason(err);
|
||||
if (reason === APPROVAL_ALREADY_RESOLVED || reason === APPROVAL_NOT_FOUND) {
|
||||
return true;
|
||||
}
|
||||
if (gatewayCode === APPROVAL_NOT_FOUND) {
|
||||
return true;
|
||||
}
|
||||
return /unknown or expired approval id/i.test(err.message);
|
||||
}
|
||||
|
||||
function parseApprovalList(
|
||||
payload: unknown,
|
||||
parseEntry: (entry: unknown) => ExecApprovalRequest | null,
|
||||
): ExecApprovalRequest[] | null {
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
return payload.flatMap((entry) => {
|
||||
const parsed = parseEntry(entry);
|
||||
return parsed ? [parsed] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function sortApprovalsNewestFirst(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
|
||||
return queue.toSorted((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
}
|
||||
|
||||
function currentApprovalsForKind(
|
||||
queue: ExecApprovalRequest[],
|
||||
kind: ExecApprovalRequest["kind"],
|
||||
): ExecApprovalRequest[] {
|
||||
return pruneExecApprovalQueue(queue).filter((entry) => entry.kind === kind);
|
||||
}
|
||||
|
||||
function mergeRefreshedApprovalQueue(
|
||||
refreshed: ExecApprovalRequest[],
|
||||
refreshStartedWith: ExecApprovalRequest[],
|
||||
currentQueue: ExecApprovalRequest[],
|
||||
removedDuringRefresh: ReadonlySet<string>,
|
||||
): ExecApprovalRequest[] {
|
||||
const refreshStartIds = new Set(refreshStartedWith.map((entry) => entry.id));
|
||||
const prunedCurrentQueue = pruneExecApprovalQueue(currentQueue);
|
||||
const currentQueueIds = new Set(prunedCurrentQueue.map((entry) => entry.id));
|
||||
const currentRefreshed = pruneExecApprovalQueue(refreshed).filter(
|
||||
(entry) =>
|
||||
!removedDuringRefresh.has(entry.id) &&
|
||||
(!refreshStartIds.has(entry.id) || currentQueueIds.has(entry.id)),
|
||||
);
|
||||
const refreshedIds = new Set(currentRefreshed.map((entry) => entry.id));
|
||||
const arrivedDuringRefresh = prunedCurrentQueue.filter(
|
||||
(entry) => !refreshStartIds.has(entry.id) && !refreshedIds.has(entry.id),
|
||||
);
|
||||
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 {
|
||||
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)) {
|
||||
state.execApprovalError = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueueExecApprovalPrompt(
|
||||
state: ExecApprovalPromptState,
|
||||
entry: ExecApprovalRequest,
|
||||
): void {
|
||||
state.execApprovalQueue = addExecApproval(state.execApprovalQueue, entry);
|
||||
state.execApprovalError = null;
|
||||
scheduleApprovalExpiryPrune(state, entry);
|
||||
}
|
||||
|
||||
export async function refreshPendingApprovalQueue(
|
||||
state: ExecApprovalPromptState,
|
||||
options?: {
|
||||
isCurrentClient?: (client: NonNullable<ExecApprovalPromptState["client"]>) => boolean;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const client = state.client;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
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([
|
||||
client.request("exec.approval.list", {}),
|
||||
client.request("plugin.approval.list", {}),
|
||||
]);
|
||||
const execApprovals =
|
||||
execResult.status === "fulfilled"
|
||||
? (parseApprovalList(execResult.value, parseExecApprovalRequested) ?? [])
|
||||
: currentApprovalsForKind(state.execApprovalQueue, "exec");
|
||||
const pluginApprovals =
|
||||
pluginResult.status === "fulfilled"
|
||||
? (parseApprovalList(pluginResult.value, parsePluginApprovalRequested) ?? [])
|
||||
: currentApprovalsForKind(state.execApprovalQueue, "plugin");
|
||||
const refreshed = mergeRefreshedApprovalQueue(
|
||||
sortApprovalsNewestFirst([...execApprovals, ...pluginApprovals]),
|
||||
refreshStartedWith,
|
||||
state.execApprovalQueue,
|
||||
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 {
|
||||
refreshes.delete(refresh);
|
||||
if (refreshes.size === 0) {
|
||||
state.execApprovalRefreshes = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissExecApprovalPrompt(state: ExecApprovalPromptState, id: string): void {
|
||||
removeExecApprovalFromState(state, 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);
|
||||
for (const refresh of state.execApprovalRefreshes ?? []) {
|
||||
refresh.removedIds.add(id);
|
||||
}
|
||||
}
|
||||
35
ui/src/app/gateway.ts
Normal file
35
ui/src/app/gateway.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { EventLogEntry } from "../api/event-log.ts";
|
||||
import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts";
|
||||
|
||||
export type ApplicationGatewaySnapshot = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
hello: GatewayHelloOk | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
};
|
||||
|
||||
export type ApplicationGatewayConnection = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type ApplicationGatewayConnectOptions = Partial<ApplicationGatewayConnection> & {
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type ApplicationGateway = {
|
||||
readonly snapshot: ApplicationGatewaySnapshot;
|
||||
readonly connection: ApplicationGatewayConnection;
|
||||
readonly eventLog: readonly EventLogEntry[];
|
||||
connect: (connection?: ApplicationGatewayConnectOptions) => void;
|
||||
setSessionKey: (sessionKey: string) => void;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void;
|
||||
subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void;
|
||||
subscribeEvents: (listener: GatewayEventListener) => () => void;
|
||||
};
|
||||
118
ui/src/app/mount-fallback.test.ts
Normal file
118
ui/src/app/mount-fallback.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Control UI tests cover mount fallback behavior.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const indexHtmlPath = path.resolve(
|
||||
process.cwd(),
|
||||
path.basename(process.cwd()) === "ui" ? "index.html" : "ui/index.html",
|
||||
);
|
||||
type TestWindow = Window & typeof globalThis;
|
||||
|
||||
async function readIndexHtmlWithDelay(delayMs: number): Promise<string> {
|
||||
const html = await readFile(indexHtmlPath, "utf8");
|
||||
return html.replace(
|
||||
'data-openclaw-mount-timeout-ms="12000"',
|
||||
`data-openclaw-mount-timeout-ms="${delayMs}"`,
|
||||
);
|
||||
}
|
||||
|
||||
function waitForWindowTimeout(window: TestWindow, delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
function createIsolatedWindow(): TestWindow {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const frameWindow = frame.contentWindow as TestWindow | null;
|
||||
if (!frameWindow) {
|
||||
throw new Error("failed to create isolated frame window");
|
||||
}
|
||||
return frameWindow;
|
||||
}
|
||||
|
||||
function installFallbackShell(window: TestWindow, html: string): void {
|
||||
const parsed = new window.DOMParser().parseFromString(html, "text/html");
|
||||
window.document.head.innerHTML = parsed.head.innerHTML;
|
||||
window.document.body.innerHTML = parsed.body.innerHTML;
|
||||
|
||||
const sentinel = Array.from(parsed.querySelectorAll<HTMLScriptElement>("script:not([src])")).find(
|
||||
(script) => script.textContent?.includes("openclaw-mount-fallback"),
|
||||
);
|
||||
if (!sentinel?.textContent) {
|
||||
throw new Error("Expected inline mount fallback script in index.html");
|
||||
}
|
||||
window.eval(sentinel.textContent);
|
||||
}
|
||||
|
||||
function requireElementById<T extends HTMLElement>(
|
||||
window: TestWindow,
|
||||
id: string,
|
||||
constructor: new () => T,
|
||||
): T {
|
||||
const element = window.document.getElementById(id);
|
||||
expect(element).toBeInstanceOf(constructor);
|
||||
if (!(element instanceof constructor)) {
|
||||
throw new Error(`Expected #${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("Control UI mount fallback", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("shows the static troubleshooting panel when the app element is never registered", async () => {
|
||||
const frameWindow = createIsolatedWindow();
|
||||
expect(frameWindow.customElements.get("openclaw-app")).toBeUndefined();
|
||||
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
|
||||
await waitForWindowTimeout(frameWindow, 10);
|
||||
|
||||
const fallback = requireElementById(
|
||||
frameWindow,
|
||||
"openclaw-mount-fallback",
|
||||
frameWindow.HTMLElement,
|
||||
);
|
||||
expect(fallback.hidden).toBe(false);
|
||||
expect([...frameWindow.document.body.classList]).toEqual(["openclaw-mount-fallback-active"]);
|
||||
expect(fallback.querySelector("h1")?.textContent?.trim()).toBe("Control UI did not start");
|
||||
expect(fallback.querySelector("a")?.textContent?.trim()).toBe("Control UI troubleshooting");
|
||||
expect(frameWindow.document.activeElement).toBeInstanceOf(frameWindow.HTMLElement);
|
||||
expect([...(frameWindow.document.activeElement as HTMLElement).classList]).toEqual([
|
||||
"mount-fallback__panel",
|
||||
]);
|
||||
|
||||
const waitButton = requireElementById(
|
||||
frameWindow,
|
||||
"openclaw-mount-wait",
|
||||
frameWindow.HTMLButtonElement,
|
||||
);
|
||||
waitButton.click();
|
||||
expect(fallback.hidden).toBe(true);
|
||||
expect([...frameWindow.document.body.classList]).toEqual([]);
|
||||
|
||||
await waitForWindowTimeout(frameWindow, 10);
|
||||
expect(fallback.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the fallback hidden when the app element registers before the timeout", async () => {
|
||||
const frameWindow = createIsolatedWindow();
|
||||
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(25));
|
||||
if (!frameWindow.customElements.get("openclaw-app")) {
|
||||
frameWindow.customElements.define("openclaw-app", class extends frameWindow.HTMLElement {});
|
||||
}
|
||||
await frameWindow.customElements.whenDefined("openclaw-app");
|
||||
await waitForWindowTimeout(frameWindow, 35);
|
||||
|
||||
const fallback = requireElementById(
|
||||
frameWindow,
|
||||
"openclaw-mount-fallback",
|
||||
frameWindow.HTMLElement,
|
||||
);
|
||||
expect(fallback.hidden).toBe(true);
|
||||
expect([...frameWindow.document.body.classList]).toEqual([]);
|
||||
});
|
||||
});
|
||||
125
ui/src/app/native-bridge.test.ts
Normal file
125
ui/src/app/native-bridge.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
handleChatDraftChange as applyDraftChange,
|
||||
navigateChatInputHistory,
|
||||
type ChatInputHistoryState,
|
||||
} from "../pages/chat/input-history.ts";
|
||||
import { createNativeChatDrafts, isWebView2, sendToNative } from "./native-bridge.ts";
|
||||
|
||||
type FakeBridge = {
|
||||
postMessage: ReturnType<typeof vi.fn>;
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
removeEventListener: ReturnType<typeof vi.fn>;
|
||||
listeners: ((event: MessageEvent) => void)[];
|
||||
posted: unknown[];
|
||||
};
|
||||
|
||||
function makeBridge(): FakeBridge {
|
||||
const listeners: ((event: MessageEvent) => void)[] = [];
|
||||
const posted: unknown[] = [];
|
||||
const bridge: FakeBridge = {
|
||||
posted,
|
||||
listeners,
|
||||
postMessage: vi.fn((message: unknown) => posted.push(message)),
|
||||
addEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => {
|
||||
listeners.push(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal("chrome", { webview: bridge });
|
||||
return bridge;
|
||||
}
|
||||
|
||||
function dispatch(bridge: FakeBridge, data: unknown) {
|
||||
for (const listener of bridge.listeners) {
|
||||
listener({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("native chat drafts", () => {
|
||||
it("detects WebView2 and sends native messages", () => {
|
||||
expect(isWebView2()).toBe(false);
|
||||
const bridge = makeBridge();
|
||||
expect(isWebView2()).toBe(true);
|
||||
sendToNative({ type: "ready" });
|
||||
expect(bridge.posted).toEqual([{ type: "ready" }]);
|
||||
});
|
||||
|
||||
it("registers the listener before the ready handshake", () => {
|
||||
const callOrder: string[] = [];
|
||||
vi.stubGlobal("chrome", {
|
||||
webview: {
|
||||
postMessage: vi.fn(() => callOrder.push("post")),
|
||||
addEventListener: vi.fn(() => callOrder.push("listen")),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
createNativeChatDrafts();
|
||||
|
||||
expect(callOrder).toEqual(["listen", "post"]);
|
||||
});
|
||||
|
||||
it("delivers drafts and ignores invalid messages", () => {
|
||||
const bridge = makeBridge();
|
||||
const drafts = createNativeChatDrafts();
|
||||
const listener = vi.fn();
|
||||
drafts.subscribe(listener);
|
||||
|
||||
dispatch(bridge, { type: "draft-text", payload: { text: "hello from native" } });
|
||||
dispatch(bridge, { type: "draft-text" });
|
||||
dispatch(bridge, { type: "draft-text", payload: { text: 42 } });
|
||||
dispatch(bridge, { type: "unknown" });
|
||||
dispatch(bridge, null);
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith("hello from native");
|
||||
});
|
||||
|
||||
it("removes the native listener and stops delivery on dispose", () => {
|
||||
const bridge = makeBridge();
|
||||
const drafts = createNativeChatDrafts();
|
||||
const listener = vi.fn();
|
||||
drafts.subscribe(listener);
|
||||
|
||||
drafts.dispose();
|
||||
dispatch(bridge, { type: "draft-text", payload: { text: "after cleanup" } });
|
||||
|
||||
expect(bridge.listeners).toHaveLength(0);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies native drafts through the real Chat draft owner", () => {
|
||||
const bridge = makeBridge();
|
||||
const state: ChatInputHistoryState = {
|
||||
sessionKey: "s1",
|
||||
chatLoading: false,
|
||||
chatMessage: "",
|
||||
chatMessages: [],
|
||||
chatLocalInputHistoryBySession: { s1: [{ text: "previous input", ts: 1 }] },
|
||||
chatInputHistorySessionKey: null,
|
||||
chatInputHistoryItems: null,
|
||||
chatInputHistoryIndex: -1,
|
||||
chatDraftBeforeHistory: null,
|
||||
};
|
||||
navigateChatInputHistory(state, "up");
|
||||
const drafts = createNativeChatDrafts();
|
||||
drafts.subscribe((text) => applyDraftChange(state, text));
|
||||
|
||||
dispatch(bridge, { type: "draft-text", payload: { text: "native injection" } });
|
||||
|
||||
expect(state.chatMessage).toBe("native injection");
|
||||
expect(state.chatInputHistoryIndex).toBe(-1);
|
||||
expect(state.chatInputHistoryItems).toBeNull();
|
||||
expect(state.chatInputHistorySessionKey).toBeNull();
|
||||
});
|
||||
});
|
||||
100
ui/src/app/native-bridge.ts
Normal file
100
ui/src/app/native-bridge.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Application-owned native draft delivery.
|
||||
type WebView2Bridge = {
|
||||
postMessage(message: unknown): void;
|
||||
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
||||
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
||||
};
|
||||
|
||||
export type NativeBridgeMessage =
|
||||
| { type: "draft-text"; payload: { text: string } }
|
||||
| { type: "ready"; payload?: Record<string, unknown> };
|
||||
|
||||
export type NativeChatDrafts = {
|
||||
subscribe: (listener: (draft: string) => void) => () => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function getWebview(): WebView2Bridge | undefined {
|
||||
const webview = (window as unknown as { chrome?: { webview?: WebView2Bridge } }).chrome?.webview;
|
||||
return webview;
|
||||
}
|
||||
|
||||
export function isWebView2(): boolean {
|
||||
return getWebview() !== undefined;
|
||||
}
|
||||
|
||||
export function sendToNative(msg: NativeBridgeMessage): void {
|
||||
getWebview()?.postMessage(msg);
|
||||
}
|
||||
|
||||
function readNativeDraft(raw: unknown): string | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const msg = raw as Record<string, unknown>;
|
||||
if (typeof msg.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (msg.type === "draft-text") {
|
||||
const text =
|
||||
msg.payload && typeof msg.payload === "object"
|
||||
? (msg.payload as Record<string, unknown>).text
|
||||
: undefined;
|
||||
if (typeof text === "string") {
|
||||
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.
|
||||
* Drafts received while Chat is not mounted are retained for its next subscriber.
|
||||
*/
|
||||
export function createNativeChatDrafts(): NativeChatDrafts {
|
||||
const bridge = getWebview();
|
||||
if (!bridge) {
|
||||
return {
|
||||
subscribe: () => () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
let pendingDraft: string | null = null;
|
||||
const listeners = new Set<(draft: string) => void>();
|
||||
const handler = (event: MessageEvent) => {
|
||||
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 {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (pendingDraft !== null) {
|
||||
const draft = pendingDraft;
|
||||
pendingDraft = null;
|
||||
listener(draft);
|
||||
}
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
listeners.clear();
|
||||
pendingDraft = null;
|
||||
bridge.removeEventListener("message", handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
41
ui/src/app/operator-access.ts
Normal file
41
ui/src/app/operator-access.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Control UI app-level operator scope checks.
|
||||
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
|
||||
|
||||
export function hasOperatorReadAccess(
|
||||
auth: { role?: string; scopes?: readonly string[] } | null,
|
||||
): boolean {
|
||||
if (!auth?.scopes) {
|
||||
return false;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: auth.role ?? "operator",
|
||||
requestedScopes: ["operator.read"],
|
||||
allowedScopes: auth.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasOperatorWriteAccess(
|
||||
auth: { role?: string; scopes?: readonly string[] } | null,
|
||||
): boolean {
|
||||
if (!auth?.scopes) {
|
||||
return true;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: auth.role ?? "operator",
|
||||
requestedScopes: ["operator.write"],
|
||||
allowedScopes: auth.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasOperatorAdminAccess(
|
||||
auth: { role?: string; scopes?: readonly string[] } | null,
|
||||
): boolean {
|
||||
if (!auth?.scopes) {
|
||||
return true;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: auth.role ?? "operator",
|
||||
requestedScopes: ["operator.admin"],
|
||||
allowedScopes: auth.scopes,
|
||||
});
|
||||
}
|
||||
663
ui/src/app/overlays.ts
Normal file
663
ui/src/app/overlays.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
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 {
|
||||
closeDevicePairSetup as closeDevicePairSetupState,
|
||||
openDevicePairSetup as openDevicePairSetupState,
|
||||
refreshDevicePairSetup as refreshDevicePairSetupState,
|
||||
type DevicePairSetup,
|
||||
type DevicePairSetupState,
|
||||
} from "../lib/device-pair-setup.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;
|
||||
devicePairSetupOpen: boolean;
|
||||
devicePairSetupLoading: boolean;
|
||||
devicePairSetupError: string | null;
|
||||
devicePairSetup: DevicePairSetup | null;
|
||||
devicePairPendingCount: number;
|
||||
};
|
||||
|
||||
export type ApplicationOverlays = {
|
||||
readonly snapshot: ApplicationOverlaySnapshot;
|
||||
subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void;
|
||||
runUpdate: () => Promise<void>;
|
||||
dismissUpdate: () => void;
|
||||
decideApproval: (decision: ExecApprovalDecision) => Promise<void>;
|
||||
openDevicePairSetup: () => Promise<void>;
|
||||
refreshDevicePairSetup: () => Promise<void>;
|
||||
closeDevicePairSetup: () => 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,
|
||||
devicePairSetupOpen: false,
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
devicePairPendingCount: 0,
|
||||
};
|
||||
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;
|
||||
let devicePairPendingCountGeneration = 0;
|
||||
const devicePairSetupState: DevicePairSetupState & { pendingCount: number } = {
|
||||
client: gateway.snapshot.client,
|
||||
connected: gateway.snapshot.connected,
|
||||
devicePairSetupOpen: false,
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
pendingCount: 0,
|
||||
};
|
||||
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,
|
||||
devicePairSetupOpen: devicePairSetupState.devicePairSetupOpen,
|
||||
devicePairSetupLoading: devicePairSetupState.devicePairSetupLoading,
|
||||
devicePairSetupError: devicePairSetupState.devicePairSetupError,
|
||||
devicePairSetup: devicePairSetupState.devicePairSetup,
|
||||
devicePairPendingCount: devicePairSetupState.pendingCount,
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
};
|
||||
promptState.execApprovalExpired = publish;
|
||||
|
||||
const refreshDevicePairPendingCount = async () => {
|
||||
const client = gateway.snapshot.client;
|
||||
if (
|
||||
!client ||
|
||||
!gateway.snapshot.connected ||
|
||||
disposed ||
|
||||
!devicePairSetupState.devicePairSetupOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const generation = ++devicePairPendingCountGeneration;
|
||||
let result: { pending?: unknown };
|
||||
try {
|
||||
result = await client.request<{ pending?: unknown }>("device.pair.list", {});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
disposed ||
|
||||
generation !== devicePairPendingCountGeneration ||
|
||||
gateway.snapshot.client !== client ||
|
||||
!gateway.snapshot.connected ||
|
||||
!devicePairSetupState.devicePairSetupOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
devicePairSetupState.pendingCount = Array.isArray(result.pending) ? result.pending.length : 0;
|
||||
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;
|
||||
devicePairSetupState.client = next.client;
|
||||
devicePairSetupState.connected = next.connected;
|
||||
if (previousClient !== next.client || !next.connected) {
|
||||
devicePairPendingCountGeneration += 1;
|
||||
closeDevicePairSetupState(devicePairSetupState);
|
||||
devicePairSetupState.pendingCount = 0;
|
||||
}
|
||||
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 === "device.pair.requested" || event.event === "device.pair.resolved") {
|
||||
void refreshDevicePairPendingCount();
|
||||
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();
|
||||
}
|
||||
},
|
||||
async openDevicePairSetup() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
devicePairSetupState.pendingCount = 0;
|
||||
const setupOperation = openDevicePairSetupState(devicePairSetupState);
|
||||
// Pairing-list latency must not keep a ready setup code behind the loading state.
|
||||
void refreshDevicePairPendingCount();
|
||||
publish();
|
||||
await setupOperation;
|
||||
if (!disposed) {
|
||||
publish();
|
||||
}
|
||||
},
|
||||
async refreshDevicePairSetup() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const operation = refreshDevicePairSetupState(devicePairSetupState);
|
||||
publish();
|
||||
await operation;
|
||||
if (!disposed) {
|
||||
publish();
|
||||
}
|
||||
},
|
||||
closeDevicePairSetup() {
|
||||
devicePairPendingCountGeneration += 1;
|
||||
closeDevicePairSetupState(devicePairSetupState);
|
||||
devicePairSetupState.pendingCount = 0;
|
||||
publish();
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
updateRunGeneration += 1;
|
||||
devicePairPendingCountGeneration += 1;
|
||||
cancelUpdateVerification();
|
||||
closeDevicePairSetupState(devicePairSetupState);
|
||||
stopGateway();
|
||||
stopEvents();
|
||||
for (const timer of promptState.execApprovalExpiryTimers?.values() ?? []) {
|
||||
globalThis.clearTimeout(timer);
|
||||
}
|
||||
promptState.execApprovalExpiryTimers?.clear();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
38
ui/src/app/public-assets.test.ts
Normal file
38
ui/src/app/public-assets.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Control UI tests cover public assets behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { controlUiPublicAssetPath, inferControlUiPublicAssetPath } from "./public-assets.ts";
|
||||
|
||||
describe("controlUiPublicAssetPath", () => {
|
||||
it("resolves root-mounted public assets from the URL root", () => {
|
||||
expect(controlUiPublicAssetPath("favicon.svg", "")).toBe("/favicon.svg");
|
||||
expect(controlUiPublicAssetPath("manifest.webmanifest", null)).toBe("/manifest.webmanifest");
|
||||
});
|
||||
|
||||
it("resolves base-mounted public assets under the configured base path", () => {
|
||||
expect(controlUiPublicAssetPath("favicon.svg", "/ui")).toBe("/ui/favicon.svg");
|
||||
expect(controlUiPublicAssetPath("sw.js", "/apps/openclaw/")).toBe("/apps/openclaw/sw.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferControlUiPublicAssetPath", () => {
|
||||
it("uses the root for known nested routes without a configured base path", () => {
|
||||
expect(
|
||||
inferControlUiPublicAssetPath("manifest.webmanifest", { pathname: "/skills/workshop" }),
|
||||
).toBe("/manifest.webmanifest");
|
||||
});
|
||||
|
||||
it("infers base-mounted assets from nested routes", () => {
|
||||
expect(inferControlUiPublicAssetPath("sw.js", { pathname: "/openclaw/skills/workshop" })).toBe(
|
||||
"/openclaw/sw.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit base path over pathname inference", () => {
|
||||
expect(
|
||||
inferControlUiPublicAssetPath("apple-touch-icon.png", {
|
||||
basePath: "/control/",
|
||||
pathname: "/skills/workshop",
|
||||
}),
|
||||
).toBe("/control/apple-touch-icon.png");
|
||||
});
|
||||
});
|
||||
53
ui/src/app/public-assets.ts
Normal file
53
ui/src/app/public-assets.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Control UI module implements public assets behavior.
|
||||
import { inferBasePathFromPathname, normalizeBasePath } from "../app-route-paths.ts";
|
||||
|
||||
export type ControlUiPublicAsset =
|
||||
| "apple-touch-icon.png"
|
||||
| "favicon-32.png"
|
||||
| "favicon.ico"
|
||||
| "favicon.svg"
|
||||
| "manifest.webmanifest"
|
||||
| "sw.js";
|
||||
|
||||
type WindowWithControlUiBasePath = Window &
|
||||
typeof globalThis & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function controlUiPublicAssetPath(
|
||||
asset: ControlUiPublicAsset,
|
||||
basePath: string | null | undefined,
|
||||
): string {
|
||||
const base = normalizeBasePath(basePath ?? "");
|
||||
return base ? `${base}/${asset}` : `/${asset}`;
|
||||
}
|
||||
|
||||
export function inferControlUiPublicAssetPath(
|
||||
asset: ControlUiPublicAsset,
|
||||
params?: {
|
||||
basePath?: string | null;
|
||||
pathname?: string;
|
||||
},
|
||||
): string {
|
||||
const configured = params?.basePath ?? readConfiguredBasePath();
|
||||
const inferredBasePath =
|
||||
configured != null
|
||||
? configured
|
||||
: inferBasePathFromPathname(params?.pathname ?? currentPathname());
|
||||
return controlUiPublicAssetPath(asset, inferredBasePath);
|
||||
}
|
||||
|
||||
function readConfiguredBasePath(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const value = (window as WindowWithControlUiBasePath)["__OPENCLAW_CONTROL_UI_BASE_PATH__"];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function currentPathname(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "/";
|
||||
}
|
||||
return window.location.pathname;
|
||||
}
|
||||
324
ui/src/app/router-outlet.ts
Normal file
324
ui/src/app/router-outlet.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { AsyncDirective } from "lit/async-directive.js";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { directive } from "lit/directive.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
|
||||
const PENDING_UI_DELAY_MS = 1_000;
|
||||
|
||||
type RenderableModule<TData> = {
|
||||
render: (data: TData | undefined) => unknown;
|
||||
};
|
||||
|
||||
export type RouterOutletOptions<TLoadContext = unknown> = {
|
||||
retryContext?: TLoadContext;
|
||||
};
|
||||
|
||||
export type RouterOutletBoundaryOptions = {
|
||||
onNotFound?: () => void;
|
||||
};
|
||||
|
||||
export type RouterOutletSelection<
|
||||
TRouteId extends string = string,
|
||||
TModule = unknown,
|
||||
TData = unknown,
|
||||
> = {
|
||||
status: RouterState<TRouteId, TModule, TData>["status"];
|
||||
active: RouteMatch<TRouteId, TModule, TData> | undefined;
|
||||
pending: RouteMatch<TRouteId, TModule, TData> | undefined;
|
||||
showPending: boolean;
|
||||
};
|
||||
|
||||
export function selectRenderedRouteMatch<TRouteId extends string, TModule, TData>(
|
||||
active: RouteMatch<TRouteId, TModule, TData> | undefined,
|
||||
pending: RouteMatch<TRouteId, TModule, TData> | undefined,
|
||||
): RouteMatch<TRouteId, TModule, TData> | undefined {
|
||||
const coldPending =
|
||||
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
|
||||
return coldPending && active ? active : (pending ?? active);
|
||||
}
|
||||
|
||||
function selectRouterOutletState<TRouteId extends string, TModule, TData>(
|
||||
state: RouterState<TRouteId, TModule, TData>,
|
||||
): RouterOutletSelection<TRouteId, TModule, TData> {
|
||||
return {
|
||||
status: state.status,
|
||||
active: state.matches[0],
|
||||
pending: state.pendingMatches[0],
|
||||
showPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
function equalRouterOutletState(
|
||||
previous: RouterOutletSelection,
|
||||
next: RouterOutletSelection,
|
||||
): boolean {
|
||||
return (
|
||||
previous.status === next.status &&
|
||||
previous.active === next.active &&
|
||||
previous.pending === next.pending
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderableModule<TData>(module: unknown): module is RenderableModule<TData> {
|
||||
return (
|
||||
typeof module === "object" &&
|
||||
module !== null &&
|
||||
"render" in module &&
|
||||
typeof module.render === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function measureRoutedRender<T>(routeId: string, render: () => T): T {
|
||||
const startedAt = globalThis.performance?.now() ?? 0;
|
||||
const result = render();
|
||||
const durationMs = Math.round((globalThis.performance?.now() ?? startedAt) - startedAt);
|
||||
if (durationMs >= 16) {
|
||||
console.debug("[openclaw] routed render", { routeId, durationMs });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
return html`
|
||||
<section class="card lazy-view-state lazy-view-state--loading" role="status">
|
||||
<div class="card-title">${t("lazyView.loadingTitle")}</div>
|
||||
<div class="card-sub">${t("common.loading")}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
|
||||
router: Router<TRouteId, TLoadContext, TModule, TData>,
|
||||
retryContext: TLoadContext | undefined,
|
||||
error: unknown,
|
||||
routeId: TRouteId,
|
||||
render?: () => unknown,
|
||||
) {
|
||||
const routeError = error instanceof Error ? error.message : String(error);
|
||||
return html`
|
||||
${render?.() ?? nothing}
|
||||
<div class="callout danger" role="alert">
|
||||
<strong>${t("lazyView.errorTitle")}</strong>
|
||||
<div>${routeError}</div>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
@click=${() =>
|
||||
retryContext === undefined
|
||||
? undefined
|
||||
: void router.revalidate(retryContext, routeId).catch(() => undefined)}
|
||||
>
|
||||
${t("lazyView.retry")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderRouterOutlet<TRouteId extends string, TLoadContext, TModule, TData = unknown>(
|
||||
router: Router<TRouteId, TLoadContext, TModule, TData>,
|
||||
selection: RouterOutletSelection<TRouteId, TModule, TData>,
|
||||
options: RouterOutletOptions<TLoadContext> = {},
|
||||
): unknown {
|
||||
const pending = selection.pending;
|
||||
const renderedMatch = selectRenderedRouteMatch(selection.active, pending);
|
||||
if (renderedMatch?.status === "notFound") {
|
||||
return nothing;
|
||||
}
|
||||
if (renderedMatch?.status === "redirected") {
|
||||
return nothing;
|
||||
}
|
||||
if (!renderedMatch) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const routeId = renderedMatch.routeId;
|
||||
if (!renderedMatch?.module) {
|
||||
return renderedMatch.error
|
||||
? renderError<TRouteId, TLoadContext, TModule, TData>(
|
||||
router,
|
||||
options.retryContext,
|
||||
renderedMatch.error,
|
||||
routeId,
|
||||
)
|
||||
: selection.showPending
|
||||
? renderPending()
|
||||
: nothing;
|
||||
}
|
||||
const routeModule = renderedMatch.module;
|
||||
if (!isRenderableModule<TData>(routeModule)) {
|
||||
return renderedMatch.error
|
||||
? renderError<TRouteId, TLoadContext, TModule, TData>(
|
||||
router,
|
||||
options.retryContext,
|
||||
renderedMatch.error,
|
||||
routeId,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
const renderedPage = () =>
|
||||
measureRoutedRender(routeId, () => routeModule.render(renderedMatch.data));
|
||||
return renderedMatch.error
|
||||
? renderError<TRouteId, TLoadContext, TModule, TData>(
|
||||
router,
|
||||
options.retryContext,
|
||||
renderedMatch.error,
|
||||
routeId,
|
||||
renderedPage,
|
||||
)
|
||||
: renderedPage();
|
||||
}
|
||||
|
||||
class RouterOutletDirective extends AsyncDirective {
|
||||
private router?: Router<string, unknown, unknown, unknown>;
|
||||
private retryContext: unknown;
|
||||
private unsubscribe?: () => void;
|
||||
private boundaryOptions?: RouterOutletBoundaryOptions;
|
||||
private notFoundScheduled = false;
|
||||
private pendingMatchId?: string;
|
||||
private pendingTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private pendingSelection?: RouterOutletSelection;
|
||||
private showPending = false;
|
||||
|
||||
override render(
|
||||
router: unknown,
|
||||
retryContext: unknown,
|
||||
boundaryOptions: RouterOutletBoundaryOptions,
|
||||
) {
|
||||
const nextRouter = router as Router<string, unknown, unknown, unknown>;
|
||||
this.updateSubscription(nextRouter);
|
||||
this.router = nextRouter;
|
||||
this.retryContext = retryContext;
|
||||
this.boundaryOptions = boundaryOptions;
|
||||
return this.renderSelection(selectRouterOutletState(nextRouter.getState()));
|
||||
}
|
||||
|
||||
override disconnected() {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
this.clearPendingTimer();
|
||||
this.pendingSelection = undefined;
|
||||
this.boundaryOptions = undefined;
|
||||
this.retryContext = undefined;
|
||||
this.notFoundScheduled = false;
|
||||
}
|
||||
|
||||
override reconnected() {
|
||||
if (this.router) {
|
||||
this.updateSubscription(this.router);
|
||||
}
|
||||
}
|
||||
|
||||
private updateSubscription(router: Router<string, unknown, unknown, unknown>) {
|
||||
if (this.router === router && this.unsubscribe) {
|
||||
return;
|
||||
}
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = router.subscribeSelector(
|
||||
selectRouterOutletState,
|
||||
(selection) => {
|
||||
if (this.isConnected) {
|
||||
this.setValue(this.renderSelection(selection));
|
||||
}
|
||||
},
|
||||
equalRouterOutletState,
|
||||
);
|
||||
}
|
||||
|
||||
private renderSelection(selection: RouterOutletSelection) {
|
||||
this.pendingSelection = selection;
|
||||
const pending = selection.pending;
|
||||
const coldPending =
|
||||
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
|
||||
const needsPendingFallback = coldPending && !selection.active;
|
||||
if (!needsPendingFallback) {
|
||||
this.clearPendingTimer();
|
||||
this.pendingMatchId = undefined;
|
||||
this.showPending = false;
|
||||
} else if (this.pendingMatchId !== pending.id) {
|
||||
this.clearPendingTimer();
|
||||
this.pendingMatchId = pending.id;
|
||||
this.showPending = false;
|
||||
this.pendingTimer = globalThis.setTimeout(() => {
|
||||
this.pendingTimer = undefined;
|
||||
const pendingSelection = this.pendingSelection;
|
||||
if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) {
|
||||
return;
|
||||
}
|
||||
this.showPending = true;
|
||||
this.setValue(this.renderSelection(pendingSelection));
|
||||
}, PENDING_UI_DELAY_MS);
|
||||
}
|
||||
if (selection.status === "notFound") {
|
||||
if (!this.notFoundScheduled) {
|
||||
this.notFoundScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.notFoundScheduled = false;
|
||||
this.boundaryOptions?.onNotFound?.();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.notFoundScheduled = false;
|
||||
}
|
||||
const router = this.router;
|
||||
if (!router) {
|
||||
return nothing;
|
||||
}
|
||||
return renderRouterOutlet(
|
||||
router,
|
||||
{ ...selection, showPending: this.showPending },
|
||||
{
|
||||
retryContext: this.retryContext,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private clearPendingTimer() {
|
||||
if (this.pendingTimer !== undefined) {
|
||||
globalThis.clearTimeout(this.pendingTimer);
|
||||
this.pendingTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routerOutletDirective = directive(RouterOutletDirective);
|
||||
|
||||
export function routerOutlet<TRouteId extends string, TModule, TData, TContext>(
|
||||
router: Router<TRouteId, TContext, TModule, TData>,
|
||||
boundaryOptions: RouterOutletBoundaryOptions,
|
||||
options: RouterOutletOptions<TContext> = {},
|
||||
): unknown {
|
||||
return routerOutletDirective(router, options.retryContext, boundaryOptions);
|
||||
}
|
||||
|
||||
export class OpenClawRouterOutlet<
|
||||
TRouteId extends string = string,
|
||||
TLoadContext = unknown,
|
||||
TModule = unknown,
|
||||
TData = unknown,
|
||||
> extends LitElement {
|
||||
@property({ attribute: false }) router?: Router<TRouteId, TLoadContext, TModule, TData>;
|
||||
@property({ attribute: false }) retryContext?: TLoadContext;
|
||||
@property({ attribute: false }) onNotFound?: () => void;
|
||||
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.router) {
|
||||
return nothing;
|
||||
}
|
||||
return routerOutlet(
|
||||
this.router,
|
||||
{ onNotFound: this.onNotFound },
|
||||
{
|
||||
retryContext: this.retryContext,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-router-outlet")) {
|
||||
customElements.define("openclaw-router-outlet", OpenClawRouterOutlet);
|
||||
}
|
||||
117
ui/src/app/service-worker-cache.test.ts
Normal file
117
ui/src/app/service-worker-cache.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// Control UI tests cover service worker cache behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const serviceWorkerPath = path.join(here, "../../public/sw.js");
|
||||
|
||||
describe("Control UI service worker cache versioning", () => {
|
||||
it("registers the service worker with a build id and bounds prior build caches", () => {
|
||||
const mainSource = fs.readFileSync(path.join(here, "../main.ts"), "utf8");
|
||||
const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
||||
const viteConfigSource = fs.readFileSync(path.join(here, "../../vite.config.ts"), "utf8");
|
||||
|
||||
expect(mainSource).toContain('swUrl.searchParams.set("v"');
|
||||
expect(mainSource).toContain('updateViaCache: "none"');
|
||||
expect(mainSource).toContain('navigator.serviceWorker.addEventListener("message"');
|
||||
expect(mainSource).toContain("event.data.version !== currentControlUiBuildId");
|
||||
expect(serviceWorkerSource).toContain(
|
||||
'const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__"',
|
||||
);
|
||||
expect(serviceWorkerSource).toContain("URL_CACHE_VERSION");
|
||||
expect(serviceWorkerSource).toContain("CONTROL_CACHE_LIMIT = 3");
|
||||
expect(serviceWorkerSource).toContain("slice(-priorCacheLimit)");
|
||||
expect(serviceWorkerSource).toContain("caches.delete");
|
||||
expect(serviceWorkerSource).toContain("includeUncontrolled: true");
|
||||
expect(serviceWorkerSource).not.toContain(
|
||||
'postMessage({ type: "sw-updated", version: CACHE_VERSION },',
|
||||
);
|
||||
expect(viteConfigSource).toContain("source.replace(placeholder, JSON.stringify(buildId))");
|
||||
expect(serviceWorkerSource).not.toContain('const CACHE_NAME = "openclaw-control-v1"');
|
||||
});
|
||||
|
||||
it("broadcasts updated versions to uncontrolled window clients during activation", async () => {
|
||||
const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
||||
const windowClient = { postMessage: vi.fn() };
|
||||
const matchedClients = createDeferred<Array<typeof windowClient>>();
|
||||
const listeners = new Map<string, Array<(event: ActivateEventStub) => void>>();
|
||||
const cacheDelete = vi.fn(async () => true);
|
||||
const clients = {
|
||||
claim: vi.fn(async () => undefined),
|
||||
matchAll: vi.fn(() => matchedClients.promise),
|
||||
};
|
||||
const caches = {
|
||||
delete: cacheDelete,
|
||||
keys: vi.fn(async () => [
|
||||
"openclaw-control-oldest",
|
||||
"openclaw-control-older",
|
||||
"openclaw-control-previous",
|
||||
"openclaw-control-new-build",
|
||||
"other-cache",
|
||||
]),
|
||||
open: vi.fn(),
|
||||
};
|
||||
const serviceWorkerGlobal = {
|
||||
addEventListener(type: string, listener: (event: ActivateEventStub) => void) {
|
||||
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
|
||||
},
|
||||
clients,
|
||||
location: { href: "https://control.example/sw.js?v=new-build" },
|
||||
registration: { showNotification: vi.fn() },
|
||||
skipWaiting: vi.fn(),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
URL,
|
||||
caches,
|
||||
fetch: vi.fn(),
|
||||
self: serviceWorkerGlobal,
|
||||
});
|
||||
|
||||
new vm.Script(serviceWorkerSource, { filename: "ui/public/sw.js" }).runInContext(context);
|
||||
|
||||
const activateHandler = listeners.get("activate")?.[0];
|
||||
expect(activateHandler).toBeDefined();
|
||||
let activationPromise: Promise<unknown> | undefined;
|
||||
activateHandler?.({
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
activationPromise = promise;
|
||||
},
|
||||
});
|
||||
|
||||
let activationSettled = false;
|
||||
void activationPromise?.then(() => {
|
||||
activationSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(activationSettled).toBe(false);
|
||||
expect(windowClient.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
matchedClients.resolve([windowClient]);
|
||||
await activationPromise;
|
||||
|
||||
expect(clients.matchAll).toHaveBeenCalledWith({ type: "window", includeUncontrolled: true });
|
||||
expect(clients.claim).toHaveBeenCalled();
|
||||
expect(cacheDelete).toHaveBeenCalledWith("openclaw-control-oldest");
|
||||
expect(windowClient.postMessage).toHaveBeenCalledWith({
|
||||
type: "sw-updated",
|
||||
version: "new-build",
|
||||
});
|
||||
expect(windowClient.postMessage.mock.calls[0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
type ActivateEventStub = {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
};
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
685
ui/src/app/settings.node.test.ts
Normal file
685
ui/src/app/settings.node.test.ts
Normal file
@@ -0,0 +1,685 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createImportedCustomThemeFixture } from "../test-helpers/custom-theme.ts";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import {
|
||||
loadLocalUserIdentity,
|
||||
loadSettings,
|
||||
saveLocalUserIdentity,
|
||||
saveSettings,
|
||||
} from "./settings.ts";
|
||||
|
||||
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: params.protocol,
|
||||
host: params.host,
|
||||
hostname: params.host.replace(/:\d+$/, ""),
|
||||
pathname: params.pathname,
|
||||
} as Location);
|
||||
}
|
||||
|
||||
function setControlUiBasePath(value: string | undefined) {
|
||||
type TestWindow = Window & typeof globalThis & { [key: string]: unknown };
|
||||
if (typeof window === "undefined") {
|
||||
vi.stubGlobal(
|
||||
"window",
|
||||
value == null
|
||||
? ({} as TestWindow)
|
||||
: ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as unknown as TestWindow),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (value == null) {
|
||||
delete (window as TestWindow)["__OPENCLAW_CONTROL_UI_BASE_PATH__"];
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function expectedGatewayUrl(basePath: string): string {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}://${location.host}${basePath}`;
|
||||
}
|
||||
|
||||
describe("loadSettings default gateway URL derivation", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
vi.stubGlobal("sessionStorage", createStorageMock());
|
||||
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setControlUiBasePath(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setControlUiBasePath(undefined);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses configured base path and normalizes trailing slash", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/ignored/path",
|
||||
});
|
||||
setControlUiBasePath(" /openclaw/ ");
|
||||
|
||||
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/openclaw"));
|
||||
});
|
||||
|
||||
it("defaults chat auto-scroll to near-bottom", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
expect(loadSettings().chatAutoScroll).toBe("near-bottom");
|
||||
});
|
||||
|
||||
it("infers base path from nested pathname when configured base path is not set", () => {
|
||||
setTestLocation({
|
||||
protocol: "http:",
|
||||
host: "gateway.example:18789",
|
||||
pathname: "/apps/openclaw/chat",
|
||||
});
|
||||
|
||||
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw"));
|
||||
});
|
||||
|
||||
it("skips node sessionStorage accessors that warn without a storage file", () => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
setControlUiBasePath(undefined);
|
||||
const warningSpy = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.gatewayUrl).toBe(expectedGatewayUrl(""));
|
||||
expect(settings.token).toBe("");
|
||||
expect(
|
||||
warningSpy.mock.calls.some(
|
||||
([message]) => message === "`--localstorage-file` was provided without a valid path",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores and scrubs legacy persisted tokens", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
sessionStorage.setItem("openclaw.control.token.v1", "legacy-session-token");
|
||||
localStorage.setItem(
|
||||
"openclaw.control.settings.v1",
|
||||
JSON.stringify({
|
||||
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||
token: "persisted-token",
|
||||
sessionKey: "agent",
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.gatewayUrl).toBe("wss://gateway.example:8443/openclaw");
|
||||
expect(settings.token).toBe("");
|
||||
expect(settings.sessionKey).toBe("agent");
|
||||
const scopedKey = "openclaw.control.settings.v1:wss://gateway.example:8443/openclaw";
|
||||
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).toEqual({
|
||||
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatPersistCommentary: false,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
recentSessionsCollapsed: false,
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
sessionsByGateway: {
|
||||
"wss://gateway.example:8443/openclaw": {
|
||||
sessionKey: "agent",
|
||||
lastActiveSessionKey: "agent",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("loads the current-tab token from sessionStorage", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "session-token",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
});
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.gatewayUrl).toBe(gwUrl);
|
||||
expect(settings.token).toBe("session-token");
|
||||
});
|
||||
|
||||
it("does not reuse a session token for a different gatewayUrl", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
const otherUrl = "wss://other-gateway.example:8443";
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "gateway-a-token",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
|
||||
saveSettings({
|
||||
gatewayUrl: otherUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.gatewayUrl).toBe(gwUrl);
|
||||
expect(settings.token).toBe("gateway-a-token");
|
||||
});
|
||||
|
||||
it("does not persist gateway tokens when saving settings", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "memory-only-token",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
const settings = loadSettings();
|
||||
expect(settings.gatewayUrl).toBe(gwUrl);
|
||||
expect(settings.token).toBe("memory-only-token");
|
||||
|
||||
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
|
||||
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).toEqual({
|
||||
gatewayUrl: gwUrl,
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatPersistCommentary: false,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
recentSessionsCollapsed: false,
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
sessionsByGateway: {
|
||||
[gwUrl]: {
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sessionStorage.length).toBe(1);
|
||||
});
|
||||
|
||||
it("persists recent sessions collapse state across save and load", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
recentSessionsCollapsed: true,
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
});
|
||||
|
||||
expect(loadSettings().recentSessionsCollapsed).toBe(true);
|
||||
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
recentSessionsCollapsed: false,
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
});
|
||||
|
||||
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
|
||||
const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(persisted.recentSessionsCollapsed).toBe(false);
|
||||
expect(loadSettings().recentSessionsCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes persisted text scale to the nearest supported stop", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
localStorage.setItem(
|
||||
`openclaw.control.settings.v1:${gwUrl}`,
|
||||
JSON.stringify({
|
||||
gatewayUrl: gwUrl,
|
||||
textScale: 123,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadSettings().textScale).toBe(125);
|
||||
});
|
||||
|
||||
it("loads valid chat auto-scroll modes and normalizes invalid values", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
localStorage.setItem(
|
||||
`openclaw.control.settings.v1:${gwUrl}`,
|
||||
JSON.stringify({
|
||||
gatewayUrl: gwUrl,
|
||||
chatAutoScroll: "off",
|
||||
}),
|
||||
);
|
||||
expect(loadSettings().chatAutoScroll).toBe("off");
|
||||
|
||||
localStorage.setItem(
|
||||
`openclaw.control.settings.v1:${gwUrl}`,
|
||||
JSON.stringify({
|
||||
gatewayUrl: gwUrl,
|
||||
chatAutoScroll: "disabled",
|
||||
}),
|
||||
);
|
||||
expect(loadSettings().chatAutoScroll).toBe("near-bottom");
|
||||
});
|
||||
|
||||
it("clears the current-tab token when saving an empty token", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "stale-token",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
|
||||
expect(loadSettings().token).toBe("");
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("persists themeMode and navWidth alongside the selected theme", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "dash",
|
||||
themeMode: "light",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 320,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
|
||||
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
|
||||
const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(persisted.theme).toBe("dash");
|
||||
expect(persisted.themeMode).toBe("light");
|
||||
expect(persisted.navWidth).toBe(320);
|
||||
});
|
||||
|
||||
it("persists the browser-local custom theme payload when present", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
const customTheme = createImportedCustomThemeFixture();
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "custom",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
customTheme,
|
||||
});
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.theme).toBe("custom");
|
||||
expect(settings.customTheme?.label).toBe("Light Green");
|
||||
expect(settings.customTheme?.themeId).toBe("cmlhfpjhw000004l4f4ax3m7z");
|
||||
});
|
||||
|
||||
it("falls back to claw when persisted custom theme data is invalid", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
localStorage.setItem(
|
||||
`openclaw.control.settings.v1:${gwUrl}`,
|
||||
JSON.stringify({
|
||||
gatewayUrl: gwUrl,
|
||||
theme: "custom",
|
||||
themeMode: "dark",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
customTheme: {
|
||||
sourceUrl: "https://tweakcn.com/themes/broken",
|
||||
themeId: "broken",
|
||||
label: "Broken",
|
||||
importedAt: "2026-04-22T00:00:00.000Z",
|
||||
light: {},
|
||||
dark: {},
|
||||
},
|
||||
sessionsByGateway: {
|
||||
[gwUrl]: {
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.theme).toBe("claw");
|
||||
expect(settings.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("scopes persisted session selection per gateway", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway-a.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "agent:test_old:main",
|
||||
lastActiveSessionKey: "agent:test_old:main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.gatewayUrl).toBe(gwUrl);
|
||||
expect(settings.sessionKey).toBe("agent:test_old:main");
|
||||
expect(settings.lastActiveSessionKey).toBe("agent:test_old:main");
|
||||
});
|
||||
|
||||
it("caps persisted session scopes to the most recent gateways", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
const gwUrl = expectedGatewayUrl("");
|
||||
const scopedKey = `openclaw.control.settings.v1:wss://gateway.example:8443`;
|
||||
|
||||
// Pre-seed sessionsByGateway with 11 stale gateway entries so the next
|
||||
// saveSettings call pushes the total to 12 and triggers the cap (10).
|
||||
const staleEntries: Record<string, { sessionKey: string; lastActiveSessionKey: string }> = {};
|
||||
for (let i = 0; i < 11; i += 1) {
|
||||
staleEntries[`wss://stale-${i}.example:8443`] = {
|
||||
sessionKey: `agent:stale_${i}:main`,
|
||||
lastActiveSessionKey: `agent:stale_${i}:main`,
|
||||
};
|
||||
}
|
||||
localStorage.setItem(scopedKey, JSON.stringify({ sessionsByGateway: staleEntries }));
|
||||
|
||||
saveSettings({
|
||||
gatewayUrl: gwUrl,
|
||||
token: "",
|
||||
sessionKey: "agent:current:main",
|
||||
lastActiveSessionKey: "agent:current:main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}");
|
||||
|
||||
const scopedSessions = persisted.sessionsByGateway as Record<
|
||||
string,
|
||||
{ sessionKey: string; lastActiveSessionKey: string }
|
||||
>;
|
||||
expect(scopedSessions["wss://gateway.example:8443"]).toEqual({
|
||||
sessionKey: "agent:current:main",
|
||||
lastActiveSessionKey: "agent:current:main",
|
||||
});
|
||||
expect(Object.keys(scopedSessions)).toEqual([
|
||||
"wss://stale-2.example:8443",
|
||||
"wss://stale-3.example:8443",
|
||||
"wss://stale-4.example:8443",
|
||||
"wss://stale-5.example:8443",
|
||||
"wss://stale-6.example:8443",
|
||||
"wss://stale-7.example:8443",
|
||||
"wss://stale-8.example:8443",
|
||||
"wss://stale-9.example:8443",
|
||||
"wss://stale-10.example:8443",
|
||||
"wss://gateway.example:8443",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists local user identity separately from gateway settings", () => {
|
||||
setTestLocation({
|
||||
protocol: "https:",
|
||||
host: "gateway.example:8443",
|
||||
pathname: "/",
|
||||
});
|
||||
|
||||
saveLocalUserIdentity({ name: "Buns", avatar: "🦞" });
|
||||
|
||||
expect(loadLocalUserIdentity()).toEqual({
|
||||
name: "Buns",
|
||||
avatar: "🦞",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("openclaw.control.user.v1") ?? "{}")).toEqual({
|
||||
name: "Buns",
|
||||
avatar: "🦞",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid local user identity values on load", () => {
|
||||
localStorage.setItem(
|
||||
"openclaw.control.user.v1",
|
||||
JSON.stringify({
|
||||
name: " ",
|
||||
avatar: "https://example.com/avatar.png",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadLocalUserIdentity()).toEqual({
|
||||
name: null,
|
||||
avatar: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the persisted local user identity when cleared", () => {
|
||||
saveLocalUserIdentity({ name: "Buns", avatar: "data:image/png;base64,AAA" });
|
||||
saveLocalUserIdentity({ name: null, avatar: null });
|
||||
|
||||
expect(loadLocalUserIdentity()).toEqual({
|
||||
name: null,
|
||||
avatar: null,
|
||||
});
|
||||
expect(localStorage.getItem("openclaw.control.user.v1")).toBeNull();
|
||||
});
|
||||
});
|
||||
645
ui/src/app/settings.ts
Normal file
645
ui/src/app/settings.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
// Control UI module implements storage behavior.
|
||||
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 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)}`;
|
||||
}
|
||||
|
||||
type ScopedSessionSelection = {
|
||||
sessionKey: string;
|
||||
lastActiveSessionKey: string;
|
||||
};
|
||||
|
||||
type PersistedUiSettings = Omit<UiSettings, "token" | "sessionKey" | "lastActiveSessionKey"> & {
|
||||
token?: never;
|
||||
sessionKey?: string;
|
||||
lastActiveSessionKey?: string;
|
||||
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 { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
|
||||
import {
|
||||
hasLocalUserIdentity,
|
||||
normalizeLocalUserIdentity,
|
||||
type LocalUserIdentity,
|
||||
} from "./user-identity.ts";
|
||||
|
||||
export const BORDER_RADIUS_STOPS = [0, 25, 50, 75, 100] as const;
|
||||
export type BorderRadiusStop = (typeof BORDER_RADIUS_STOPS)[number];
|
||||
|
||||
export const TEXT_SCALE_STOPS = [90, 100, 110, 125, 140] as const;
|
||||
export type TextScaleStop = (typeof TEXT_SCALE_STOPS)[number];
|
||||
|
||||
export const CHAT_AUTO_SCROLL_MODES = ["always", "near-bottom", "off"] as const;
|
||||
export type ChatAutoScrollMode = (typeof CHAT_AUTO_SCROLL_MODES)[number];
|
||||
|
||||
export function normalizeChatAutoScrollMode(value: unknown): ChatAutoScrollMode {
|
||||
return CHAT_AUTO_SCROLL_MODES.includes(value as ChatAutoScrollMode)
|
||||
? (value as ChatAutoScrollMode)
|
||||
: "near-bottom";
|
||||
}
|
||||
|
||||
function snapBorderRadius(value: number): BorderRadiusStop {
|
||||
let best: BorderRadiusStop = BORDER_RADIUS_STOPS[0];
|
||||
let bestDist = Math.abs(value - best);
|
||||
for (const stop of BORDER_RADIUS_STOPS) {
|
||||
const dist = Math.abs(value - stop);
|
||||
if (dist < bestDist) {
|
||||
best = stop;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function normalizeTextScale(value: unknown, fallback: TextScaleStop = 100): TextScaleStop {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
let best: TextScaleStop = TEXT_SCALE_STOPS[0];
|
||||
let bestDist = Math.abs(value - best);
|
||||
for (const stop of TEXT_SCALE_STOPS) {
|
||||
const dist = Math.abs(value - stop);
|
||||
if (dist < bestDist) {
|
||||
best = stop;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export type UiSettings = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
sessionKey: string;
|
||||
lastActiveSessionKey: string;
|
||||
theme: ThemeName;
|
||||
themeMode: ThemeMode;
|
||||
chatShowThinking: boolean;
|
||||
chatShowToolCalls: boolean;
|
||||
chatPersistCommentary?: boolean;
|
||||
chatAutoScroll?: ChatAutoScrollMode;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
recentSessionsCollapsed?: boolean; // Collapse recent sessions list in sidebar
|
||||
borderRadius: number; // Corner roundness (0–100, default 50)
|
||||
textScale?: TextScaleStop; // Browser-local text scale percentage
|
||||
customTheme?: ImportedCustomTheme;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
return Boolean(document.querySelector('script[src*="/@vite/client"]'));
|
||||
}
|
||||
|
||||
function formatHostWithPort(hostname: string, port: string): string {
|
||||
const normalizedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
return `${normalizedHost}:${port}`;
|
||||
}
|
||||
|
||||
function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const configured =
|
||||
typeof window !== "undefined" &&
|
||||
normalizeOptionalString(
|
||||
(window as WindowWithControlUiBasePath)["__OPENCLAW_CONTROL_UI_BASE_PATH__"],
|
||||
);
|
||||
const basePath = configured
|
||||
? normalizeBasePath(configured)
|
||||
: inferBasePathFromPathname(location.pathname);
|
||||
const pageUrl = `${proto}://${location.host}${basePath}`;
|
||||
if (!isViteDevPage()) {
|
||||
return { pageUrl, effectiveUrl: pageUrl };
|
||||
}
|
||||
const effectiveUrl = `${proto}://${formatHostWithPort(location.hostname, "18789")}`;
|
||||
return { pageUrl, effectiveUrl };
|
||||
}
|
||||
|
||||
function getSessionStorage(): Storage | null {
|
||||
return getSafeSessionStorage();
|
||||
}
|
||||
|
||||
function normalizeGatewayTokenScope(gatewayUrl: string): string {
|
||||
const trimmed = normalizeOptionalString(gatewayUrl) ?? "";
|
||||
if (!trimmed) {
|
||||
return "default";
|
||||
}
|
||||
try {
|
||||
const base =
|
||||
typeof location !== "undefined"
|
||||
? `${location.protocol}//${location.host}${location.pathname || "/"}`
|
||||
: undefined;
|
||||
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
|
||||
const pathname =
|
||||
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
|
||||
return `${parsed.protocol}//${parsed.host}${pathname}`;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenSessionKeyForGateway(gatewayUrl: string): string {
|
||||
return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
|
||||
}
|
||||
|
||||
function resolveScopedSessionSelection(
|
||||
gatewayUrl: string,
|
||||
parsed: PersistedUiSettings,
|
||||
fallback: ScopedSessionSelection,
|
||||
): ScopedSessionSelection {
|
||||
const scope = normalizeGatewayTokenScope(gatewayUrl);
|
||||
const scoped = parsed.sessionsByGateway?.[scope];
|
||||
const scopedSessionKey = normalizeOptionalString(scoped?.sessionKey);
|
||||
const scopedLastActiveSessionKey = normalizeOptionalString(scoped?.lastActiveSessionKey);
|
||||
if (scopedSessionKey && scopedLastActiveSessionKey) {
|
||||
return {
|
||||
sessionKey: scopedSessionKey,
|
||||
lastActiveSessionKey: scopedLastActiveSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
const legacySessionKey = normalizeOptionalString(parsed.sessionKey) ?? fallback.sessionKey;
|
||||
const legacyLastActiveSessionKey =
|
||||
normalizeOptionalString(parsed.lastActiveSessionKey) ??
|
||||
legacySessionKey ??
|
||||
fallback.lastActiveSessionKey;
|
||||
|
||||
return {
|
||||
sessionKey: legacySessionKey,
|
||||
lastActiveSessionKey: legacyLastActiveSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
if (!storage) {
|
||||
return "";
|
||||
}
|
||||
storage.removeItem(LEGACY_TOKEN_SESSION_KEY);
|
||||
const token = storage.getItem(tokenSessionKeyForGateway(gatewayUrl));
|
||||
return normalizeOptionalString(token) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayTokenForUrlEdit(
|
||||
currentGatewayUrl: string,
|
||||
nextGatewayUrl: string,
|
||||
currentToken: string,
|
||||
): string {
|
||||
if (
|
||||
normalizeGatewayTokenScope(currentGatewayUrl) === normalizeGatewayTokenScope(nextGatewayUrl)
|
||||
) {
|
||||
return currentToken;
|
||||
}
|
||||
// Gateway tokens stay session-scoped across endpoint edits.
|
||||
// Durable settings may contain scrubbed legacy tokens, but must not restore them here.
|
||||
return loadSessionToken(nextGatewayUrl);
|
||||
}
|
||||
|
||||
function persistSessionToken(gatewayUrl: string, token: string) {
|
||||
try {
|
||||
const storage = getSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
storage.removeItem(LEGACY_TOKEN_SESSION_KEY);
|
||||
const key = tokenSessionKeyForGateway(gatewayUrl);
|
||||
const normalized = normalizeOptionalString(token) ?? "";
|
||||
if (normalized) {
|
||||
storage.setItem(key, normalized);
|
||||
return;
|
||||
}
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
|
||||
const storage = getSafeLocalStorage();
|
||||
|
||||
const defaults: UiSettings = {
|
||||
gatewayUrl: defaultUrl,
|
||||
token: loadSessionToken(defaultUrl),
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
chatPersistCommentary: false,
|
||||
chatAutoScroll: "near-bottom",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
recentSessionsCollapsed: false,
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
};
|
||||
|
||||
try {
|
||||
// First check for legacy key (no scope), then check for scoped key
|
||||
const scopedKey = settingsKeyForGateway(defaults.gatewayUrl);
|
||||
const raw =
|
||||
storage?.getItem(scopedKey) ??
|
||||
storage?.getItem(SETTINGS_KEY_PREFIX + "default") ??
|
||||
storage?.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return defaults;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedUiSettings;
|
||||
const parsedGatewayUrl = normalizeOptionalString(parsed.gatewayUrl) ?? defaults.gatewayUrl;
|
||||
const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
|
||||
const scopedSessionSelection = resolveScopedSessionSelection(gatewayUrl, parsed, defaults);
|
||||
const customTheme = parseImportedCustomTheme((parsed as { customTheme?: unknown }).customTheme);
|
||||
const { theme, mode } = parseThemeSelection(
|
||||
(parsed as { theme?: unknown }).theme,
|
||||
(parsed as { themeMode?: unknown }).themeMode,
|
||||
);
|
||||
const settings: UiSettings = {
|
||||
gatewayUrl,
|
||||
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
|
||||
token: loadSessionToken(gatewayUrl),
|
||||
sessionKey: scopedSessionSelection.sessionKey,
|
||||
lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey,
|
||||
theme: theme === "custom" && !customTheme ? "claw" : theme,
|
||||
themeMode: mode,
|
||||
chatShowThinking:
|
||||
typeof parsed.chatShowThinking === "boolean"
|
||||
? parsed.chatShowThinking
|
||||
: defaults.chatShowThinking,
|
||||
chatShowToolCalls:
|
||||
typeof parsed.chatShowToolCalls === "boolean"
|
||||
? parsed.chatShowToolCalls
|
||||
: defaults.chatShowToolCalls,
|
||||
chatPersistCommentary:
|
||||
typeof parsed.chatPersistCommentary === "boolean"
|
||||
? parsed.chatPersistCommentary
|
||||
: defaults.chatPersistCommentary,
|
||||
chatAutoScroll: normalizeChatAutoScrollMode(parsed.chatAutoScroll),
|
||||
splitRatio:
|
||||
typeof parsed.splitRatio === "number" &&
|
||||
parsed.splitRatio >= 0.4 &&
|
||||
parsed.splitRatio <= 0.7
|
||||
? parsed.splitRatio
|
||||
: defaults.splitRatio,
|
||||
navCollapsed:
|
||||
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
|
||||
navWidth:
|
||||
typeof parsed.navWidth === "number" && parsed.navWidth >= 200 && parsed.navWidth <= 400
|
||||
? parsed.navWidth
|
||||
: defaults.navWidth,
|
||||
navGroupsCollapsed:
|
||||
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
|
||||
? parsed.navGroupsCollapsed
|
||||
: defaults.navGroupsCollapsed,
|
||||
recentSessionsCollapsed:
|
||||
typeof parsed.recentSessionsCollapsed === "boolean"
|
||||
? parsed.recentSessionsCollapsed
|
||||
: defaults.recentSessionsCollapsed,
|
||||
borderRadius:
|
||||
typeof parsed.borderRadius === "number" &&
|
||||
parsed.borderRadius >= 0 &&
|
||||
parsed.borderRadius <= 100
|
||||
? snapBorderRadius(parsed.borderRadius)
|
||||
: defaults.borderRadius,
|
||||
textScale: normalizeTextScale(parsed.textScale, defaults.textScale),
|
||||
customTheme: customTheme ?? undefined,
|
||||
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
|
||||
};
|
||||
if ("token" in parsed) {
|
||||
persistSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const raw = storage?.getItem(LOCAL_USER_IDENTITY_KEY);
|
||||
if (!raw) {
|
||||
return normalizeLocalUserIdentity();
|
||||
}
|
||||
return normalizeLocalUserIdentity(JSON.parse(raw) as Partial<LocalUserIdentity>);
|
||||
} catch {
|
||||
return normalizeLocalUserIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocalUserIdentity(next: LocalUserIdentity) {
|
||||
const storage = getSafeLocalStorage();
|
||||
const normalized = normalizeLocalUserIdentity(next);
|
||||
try {
|
||||
if (!hasLocalUserIdentity(normalized)) {
|
||||
storage?.removeItem(LOCAL_USER_IDENTITY_KEY);
|
||||
return;
|
||||
}
|
||||
storage?.setItem(LOCAL_USER_IDENTITY_KEY, JSON.stringify(normalized));
|
||||
} 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();
|
||||
const scope = normalizeGatewayTokenScope(next.gatewayUrl);
|
||||
const scopedKey = settingsKeyForGateway(next.gatewayUrl);
|
||||
let existingSessionsByGateway: Record<string, ScopedSessionSelection> = {};
|
||||
try {
|
||||
// Try to migrate from legacy key or other scopes
|
||||
const raw =
|
||||
storage?.getItem(scopedKey) ??
|
||||
storage?.getItem(SETTINGS_KEY_PREFIX + "default") ??
|
||||
storage?.getItem("openclaw.control.settings.v1");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as PersistedUiSettings;
|
||||
if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") {
|
||||
existingSessionsByGateway = parsed.sessionsByGateway;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
const sessionsByGateway = Object.fromEntries(
|
||||
[
|
||||
...Object.entries(existingSessionsByGateway).filter(([key]) => key !== scope),
|
||||
[
|
||||
scope,
|
||||
{
|
||||
sessionKey: next.sessionKey,
|
||||
lastActiveSessionKey: next.lastActiveSessionKey,
|
||||
},
|
||||
],
|
||||
].slice(-MAX_SCOPED_SESSION_ENTRIES),
|
||||
);
|
||||
const persisted: PersistedUiSettings = {
|
||||
gatewayUrl: next.gatewayUrl,
|
||||
theme: next.theme,
|
||||
themeMode: next.themeMode,
|
||||
chatShowThinking: next.chatShowThinking,
|
||||
chatShowToolCalls: next.chatShowToolCalls,
|
||||
chatPersistCommentary: next.chatPersistCommentary ?? false,
|
||||
chatAutoScroll: normalizeChatAutoScrollMode(next.chatAutoScroll),
|
||||
splitRatio: next.splitRatio,
|
||||
navCollapsed: next.navCollapsed,
|
||||
navWidth: next.navWidth,
|
||||
navGroupsCollapsed: next.navGroupsCollapsed,
|
||||
recentSessionsCollapsed: next.recentSessionsCollapsed ?? false,
|
||||
borderRadius: next.borderRadius,
|
||||
textScale: normalizeTextScale(next.textScale),
|
||||
...(next.customTheme ? { customTheme: next.customTheme } : {}),
|
||||
sessionsByGateway,
|
||||
...(next.locale ? { locale: next.locale } : {}),
|
||||
};
|
||||
const serialized = JSON.stringify(persisted);
|
||||
try {
|
||||
storage?.setItem(scopedKey, serialized);
|
||||
storage?.setItem(LEGACY_SETTINGS_KEY, serialized);
|
||||
} catch {
|
||||
// best-effort — quota exceeded or security restrictions should not
|
||||
// prevent in-memory settings and visual updates from being applied
|
||||
}
|
||||
}
|
||||
47
ui/src/app/theme-transition.ts
Normal file
47
ui/src/app/theme-transition.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Control UI module implements theme transition behavior.
|
||||
import type { ResolvedTheme } from "./theme.ts";
|
||||
|
||||
export type ThemeTransitionContext = {
|
||||
element?: HTMLElement | null;
|
||||
pointerClientX?: number;
|
||||
pointerClientY?: number;
|
||||
};
|
||||
|
||||
export type ThemeTransitionOptions = {
|
||||
nextTheme: ResolvedTheme;
|
||||
applyTheme: () => void;
|
||||
// Retained so callers from stacked slices can keep passing pointer metadata
|
||||
// while theme switching remains an immediate, non-animated update here.
|
||||
context?: ThemeTransitionContext;
|
||||
currentTheme?: ResolvedTheme | null;
|
||||
};
|
||||
|
||||
const cleanupThemeTransition = (root: HTMLElement) => {
|
||||
root.classList.remove("theme-transition");
|
||||
root.style.removeProperty("--theme-switch-x");
|
||||
root.style.removeProperty("--theme-switch-y");
|
||||
};
|
||||
|
||||
export const startThemeTransition = ({
|
||||
nextTheme,
|
||||
applyTheme,
|
||||
currentTheme,
|
||||
}: ThemeTransitionOptions) => {
|
||||
if (currentTheme === nextTheme) {
|
||||
// Even when the resolved palette is unchanged (e.g. system->dark on a dark OS),
|
||||
// we still need to persist the user's explicit selection immediately.
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
const documentReference = globalThis.document ?? null;
|
||||
if (!documentReference) {
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
const root = documentReference.documentElement;
|
||||
// Theme updates should be visible immediately on click with no transition lag.
|
||||
applyTheme();
|
||||
cleanupThemeTransition(root);
|
||||
};
|
||||
37
ui/src/app/theme.test.ts
Normal file
37
ui/src/app/theme.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Control UI tests cover theme behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseThemeSelection, resolveSystemTheme, resolveTheme } from "./theme.ts";
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
it("resolves named theme families when mode is provided", () => {
|
||||
expect(resolveTheme("knot", "dark")).toBe("openknot");
|
||||
expect(resolveTheme("dash", "light")).toBe("dash-light");
|
||||
});
|
||||
|
||||
it("uses system preference when mode is system", () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
|
||||
expect(resolveTheme("knot", "system")).toBe("openknot-light");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemTheme", () => {
|
||||
it("mirrors the active preferred color scheme", () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
|
||||
expect(resolveSystemTheme()).toBe("light");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseThemeSelection", () => {
|
||||
it("maps legacy stored values onto theme + mode", () => {
|
||||
expect(parseThemeSelection("system", undefined)).toEqual({
|
||||
theme: "claw",
|
||||
mode: "system",
|
||||
});
|
||||
expect(parseThemeSelection("fieldmanual", undefined)).toEqual({
|
||||
theme: "dash",
|
||||
mode: "dark",
|
||||
});
|
||||
});
|
||||
});
|
||||
80
ui/src/app/theme.ts
Normal file
80
ui/src/app/theme.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Control UI module implements theme behavior.
|
||||
export type ThemeName = "claw" | "knot" | "dash" | "custom";
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
export type ResolvedTheme =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "openknot"
|
||||
| "openknot-light"
|
||||
| "dash"
|
||||
| "dash-light"
|
||||
| "custom"
|
||||
| "custom-light";
|
||||
|
||||
export const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash", "custom"]);
|
||||
const VALID_THEME_MODES = new Set<ThemeMode>(["system", "light", "dark"]);
|
||||
|
||||
type ThemeSelection = { theme: ThemeName; mode: ThemeMode };
|
||||
|
||||
const LEGACY_MAP: Record<string, ThemeSelection> = {
|
||||
defaultTheme: { theme: "claw", mode: "dark" },
|
||||
docsTheme: { theme: "claw", mode: "light" },
|
||||
lightTheme: { theme: "knot", mode: "dark" },
|
||||
landingTheme: { theme: "knot", mode: "dark" },
|
||||
newTheme: { theme: "knot", mode: "dark" },
|
||||
dark: { theme: "claw", mode: "dark" },
|
||||
light: { theme: "claw", mode: "light" },
|
||||
openknot: { theme: "knot", mode: "dark" },
|
||||
fieldmanual: { theme: "dash", mode: "dark" },
|
||||
clawdash: { theme: "dash", mode: "light" },
|
||||
system: { theme: "claw", mode: "system" },
|
||||
};
|
||||
|
||||
function prefersLightScheme(): boolean {
|
||||
if (typeof globalThis.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
return globalThis.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
}
|
||||
|
||||
export function resolveSystemTheme(): ResolvedTheme {
|
||||
return prefersLightScheme() ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function parseThemeSelection(
|
||||
themeRaw: unknown,
|
||||
modeRaw: unknown,
|
||||
): { theme: ThemeName; mode: ThemeMode } {
|
||||
const theme = typeof themeRaw === "string" ? themeRaw : "";
|
||||
const mode = typeof modeRaw === "string" ? modeRaw : "";
|
||||
|
||||
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
|
||||
? (theme as ThemeName)
|
||||
: (LEGACY_MAP[theme]?.theme ?? "claw");
|
||||
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
|
||||
? (mode as ThemeMode)
|
||||
: (LEGACY_MAP[theme]?.mode ?? "system");
|
||||
|
||||
return { theme: normalizedTheme, mode: normalizedMode };
|
||||
}
|
||||
|
||||
function resolveMode(mode: ThemeMode): "light" | "dark" {
|
||||
if (mode === "system") {
|
||||
return prefersLightScheme() ? "light" : "dark";
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||
const resolvedMode = resolveMode(mode);
|
||||
if (theme === "claw") {
|
||||
return resolvedMode === "light" ? "light" : "dark";
|
||||
}
|
||||
if (theme === "knot") {
|
||||
return resolvedMode === "light" ? "openknot-light" : "openknot";
|
||||
}
|
||||
if (theme === "dash") {
|
||||
return resolvedMode === "light" ? "dash-light" : "dash";
|
||||
}
|
||||
return resolvedMode === "light" ? "custom-light" : "custom";
|
||||
}
|
||||
29
ui/src/app/user-identity.test.ts
Normal file
29
ui/src/app/user-identity.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Control UI tests cover user identity behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeLocalUserIdentity,
|
||||
resolveLocalUserAvatarText,
|
||||
resolveLocalUserAvatarUrl,
|
||||
resolveLocalUserName,
|
||||
} from "./user-identity.ts";
|
||||
|
||||
describe("local user identity helpers", () => {
|
||||
it("normalizes the display name with the same fallback used by chat", () => {
|
||||
expect(resolveLocalUserName({ name: " Val " })).toBe("Val");
|
||||
expect(resolveLocalUserName({ name: " " })).toBe("You");
|
||||
});
|
||||
|
||||
it("resolves renderable local avatar URLs through the shared chat path", () => {
|
||||
expect(resolveLocalUserAvatarUrl({ avatar: "/avatar/user" })).toBe("/avatar/user");
|
||||
expect(resolveLocalUserAvatarUrl({ avatar: "data:image/png;base64,AAA" })).toBe(
|
||||
"data:image/png;base64,AAA",
|
||||
);
|
||||
expect(resolveLocalUserAvatarUrl({ avatar: "https://example.com/avatar.png" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps text avatars only when no image avatar survives normalization", () => {
|
||||
expect(resolveLocalUserAvatarText({ avatar: "🦞" })).toBe("🦞");
|
||||
expect(resolveLocalUserAvatarText({ avatar: "/avatar/user" })).toBeNull();
|
||||
expect(normalizeLocalUserIdentity({ avatar: "line 1\nline 2" }).avatar).toBeNull();
|
||||
});
|
||||
});
|
||||
73
ui/src/app/user-identity.ts
Normal file
73
ui/src/app/user-identity.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Control UI module implements user identity behavior.
|
||||
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
|
||||
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;
|
||||
const MAX_LOCAL_USER_IMAGE_AVATAR = 2_000_000;
|
||||
|
||||
export type LocalUserIdentity = {
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
function normalizeAvatar(value?: string | null): string | null {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (isRenderableControlUiAvatarUrl(trimmed)) {
|
||||
return trimmed.length <= MAX_LOCAL_USER_IMAGE_AVATAR ? trimmed : null;
|
||||
}
|
||||
if (/[\r\n]/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.length <= MAX_LOCAL_USER_TEXT_AVATAR ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeLocalUserIdentity(
|
||||
input?: Partial<LocalUserIdentity> | null,
|
||||
): LocalUserIdentity {
|
||||
return {
|
||||
name:
|
||||
coerceIdentityValue(
|
||||
typeof input?.name === "string" ? input.name : undefined,
|
||||
MAX_LOCAL_USER_NAME,
|
||||
) ?? null,
|
||||
avatar: normalizeAvatar(input?.avatar),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasLocalUserIdentity(identity: LocalUserIdentity): boolean {
|
||||
return Boolean(identity.name || identity.avatar);
|
||||
}
|
||||
|
||||
export function resolveLocalUserName(
|
||||
input?: Partial<LocalUserIdentity> | null,
|
||||
fallback = "You",
|
||||
): string {
|
||||
return normalizeLocalUserIdentity(input).name ?? fallback;
|
||||
}
|
||||
|
||||
export function resolveLocalUserAvatarUrl(
|
||||
input?: Partial<LocalUserIdentity> | null,
|
||||
): string | null {
|
||||
const normalized = normalizeLocalUserIdentity(input);
|
||||
return resolveChatAvatarRenderUrl(normalized.avatar, {
|
||||
identity: {
|
||||
avatar: normalized.avatar ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveLocalUserAvatarText(
|
||||
input?: Partial<LocalUserIdentity> | null,
|
||||
): string | null {
|
||||
const normalized = normalizeLocalUserIdentity(input);
|
||||
const avatar = normalizeOptionalString(normalized.avatar);
|
||||
if (!avatar) {
|
||||
return null;
|
||||
}
|
||||
return resolveLocalUserAvatarUrl(normalized) ? null : avatar;
|
||||
}
|
||||
91
ui/src/app/vite-config.node.test.ts
Normal file
91
ui/src/app/vite-config.node.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBrowserOnlySharedModuleAliases,
|
||||
resolveExternalPackageAliasesForVite,
|
||||
resolveSourcePackageAliasesForVite,
|
||||
resolveTsconfigPathAliasesForVite,
|
||||
} from "../../vite.config.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
type ResolveIdHandler = (
|
||||
this: never,
|
||||
source: string,
|
||||
importer: string | undefined,
|
||||
options: { custom: Record<string, never>; isEntry: boolean; ssr: boolean },
|
||||
) => unknown;
|
||||
|
||||
function findStringAlias(key: string) {
|
||||
return resolveTsconfigPathAliasesForVite().find((alias) => alias.find === key);
|
||||
}
|
||||
|
||||
describe("Control UI Vite config", () => {
|
||||
it("resolves root tsconfig package aliases for source imports", () => {
|
||||
expect(findStringAlias("@openclaw/net-policy/ip")?.replacement).toBe(
|
||||
path.join(repoRoot, "packages/net-policy/src/ip.ts"),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Control UI dev-server source aliases for internal packages", () => {
|
||||
const aliases = resolveSourcePackageAliasesForVite();
|
||||
expect(
|
||||
aliases.find((alias) => alias.find === "@openclaw/normalization-core/string-coerce"),
|
||||
)?.toEqual({
|
||||
find: "@openclaw/normalization-core/string-coerce",
|
||||
replacement: path.join(repoRoot, "packages/normalization-core/src/string-coerce.ts"),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves published OpenClaw packages before the broad plugin alias", () => {
|
||||
const aliases = resolveExternalPackageAliasesForVite();
|
||||
expect(aliases.find((alias) => alias.find === "@openclaw/libterminal/browser")).toEqual({
|
||||
find: "@openclaw/libterminal/browser",
|
||||
replacement: path.join(repoRoot, "node_modules/@openclaw/libterminal/dist/browser.js"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps specific tsconfig aliases ahead of broad package aliases", () => {
|
||||
const aliases = resolveTsconfigPathAliasesForVite();
|
||||
const netPolicyIpIndex = aliases.findIndex((alias) => alias.find === "@openclaw/net-policy/ip");
|
||||
const netPolicyPackageIndex = aliases.findIndex(
|
||||
(alias) => alias.find === "@openclaw/net-policy",
|
||||
);
|
||||
const netPolicyWildcardIndex = aliases.findIndex(
|
||||
(alias) =>
|
||||
alias.find instanceof RegExp && alias.replacement.includes("packages/net-policy/src/$1"),
|
||||
);
|
||||
const broadOpenClawWildcardIndex = aliases.findIndex(
|
||||
(alias) => alias.find instanceof RegExp && alias.replacement.includes("extensions/$1"),
|
||||
);
|
||||
|
||||
expect(netPolicyIpIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(netPolicyWildcardIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(netPolicyPackageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(broadOpenClawWildcardIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(netPolicyIpIndex).toBeLessThan(netPolicyPackageIndex);
|
||||
expect(netPolicyWildcardIndex).toBeLessThan(broadOpenClawWildcardIndex);
|
||||
});
|
||||
|
||||
it("uses a browser-safe redactor for shared tool display imports", async () => {
|
||||
const plugin = controlUiBrowserOnlySharedModuleAliases();
|
||||
const resolveIdHook = plugin.resolveId;
|
||||
const resolveIdHandler = (
|
||||
typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler
|
||||
) as ResolveIdHandler | undefined;
|
||||
if (!resolveIdHandler) {
|
||||
throw new Error("Expected browser-only shared module alias plugin to expose resolveId");
|
||||
}
|
||||
|
||||
for (const importerSuffix of ["", "?browserv=123"]) {
|
||||
const resolved = await resolveIdHandler.call(
|
||||
{} as never,
|
||||
"../logging/redact.js",
|
||||
`${path.join(repoRoot, "src/agents/tool-display-common.ts")}${importerSuffix}`,
|
||||
{ custom: {}, isEntry: false, ssr: false },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.join(repoRoot, "ui/src/lib/browser-redact.ts"));
|
||||
}
|
||||
});
|
||||
});
|
||||
93
ui/src/app/web-push.runtime.ts
Normal file
93
ui/src/app/web-push.runtime.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
|
||||
const SW_READY_TIMEOUT = 10_000;
|
||||
|
||||
function swReady(): Promise<ServiceWorkerRegistration> {
|
||||
return Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function getExistingSubscription(): Promise<PushSubscription | null> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
const registration = await swReady();
|
||||
return await registration.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
export async function subscribeToWebPush(
|
||||
client: GatewayBrowserClient,
|
||||
): Promise<{ subscriptionId: string }> {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
throw new Error(`Notification permission ${permission}`);
|
||||
}
|
||||
|
||||
const vapidRes = await client.request("push.web.vapidPublicKey", {});
|
||||
const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey;
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error("Failed to retrieve VAPID public key");
|
||||
}
|
||||
|
||||
const registration = await swReady();
|
||||
const pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer,
|
||||
});
|
||||
const subscription = pushSubscription.toJSON();
|
||||
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys.auth) {
|
||||
throw new Error("Invalid push subscription from browser");
|
||||
}
|
||||
|
||||
try {
|
||||
return (await client.request("push.web.subscribe", {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
})) as { subscriptionId: string };
|
||||
} catch (error) {
|
||||
try {
|
||||
await pushSubscription.unsubscribe();
|
||||
} catch {
|
||||
// The Gateway error remains the actionable failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise<void> {
|
||||
const registration = await swReady();
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request("push.web.unsubscribe", {
|
||||
endpoint: subscription.endpoint,
|
||||
});
|
||||
} catch {
|
||||
// Local unsubscribe still prevents a stale browser subscription.
|
||||
}
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
export async function sendTestWebPush(client: GatewayBrowserClient): Promise<void> {
|
||||
await client.request("push.web.test", {});
|
||||
}
|
||||
147
ui/src/app/web-push.ts
Normal file
147
ui/src/app/web-push.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Application-owned browser push subscription lifecycle.
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { ApplicationGateway } from "./gateway.ts";
|
||||
|
||||
export type WebPushSnapshot = {
|
||||
supported: boolean;
|
||||
permission: NotificationPermission | "unsupported";
|
||||
subscribed: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type WebPushCapability = {
|
||||
readonly snapshot: WebPushSnapshot;
|
||||
subscribe: (listener: (snapshot: WebPushSnapshot) => void) => () => void;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
sendTest: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function isWebPushSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
typeof window !== "undefined" &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
function webPushError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function createWebPushCapability(gateway: ApplicationGateway): WebPushCapability {
|
||||
const supported = isWebPushSupported();
|
||||
let snapshot: WebPushSnapshot = {
|
||||
supported,
|
||||
permission: supported ? Notification.permission : "unsupported",
|
||||
subscribed: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
let disposed = false;
|
||||
let wasConnected = false;
|
||||
let operation: Promise<void> | null = null;
|
||||
const listeners = new Set<(snapshot: WebPushSnapshot) => void>();
|
||||
|
||||
const publish = (patch: Partial<WebPushSnapshot>) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
const readExistingSubscription = async () => {
|
||||
if (!supported) {
|
||||
return null;
|
||||
}
|
||||
const { getExistingSubscription } = await import("./web-push.runtime.ts");
|
||||
const subscription = await getExistingSubscription();
|
||||
publish({ subscribed: subscription !== null });
|
||||
return subscription;
|
||||
};
|
||||
|
||||
const reconcile = async (client: GatewayBrowserClient) => {
|
||||
try {
|
||||
const subscription = await readExistingSubscription();
|
||||
const json = subscription?.toJSON();
|
||||
if (!json?.endpoint || !json.keys?.p256dh || !json.keys.auth) {
|
||||
return;
|
||||
}
|
||||
await client.request("push.web.subscribe", {
|
||||
endpoint: json.endpoint,
|
||||
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
|
||||
});
|
||||
} catch {
|
||||
// Existing subscriptions are reconciled best-effort after reconnect.
|
||||
}
|
||||
};
|
||||
|
||||
const run = (action: (client: GatewayBrowserClient) => Promise<void>) => {
|
||||
const client = gateway.snapshot.client;
|
||||
if (!supported || !client || operation) {
|
||||
return operation ?? Promise.resolve();
|
||||
}
|
||||
publish({ loading: true, error: null });
|
||||
operation = action(client)
|
||||
.catch((error: unknown) => {
|
||||
publish({ error: webPushError(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
operation = null;
|
||||
publish({
|
||||
loading: false,
|
||||
permission: "Notification" in window ? Notification.permission : "unsupported",
|
||||
});
|
||||
});
|
||||
return operation;
|
||||
};
|
||||
|
||||
void readExistingSubscription().catch(() => {});
|
||||
const stopGateway = gateway.subscribe((gatewaySnapshot) => {
|
||||
const client = gatewaySnapshot.client;
|
||||
const connected = gatewaySnapshot.connected && client !== null;
|
||||
if (connected && !wasConnected && client) {
|
||||
void reconcile(client);
|
||||
}
|
||||
wasConnected = connected;
|
||||
});
|
||||
|
||||
return {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
enable: () =>
|
||||
run(async (client) => {
|
||||
const { subscribeToWebPush } = await import("./web-push.runtime.ts");
|
||||
await subscribeToWebPush(client);
|
||||
publish({ subscribed: true });
|
||||
}),
|
||||
disable: () =>
|
||||
run(async (client) => {
|
||||
const { unsubscribeFromWebPush } = await import("./web-push.runtime.ts");
|
||||
await unsubscribeFromWebPush(client);
|
||||
publish({ subscribed: false });
|
||||
}),
|
||||
sendTest: () =>
|
||||
run(async (client) => {
|
||||
const { sendTestWebPush } = await import("./web-push.runtime.ts");
|
||||
await sendTestWebPush(client);
|
||||
}),
|
||||
dispose() {
|
||||
disposed = true;
|
||||
stopGateway();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user