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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
type AgentSelectionGateway = {
readonly snapshot: {
client: GatewayBrowserClient | null;
assistantAgentId: string | null;
};
subscribe: (listener: (snapshot: AgentSelectionGateway["snapshot"]) => void) => () => void;
};
export type AgentSelectionState = {
selectedId: string | null;
};
export type AgentSelectionCapability = {
readonly state: AgentSelectionState;
set: (agentId: string | null) => void;
subscribe: (listener: (state: AgentSelectionState) => void) => () => void;
};
export function createAgentSelectionCapability(
gateway: AgentSelectionGateway,
): AgentSelectionCapability {
let state: AgentSelectionState = {
selectedId: gateway.snapshot.assistantAgentId
? normalizeAgentId(gateway.snapshot.assistantAgentId)
: null,
};
let client = gateway.snapshot.client;
const listeners = new Set<(next: AgentSelectionState) => void>();
const publish = (selectedId: string | null) => {
if (state.selectedId === selectedId) {
return;
}
state = { selectedId };
for (const listener of listeners) {
listener(state);
}
};
gateway.subscribe((next) => {
if (next.client !== client) {
client = next.client;
publish(next.assistantAgentId ? normalizeAgentId(next.assistantAgentId) : null);
}
});
return {
get state() {
return state;
},
set(agentId) {
publish(agentId?.trim() ? normalizeAgentId(agentId) : null);
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

749
ui/src/app/app-host.ts Normal file
View 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);
}

View File

@@ -0,0 +1,118 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { normalizeAssistantIdentity, type AssistantIdentity } from "../lib/assistant-identity.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
export type LocalAssistantIdentity = { avatar: string | null; agentId?: string | null };
type PersistedLocalAssistantIdentities = {
avatars?: Record<string, unknown>;
avatar?: unknown;
agentId?: unknown;
};
function parseLocalAssistantAvatarMap(raw: string): {
avatars: Record<string, string>;
legacyAvatar: string | null;
} {
const parsed = JSON.parse(raw) as PersistedLocalAssistantIdentities;
const avatars = Object.create(null) as Record<string, string>;
if (parsed.avatars && typeof parsed.avatars === "object" && !Array.isArray(parsed.avatars)) {
for (const [agentId, avatar] of Object.entries(parsed.avatars)) {
const normalizedAgentId = normalizeOptionalString(agentId);
const normalizedAvatar = normalizeOptionalString(avatar);
if (normalizedAgentId && normalizedAvatar) {
avatars[normalizedAgentId] = normalizedAvatar;
}
}
}
const legacyAvatar = normalizeOptionalString(parsed.avatar);
const legacyAgentId = normalizeOptionalString(parsed.agentId);
if (legacyAvatar && legacyAgentId && !Object.hasOwn(avatars, legacyAgentId)) {
avatars[legacyAgentId] = legacyAvatar;
}
return { avatars, legacyAvatar: legacyAgentId ? null : (legacyAvatar ?? null) };
}
function persistLocalAssistantAvatarMap(storage: Storage | null, avatars: Record<string, string>) {
if (Object.keys(avatars).length === 0) {
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
return;
}
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatars }));
}
export function loadLocalAssistantIdentity(opts?: {
agentId?: string | null;
}): LocalAssistantIdentity {
const agentId = normalizeOptionalString(opts?.agentId);
if (!agentId) {
return { avatar: null };
}
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
if (!raw) {
return { avatar: null };
}
const { avatars, legacyAvatar } = parseLocalAssistantAvatarMap(raw);
if (!Object.hasOwn(avatars, agentId) && legacyAvatar) {
// Assign the old global override to the first concrete agent that loads it.
avatars[agentId] = legacyAvatar;
persistLocalAssistantAvatarMap(storage, avatars);
}
return { avatar: Object.hasOwn(avatars, agentId) ? avatars[agentId] : null, agentId };
} catch {
return { avatar: null };
}
}
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
const agentId = normalizeOptionalString(next.agentId);
if (!agentId) {
return;
}
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
const avatars = raw
? parseLocalAssistantAvatarMap(raw).avatars
: (Object.create(null) as Record<string, string>);
const avatar = normalizeOptionalString(next.avatar);
if (avatar) {
avatars[agentId] = avatar;
} else {
delete avatars[agentId];
}
persistLocalAssistantAvatarMap(storage, avatars);
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
}
export async function fetchAssistantIdentity(
client: GatewayBrowserClient,
sessionKey?: string,
): Promise<AssistantIdentity | null> {
const result = await client.request<Partial<AssistantIdentity>>(
"agent.identity.get",
sessionKey?.trim() ? { sessionKey: sessionKey.trim() } : {},
);
if (!result) {
return null;
}
const identity = normalizeAssistantIdentity(result);
const localAvatar = loadLocalAssistantIdentity({ agentId: identity.agentId }).avatar;
return localAvatar
? {
...identity,
avatar: localAvatar,
avatarSource: localAvatar,
avatarStatus: "data",
avatarReason: null,
}
: identity;
}

630
ui/src/app/bootstrap.ts Normal file
View File

@@ -0,0 +1,630 @@
import type { RouteLocation } from "@openclaw/uirouter";
import type { EventLogEntry } from "../api/event-log.ts";
import {
GatewayBrowserClient,
type GatewayEventListener,
type GatewayHelloOk,
} from "../api/gateway.ts";
import {
createApplicationRouter,
inferBasePathFromPathname,
locationForRoute,
normalizeBasePath,
pathForRoute,
routeIdFromPath,
startApplicationRouter,
type ApplicationRouter,
type RouteId,
} from "../app-routes.ts";
import { createAgentIdentityCapability } from "../lib/agents/identity.ts";
import { createAgentCapability } from "../lib/agents/index.ts";
import { createChannelCapability } from "../lib/channels/index.ts";
import { createRuntimeConfigCapability } from "../lib/config/index.ts";
import { createSessionCapability, resolveSessionKey } from "../lib/sessions/index.ts";
import { generateUUID } from "../lib/uuid.ts";
import { createWorkboardCapability } from "../lib/workboard/capability.ts";
import { createAgentSelectionCapability } from "./agent-selection.ts";
import { createBrowserHistory } from "./browser.ts";
import { createApplicationConfigCapability } from "./config.ts";
import type {
ApplicationGateway,
ApplicationGatewayConnectOptions,
ApplicationGatewayConnection,
ApplicationNavigationOptions,
ApplicationGatewaySnapshot,
ApplicationContext,
ApplicationNavigationPreferences,
ApplicationNavigationPreferencesSnapshot,
ApplicationSkillWorkshopRevisionHandoff,
ApplicationTheme,
} from "./context.ts";
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
import { createNativeChatDrafts } from "./native-bridge.ts";
import { createApplicationOverlays } from "./overlays.ts";
import {
loadSettings,
patchSettings,
resolveApplicationStartupSettings,
saveSettings,
type UiSettings,
} from "./settings.ts";
import { startThemeTransition } from "./theme-transition.ts";
import { resolveTheme, type ThemeMode } from "./theme.ts";
import { createWebPushCapability } from "./web-push.ts";
function normalizeInitialApplicationLocation(
location: RouteLocation,
basePath: string,
sessionKey: string,
) {
const routeId = routeIdFromPath(location.pathname, basePath);
if ((routeId !== null && routeId !== "chat") || !sessionKey.trim()) {
return location;
}
const search = new URLSearchParams(location.search);
if (!search.get("session")?.trim()) {
search.set("session", sessionKey);
}
return {
...location,
pathname: routeId === null ? pathForRoute("chat", basePath) : location.pathname,
search: `?${search.toString()}`,
};
}
function applyStartupPresentation(settings: ReturnType<typeof loadSettings>): void {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const resolvedTheme = resolveTheme(settings.theme, settings.themeMode);
root.dataset.theme = resolvedTheme;
root.dataset.themeMode = resolvedTheme.endsWith("light") ? "light" : "dark";
root.style.colorScheme = root.dataset.themeMode;
root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`);
syncCustomThemeStyleTag(settings.customTheme);
}
function createApplicationTheme(
initialSettings: UiSettings,
): ApplicationTheme & { dispose: () => void } {
let settings = initialSettings;
let systemThemeCleanup: (() => void) | undefined;
const listeners = new Set<() => void>();
const publish = () => {
applyStartupPresentation(settings);
for (const listener of listeners) {
listener();
}
};
const detachSystemThemeListener = () => {
systemThemeCleanup?.();
systemThemeCleanup = undefined;
};
const syncSystemThemeListener = () => {
detachSystemThemeListener();
if (settings.themeMode !== "system" || typeof globalThis.matchMedia !== "function") {
return;
}
const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: light)");
const onChange = () => {
if (settings.themeMode === "system") {
publish();
}
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", onChange);
systemThemeCleanup = () => mediaQuery.removeEventListener("change", onChange);
} else if (typeof mediaQuery.addListener === "function") {
mediaQuery.addListener(onChange);
systemThemeCleanup = () => mediaQuery.removeListener(onChange);
}
};
syncSystemThemeListener();
return {
get mode() {
return settings.themeMode;
},
setMode(mode: ThemeMode, element) {
const currentSettings = loadSettings();
const nextSettings = { ...currentSettings, themeMode: mode };
const currentTheme = resolveTheme(currentSettings.theme, currentSettings.themeMode);
const nextTheme = resolveTheme(nextSettings.theme, nextSettings.themeMode);
startThemeTransition({
nextTheme,
currentTheme,
context: { element },
applyTheme: () => {
settings = patchSettings({ themeMode: mode });
publish();
syncSystemThemeListener();
},
});
},
refresh() {
settings = loadSettings();
publish();
syncSystemThemeListener();
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
detachSystemThemeListener();
listeners.clear();
},
};
}
function createApplicationNavigationPreferences(
initialSettings: UiSettings,
): ApplicationNavigationPreferences {
let settings = initialSettings;
let snapshot: ApplicationNavigationPreferencesSnapshot = {
navCollapsed: settings.navCollapsed,
navGroupsCollapsed: settings.navGroupsCollapsed,
recentSessionsCollapsed: settings.recentSessionsCollapsed ?? false,
};
const listeners = new Set<(next: ApplicationNavigationPreferencesSnapshot) => void>();
return {
get snapshot() {
return snapshot;
},
update(patch) {
const nextSnapshot = { ...snapshot, ...patch };
if (
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
nextSnapshot.recentSessionsCollapsed === snapshot.recentSessionsCollapsed &&
nextSnapshot.navGroupsCollapsed === snapshot.navGroupsCollapsed
) {
return;
}
settings = patchSettings({
navCollapsed: nextSnapshot.navCollapsed,
navGroupsCollapsed: nextSnapshot.navGroupsCollapsed,
recentSessionsCollapsed: nextSnapshot.recentSessionsCollapsed,
});
snapshot = nextSnapshot;
for (const listener of listeners) {
listener(snapshot);
}
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
function createSkillWorkshopRevisionHandoff(): ApplicationSkillWorkshopRevisionHandoff {
let pending: Parameters<ApplicationSkillWorkshopRevisionHandoff["prepare"]>[0] | null = null;
return {
prepare: (handoff) => {
pending = handoff;
},
consume: (sessionKey) => {
if (!pending || pending.sessionKey !== sessionKey) {
return null;
}
const handoff = pending;
pending = null;
return handoff;
},
clear: () => {
pending = null;
},
};
}
function createApplicationGateway(
initialSettings: ReturnType<typeof loadSettings>,
initialPassword = "",
): ApplicationGateway {
let settings = initialSettings;
let connection: ApplicationGatewayConnection = {
gatewayUrl: settings.gatewayUrl,
token: settings.token,
password: initialPassword,
};
let snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
hello: null,
assistantAgentId: "main",
sessionKey: settings.sessionKey,
lastError: null,
lastErrorCode: null,
};
let client: GatewayBrowserClient | null = null;
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<GatewayEventListener>();
const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>();
let eventLog: EventLogEntry[] = [];
let stopClientEvents: (() => void) | undefined;
const syncClientEvents = (nextClient: GatewayBrowserClient | null) => {
stopClientEvents?.();
stopClientEvents = undefined;
if (!nextClient || eventListeners.size === 0) {
return;
}
const removers = [...eventListeners].map((listener) => nextClient.addEventListener(listener));
stopClientEvents = () => {
for (const remove of removers) {
remove();
}
};
};
const notify = () => {
for (const listener of listeners) {
listener(snapshot);
}
};
const setSnapshot = (next: ApplicationGatewaySnapshot) => {
snapshot = next;
notify();
};
const publishEventLog = () => {
for (const listener of eventLogListeners) {
listener(eventLog);
}
};
const recordGatewayEvent = (event: Parameters<GatewayEventListener>[0]) => {
eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice(
0,
250,
);
publishEventLog();
};
const connect = (overrides: ApplicationGatewayConnectOptions = {}) => {
const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides;
const nextConnection = { ...connection, ...connectionOverrides };
const hasRequestedSessionKey = requestedSessionKey !== undefined;
const nextSessionKey = hasRequestedSessionKey
? requestedSessionKey.trim()
: snapshot.sessionKey;
connection = nextConnection;
settings = patchSettings({
gatewayUrl: nextConnection.gatewayUrl,
token: nextConnection.token,
...(hasRequestedSessionKey
? {
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
}
: {}),
});
client?.stop();
stopClientEvents?.();
stopClientEvents = undefined;
const nextClient = new GatewayBrowserClient({
url: nextConnection.gatewayUrl,
token: nextConnection.token.trim() ? nextConnection.token : undefined,
password: nextConnection.password.trim() ? nextConnection.password : undefined,
clientName: "openclaw-control-ui",
clientVersion: "dev",
mode: "webchat",
instanceId: generateUUID(),
onHello: (hello: GatewayHelloOk) => {
if (client !== nextClient) {
return;
}
settings = loadSettings();
const sessionDefaults = readSessionDefaults(hello);
const sessionKey = resolveSessionKey(snapshot.sessionKey, hello);
const lastActiveSessionKey = resolveSessionKey(settings.lastActiveSessionKey, hello);
if (
sessionKey !== settings.sessionKey ||
lastActiveSessionKey !== settings.lastActiveSessionKey
) {
settings = patchSettings({
sessionKey,
lastActiveSessionKey,
});
}
setSnapshot({
...snapshot,
client: nextClient,
connected: true,
hello,
assistantAgentId: sessionDefaults?.defaultAgentId ?? "main",
sessionKey,
lastError: null,
lastErrorCode: null,
});
},
onClose: ({ code, reason, error }) => {
if (client !== nextClient) {
return;
}
setSnapshot({
...snapshot,
client: nextClient,
connected: false,
hello: null,
lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`,
lastErrorCode: error?.code ?? null,
});
},
onGap: ({ expected, received }) => {
if (client !== nextClient) {
return;
}
setSnapshot({
...snapshot,
lastError: `event gap detected (expected seq ${expected}, got ${received}); reconnecting`,
lastErrorCode: null,
});
connect();
},
onEvent: recordGatewayEvent,
});
client = nextClient;
syncClientEvents(nextClient);
setSnapshot({
...snapshot,
client: nextClient,
connected: false,
hello: null,
sessionKey: nextSessionKey,
lastError: null,
lastErrorCode: null,
});
nextClient.start();
};
const gateway: ApplicationGateway = {
get snapshot() {
return snapshot;
},
get connection() {
return connection;
},
get eventLog() {
return eventLog;
},
connect,
setSessionKey: (sessionKey) => {
const nextSessionKey = sessionKey.trim();
if (!nextSessionKey || nextSessionKey === snapshot.sessionKey) {
return;
}
settings = patchSettings({
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
});
setSnapshot({ ...snapshot, sessionKey: nextSessionKey });
},
start: () => connect(),
stop: () => {
stopClientEvents?.();
stopClientEvents = undefined;
client?.stop();
client = null;
setSnapshot({
...snapshot,
client: null,
connected: false,
hello: null,
lastError: null,
lastErrorCode: null,
});
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEventLog: (listener) => {
eventLogListeners.add(listener);
return () => eventLogListeners.delete(listener);
},
subscribeEvents: (listener) => {
eventListeners.add(listener);
syncClientEvents(client);
return () => {
if (eventListeners.delete(listener)) {
syncClientEvents(client);
}
};
},
};
return gateway;
}
function readSessionDefaults(
hello: GatewayHelloOk,
): { defaultAgentId?: string | null } | undefined {
const snapshot = hello.snapshot;
if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) {
return undefined;
}
const defaults = snapshot.sessionDefaults;
return defaults && typeof defaults === "object"
? (defaults as { defaultAgentId?: string | null })
: undefined;
}
export type ApplicationRuntime = {
readonly context: ApplicationContext<RouteId>;
readonly router: ApplicationRouter;
readonly pendingGatewayConnection: {
readonly gatewayUrl: string;
readonly token: string;
} | null;
readonly confirmPendingGatewayConnection: () => void;
readonly cancelPendingGatewayConnection: () => void;
start: () => Promise<void>;
stop: () => void;
};
export function bootstrapApplication(): ApplicationRuntime {
const initialSettings = loadSettings();
const history = createBrowserHistory();
const startup = resolveApplicationStartupSettings(initialSettings, history.location());
if (startup.changed) {
saveSettings(startup.settings);
}
const basePath = normalizeBasePath(
inferBasePathFromPathname(startup.location.pathname || globalThis.location?.pathname || "/"),
);
const initialLocation = normalizeInitialApplicationLocation(
startup.location,
basePath,
startup.settings.sessionKey,
);
const currentLocation = history.location();
if (
currentLocation.pathname !== initialLocation.pathname ||
currentLocation.search !== initialLocation.search ||
currentLocation.hash !== initialLocation.hash
) {
history.replace(initialLocation);
}
const settings = startup.settings;
const gateway = createApplicationGateway(settings, startup.password ?? "");
const agents = createAgentCapability(gateway);
const agentIdentity = createAgentIdentityCapability(gateway);
const agentSelection = createAgentSelectionCapability(gateway);
const channels = createChannelCapability(gateway);
const config = createApplicationConfigCapability({
basePath,
auth: {
settings: { token: settings.token },
password: startup.password ?? "",
},
});
const sessions = createSessionCapability(gateway);
const workboard = createWorkboardCapability();
const runtimeConfig = createRuntimeConfigCapability(gateway);
const overlays = createApplicationOverlays(gateway);
const navigation = createApplicationNavigationPreferences(settings);
const theme = createApplicationTheme(settings);
const nativeChatDrafts = createNativeChatDrafts();
const webPush = createWebPushCapability(gateway);
const skillWorkshopRevision = createSkillWorkshopRevisionHandoff();
applyStartupPresentation(settings);
const router = createApplicationRouter();
let pendingGatewayConnection =
startup.pendingGatewayUrl !== null
? {
gatewayUrl: startup.pendingGatewayUrl,
token: startup.pendingGatewayToken ?? "",
}
: null;
let lastConfigRefreshClient: GatewayBrowserClient | null = null;
const stopConfigRefresh = gateway.subscribe((snapshot) => {
if (!snapshot.connected || !snapshot.client) {
lastConfigRefreshClient = null;
return;
}
if (lastConfigRefreshClient === snapshot.client) {
return;
}
lastConfigRefreshClient = snapshot.client;
void config.refresh({
auth: {
hello: snapshot.hello,
settings: { token: gateway.connection.token },
password: gateway.connection.password,
},
});
});
const routeLocation = (routeId: RouteId, options?: ApplicationNavigationOptions) => {
const location = locationForRoute(routeId, basePath);
if (options?.search !== undefined || options?.hash !== undefined) {
return {
...location,
search: options?.search ?? "",
hash: options?.hash ?? "",
};
}
return location;
};
const confirmPendingGatewayConnection = () => {
const pending = pendingGatewayConnection;
if (!pending) {
return;
}
pendingGatewayConnection = null;
gateway.connect({
gatewayUrl: pending.gatewayUrl,
token: pending.token,
});
};
const cancelPendingGatewayConnection = () => {
pendingGatewayConnection = null;
};
const context: ApplicationContext<RouteId> = {
basePath,
gateway,
agents,
agentIdentity,
agentSelection,
channels,
config,
runtimeConfig,
sessions,
workboard,
overlays,
navigation,
theme,
nativeChatDrafts,
webPush,
skillWorkshopRevision,
navigate: (routeId, options) => {
void router
.navigate(routeId, context, { history: "push" }, routeLocation(routeId, options))
.catch((error: unknown) => {
console.error("[openclaw] route navigation failed", error);
});
},
replace: (routeId, options) => {
void router
.navigate(routeId, context, { history: "replace" }, routeLocation(routeId, options))
.catch((error: unknown) => {
console.error("[openclaw] route replacement failed", error);
});
},
preload: (routeId) => router.preloadRoute(routeId, context),
};
return {
context,
router,
get pendingGatewayConnection() {
return pendingGatewayConnection;
},
confirmPendingGatewayConnection,
cancelPendingGatewayConnection,
start: async () => {
void config.refresh({ skipWithoutAuthCandidate: true });
const routerStart = startApplicationRouter(router, history, basePath, context);
gateway.start();
await routerStart;
},
stop: () => {
stopConfigRefresh();
router.stop();
gateway.stop();
agents.dispose();
channels.dispose();
sessions.dispose();
workboard.dispose();
runtimeConfig.dispose();
overlays.dispose();
theme.dispose();
nativeChatDrafts.dispose();
webPush.dispose();
skillWorkshopRevision.clear();
},
};
}

53
ui/src/app/browser.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { RouteLocation, RouterHistory } from "@openclaw/uirouter";
function readLocation(): RouteLocation {
return {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
};
}
function writeLocation(location: RouteLocation) {
return `${location.pathname}${location.search}${location.hash}`;
}
export function createBrowserHistory(): RouterHistory {
const listeners = new Set<(location: RouteLocation) => void>();
let stopPopState: (() => void) | undefined;
const ensurePopStateListener = () => {
if (stopPopState) {
return;
}
const onPopState = () => {
const location = readLocation();
for (const listener of listeners) {
listener(location);
}
};
window.addEventListener("popstate", onPopState);
stopPopState = () => window.removeEventListener("popstate", onPopState);
};
const releasePopStateListener = () => {
if (listeners.size === 0) {
stopPopState?.();
stopPopState = undefined;
}
};
return {
location: readLocation,
push: (location) => window.history.pushState({}, "", writeLocation(location)),
replace: (location) => window.history.replaceState({}, "", writeLocation(location)),
listen: (listener) => {
listeners.add(listener);
ensurePopStateListener();
return () => {
listeners.delete(listener);
releasePopStateListener();
};
},
};
}

248
ui/src/app/config.ts Normal file
View File

@@ -0,0 +1,248 @@
import { normalizeRouteBasePath } from "@openclaw/uirouter";
import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE,
type ControlUiBootstrapConfig,
type ControlUiEmbedSandboxMode,
} from "../../../src/gateway/control-ui-contract.js";
import { normalizeAssistantIdentity } from "../lib/assistant-identity.ts";
import { setUiTimeFormatPreference } from "../lib/format.ts";
import { resolveControlUiAuthCandidates } from "./control-ui-auth.ts";
type ApplicationConfigAuthSource = {
hello?: { auth?: { deviceToken?: string | null } | null } | null;
settings?: { token?: string | null } | null;
password?: string | null;
};
const SEAM_COLOR_CSS_VARIABLES = [
"--ring",
"--accent",
"--accent-hover",
"--accent-muted",
"--accent-subtle",
"--accent-glow",
"--primary",
"--focus",
"--focus-ring",
"--focus-glow",
] as const;
export type ApplicationConfig = {
assistantIdentity: {
agentId: string | null;
name: string;
avatar: string | null;
avatarSource: string | null;
avatarStatus: "none" | "local" | "remote" | "data" | null;
avatarReason: string | null;
};
serverVersion: string | null;
localMediaPreviewRoots: string[];
embedSandboxMode: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth: string | null;
terminalEnabled: boolean;
};
export type ApplicationConfigCapability = {
readonly current: ApplicationConfig;
refresh: (options?: {
auth?: ApplicationConfigAuthSource;
skipWithoutAuthCandidate?: boolean;
}) => Promise<void>;
subscribe: (listener: (config: ApplicationConfig) => void) => () => void;
};
function readDocumentTerminalEnabled(): boolean | null {
if (typeof document === "undefined") {
return null;
}
const value = document.documentElement.getAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE);
return value === "true" ? true : value === "false" ? false : null;
}
export const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = {
assistantIdentity: {
agentId: null,
name: "Assistant",
avatar: null,
avatarSource: null,
avatarStatus: null,
avatarReason: null,
},
serverVersion: null,
localMediaPreviewRoots: [],
embedSandboxMode: "strict",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
terminalEnabled: readDocumentTerminalEnabled() ?? false,
};
function normalizeSeamColor(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const hex = value.trim().replace(/^#/, "");
return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
}
function applyControlUiSeamColor(value: unknown): void {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const color = normalizeSeamColor(value);
if (!color) {
for (const property of SEAM_COLOR_CSS_VARIABLES) {
root.style.removeProperty(property);
}
return;
}
root.style.setProperty("--ring", color);
root.style.setProperty("--accent", color);
root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)");
root.style.setProperty("--accent-muted", color);
root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)");
root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)");
root.style.setProperty("--primary", color);
root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)");
root.style.setProperty(
"--focus-ring",
"0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)",
);
root.style.setProperty(
"--focus-glow",
"0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)",
);
}
export function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): ApplicationConfig {
const identity = normalizeAssistantIdentity({
agentId: parsed.assistantAgentId ?? null,
name: parsed.assistantName,
avatar: parsed.assistantAvatar ?? null,
avatarSource: parsed.assistantAvatarSource ?? null,
avatarStatus: parsed.assistantAvatarStatus ?? null,
avatarReason: parsed.assistantAvatarReason ?? null,
});
return {
assistantIdentity: {
agentId: identity.agentId ?? null,
name: identity.name,
avatar: identity.avatar,
avatarSource: identity.avatarSource ?? null,
avatarStatus: identity.avatarStatus ?? null,
avatarReason: identity.avatarReason ?? null,
},
serverVersion: parsed.serverVersion ?? null,
localMediaPreviewRoots: Array.isArray(parsed.localMediaPreviewRoots)
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")
: [],
embedSandboxMode:
parsed.embedSandbox === "trusted"
? "trusted"
: parsed.embedSandbox === "strict"
? "strict"
: "scripts",
allowExternalEmbedUrls: parsed.allowExternalEmbedUrls === true,
chatMessageMaxWidth:
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
? parsed.chatMessageMaxWidth
: null,
terminalEnabled: parsed.terminalEnabled === true,
};
}
export async function loadApplicationConfig(params: {
basePath: string;
auth?: ApplicationConfigAuthSource;
skipWithoutAuthCandidate?: boolean;
}): Promise<ApplicationConfig | null> {
if (typeof window === "undefined" || typeof fetch !== "function") {
return null;
}
const basePath = normalizeRouteBasePath(params.basePath);
const url = basePath
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
try {
const resolvedUrl = new URL(url, window.location.origin);
const sameOrigin = resolvedUrl.origin === window.location.origin;
const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(params.auth ?? {}) : [];
if (params.skipWithoutAuthCandidate && sameOrigin && authCandidates.length === 0) {
return null;
}
const attempts = authCandidates.length > 0 ? authCandidates : [""];
let res: Response | null = null;
for (const candidate of attempts) {
const headers: Record<string, string> = { Accept: "application/json" };
if (candidate) {
headers.Authorization = `Bearer ${candidate}`;
}
res = await fetch(url, { method: "GET", headers, credentials: "same-origin" });
if (res.ok) {
break;
}
if (res.status !== 401 && res.status !== 403) {
return null;
}
}
if (!res || !res.ok) {
return null;
}
const parsed = (await res.json()) as ControlUiBootstrapConfig;
setUiTimeFormatPreference(parsed.timeFormat);
applyControlUiSeamColor(parsed.seamColor);
return normalizeApplicationConfig(parsed);
} catch {
return null;
}
}
export function createApplicationConfigCapability(params: {
basePath: string;
auth?: ApplicationConfigAuthSource;
}): ApplicationConfigCapability {
let current = DEFAULT_APPLICATION_CONFIG;
let refreshVersion = 0;
const listeners = new Set<(config: ApplicationConfig) => void>();
const publish = (next: ApplicationConfig) => {
current = next;
for (const listener of listeners) {
listener(current);
}
};
return {
get current() {
return current;
},
async refresh(options) {
const version = ++refreshVersion;
const next = await loadApplicationConfig({
basePath: params.basePath,
auth: options?.auth ?? params.auth,
skipWithoutAuthCandidate: options?.skipWithoutAuthCandidate,
});
if (next && version === refreshVersion) {
const documentTerminalEnabled = readDocumentTerminalEnabled();
if (documentTerminalEnabled !== null && next.terminalEnabled !== documentTerminalEnabled) {
// CSP headers cannot change on a live document. Reload in either
// direction so the document and accepted terminal state stay aligned.
window.location.reload();
return;
}
publish(next);
}
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

82
ui/src/app/context.ts Normal file
View File

@@ -0,0 +1,82 @@
import { createContext } from "@lit/context";
import type { RouteLocation } from "@openclaw/uirouter";
import type { RouteId } from "../app-route-paths.ts";
import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
import type { AgentCapability } from "../lib/agents/index.ts";
import type { ChannelCapability } from "../lib/channels/index.ts";
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import type { WorkboardCapability } from "../lib/workboard/capability.ts";
import type { AgentSelectionCapability } from "./agent-selection.ts";
import type { ApplicationConfigCapability } from "./config.ts";
import type { ApplicationGateway } from "./gateway.ts";
import type { NativeChatDrafts } from "./native-bridge.ts";
import type { ApplicationOverlays } from "./overlays.ts";
import type { ThemeMode } from "./theme.ts";
import type { WebPushCapability } from "./web-push.ts";
export type {
ApplicationGateway,
ApplicationGatewayConnection,
ApplicationGatewayConnectOptions,
ApplicationGatewaySnapshot,
} from "./gateway.ts";
export type ApplicationTheme = {
readonly mode: ThemeMode;
setMode: (mode: ThemeMode, element?: HTMLElement | null) => void;
refresh: () => void;
subscribe: (listener: () => void) => () => void;
};
export type ApplicationNavigationPreferencesSnapshot = {
navCollapsed: boolean;
navGroupsCollapsed: Record<string, boolean>;
recentSessionsCollapsed: boolean;
};
export type ApplicationNavigationPreferences = {
readonly snapshot: ApplicationNavigationPreferencesSnapshot;
update: (patch: Partial<ApplicationNavigationPreferencesSnapshot>) => void;
subscribe: (listener: (snapshot: ApplicationNavigationPreferencesSnapshot) => void) => () => void;
};
export type ApplicationNavigationOptions = Partial<Pick<RouteLocation, "search" | "hash">>;
export type SkillWorkshopRevisionHandoff = {
sessionKey: string;
instructions: string;
proposalId: string;
proposalAgentId: string;
};
export type ApplicationSkillWorkshopRevisionHandoff = {
prepare: (handoff: SkillWorkshopRevisionHandoff) => void;
consume: (sessionKey: string) => SkillWorkshopRevisionHandoff | null;
clear: () => void;
};
export type ApplicationContext<TRouteId extends string = string> = {
readonly basePath: string;
readonly gateway: ApplicationGateway;
readonly agents: AgentCapability;
readonly agentIdentity: AgentIdentityCapability;
readonly agentSelection: AgentSelectionCapability;
readonly channels: ChannelCapability;
readonly config: ApplicationConfigCapability;
readonly runtimeConfig: RuntimeConfigCapability;
readonly sessions: SessionCapability;
readonly workboard: WorkboardCapability;
readonly overlays: ApplicationOverlays;
readonly navigation: ApplicationNavigationPreferences;
readonly theme: ApplicationTheme;
readonly nativeChatDrafts: NativeChatDrafts;
readonly webPush: WebPushCapability;
readonly skillWorkshopRevision: ApplicationSkillWorkshopRevisionHandoff;
readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void;
readonly preload: (routeId: TRouteId) => Promise<void>;
};
export const applicationContext =
createContext<ApplicationContext<RouteId>>("openclaw.application");

View 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) ?? []),
);
}

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

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

View 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
View 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
View File

@@ -0,0 +1,35 @@
import type { EventLogEntry } from "../api/event-log.ts";
import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts";
export type ApplicationGatewaySnapshot = {
client: GatewayBrowserClient | null;
connected: boolean;
hello: GatewayHelloOk | null;
assistantAgentId: string | null;
sessionKey: string;
lastError: string | null;
lastErrorCode: string | null;
};
export type ApplicationGatewayConnection = {
gatewayUrl: string;
token: string;
password: string;
};
export type ApplicationGatewayConnectOptions = Partial<ApplicationGatewayConnection> & {
sessionKey?: string;
};
export type ApplicationGateway = {
readonly snapshot: ApplicationGatewaySnapshot;
readonly connection: ApplicationGatewayConnection;
readonly eventLog: readonly EventLogEntry[];
connect: (connection?: ApplicationGatewayConnectOptions) => void;
setSessionKey: (sessionKey: string) => void;
start: () => void;
stop: () => void;
subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void;
subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void;
subscribeEvents: (listener: GatewayEventListener) => () => void;
};

View 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([]);
});
});

View File

@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
handleChatDraftChange as applyDraftChange,
navigateChatInputHistory,
type ChatInputHistoryState,
} from "../pages/chat/input-history.ts";
import { createNativeChatDrafts, isWebView2, sendToNative } from "./native-bridge.ts";
type FakeBridge = {
postMessage: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
listeners: ((event: MessageEvent) => void)[];
posted: unknown[];
};
function makeBridge(): FakeBridge {
const listeners: ((event: MessageEvent) => void)[] = [];
const posted: unknown[] = [];
const bridge: FakeBridge = {
posted,
listeners,
postMessage: vi.fn((message: unknown) => posted.push(message)),
addEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => {
listeners.push(listener);
}),
removeEventListener: vi.fn((_type: string, listener: (event: MessageEvent) => void) => {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}),
};
vi.stubGlobal("chrome", { webview: bridge });
return bridge;
}
function dispatch(bridge: FakeBridge, data: unknown) {
for (const listener of bridge.listeners) {
listener({ data } as MessageEvent);
}
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("native chat drafts", () => {
it("detects WebView2 and sends native messages", () => {
expect(isWebView2()).toBe(false);
const bridge = makeBridge();
expect(isWebView2()).toBe(true);
sendToNative({ type: "ready" });
expect(bridge.posted).toEqual([{ type: "ready" }]);
});
it("registers the listener before the ready handshake", () => {
const callOrder: string[] = [];
vi.stubGlobal("chrome", {
webview: {
postMessage: vi.fn(() => callOrder.push("post")),
addEventListener: vi.fn(() => callOrder.push("listen")),
removeEventListener: vi.fn(),
},
});
createNativeChatDrafts();
expect(callOrder).toEqual(["listen", "post"]);
});
it("delivers drafts and ignores invalid messages", () => {
const bridge = makeBridge();
const drafts = createNativeChatDrafts();
const listener = vi.fn();
drafts.subscribe(listener);
dispatch(bridge, { type: "draft-text", payload: { text: "hello from native" } });
dispatch(bridge, { type: "draft-text" });
dispatch(bridge, { type: "draft-text", payload: { text: 42 } });
dispatch(bridge, { type: "unknown" });
dispatch(bridge, null);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith("hello from native");
});
it("removes the native listener and stops delivery on dispose", () => {
const bridge = makeBridge();
const drafts = createNativeChatDrafts();
const listener = vi.fn();
drafts.subscribe(listener);
drafts.dispose();
dispatch(bridge, { type: "draft-text", payload: { text: "after cleanup" } });
expect(bridge.listeners).toHaveLength(0);
expect(listener).not.toHaveBeenCalled();
});
it("applies native drafts through the real Chat draft owner", () => {
const bridge = makeBridge();
const state: ChatInputHistoryState = {
sessionKey: "s1",
chatLoading: false,
chatMessage: "",
chatMessages: [],
chatLocalInputHistoryBySession: { s1: [{ text: "previous input", ts: 1 }] },
chatInputHistorySessionKey: null,
chatInputHistoryItems: null,
chatInputHistoryIndex: -1,
chatDraftBeforeHistory: null,
};
navigateChatInputHistory(state, "up");
const drafts = createNativeChatDrafts();
drafts.subscribe((text) => applyDraftChange(state, text));
dispatch(bridge, { type: "draft-text", payload: { text: "native injection" } });
expect(state.chatMessage).toBe("native injection");
expect(state.chatInputHistoryIndex).toBe(-1);
expect(state.chatInputHistoryItems).toBeNull();
expect(state.chatInputHistorySessionKey).toBeNull();
});
});

100
ui/src/app/native-bridge.ts Normal file
View 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);
},
};
}

View File

@@ -0,0 +1,41 @@
// Control UI app-level operator scope checks.
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
export function hasOperatorReadAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return false;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.read"],
allowedScopes: auth.scopes,
});
}
export function hasOperatorWriteAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.write"],
allowedScopes: auth.scopes,
});
}
export function hasOperatorAdminAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.admin"],
allowedScopes: auth.scopes,
});
}

663
ui/src/app/overlays.ts Normal file
View 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();
},
};
}

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

View 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
View File

@@ -0,0 +1,324 @@
import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter";
import { html, LitElement, nothing } from "lit";
import { AsyncDirective } from "lit/async-directive.js";
import { property } from "lit/decorators.js";
import { directive } from "lit/directive.js";
import { t } from "../i18n/index.ts";
const PENDING_UI_DELAY_MS = 1_000;
type RenderableModule<TData> = {
render: (data: TData | undefined) => unknown;
};
export type RouterOutletOptions<TLoadContext = unknown> = {
retryContext?: TLoadContext;
};
export type RouterOutletBoundaryOptions = {
onNotFound?: () => void;
};
export type RouterOutletSelection<
TRouteId extends string = string,
TModule = unknown,
TData = unknown,
> = {
status: RouterState<TRouteId, TModule, TData>["status"];
active: RouteMatch<TRouteId, TModule, TData> | undefined;
pending: RouteMatch<TRouteId, TModule, TData> | undefined;
showPending: boolean;
};
export function selectRenderedRouteMatch<TRouteId extends string, TModule, TData>(
active: RouteMatch<TRouteId, TModule, TData> | undefined,
pending: RouteMatch<TRouteId, TModule, TData> | undefined,
): RouteMatch<TRouteId, TModule, TData> | undefined {
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
return coldPending && active ? active : (pending ?? active);
}
function selectRouterOutletState<TRouteId extends string, TModule, TData>(
state: RouterState<TRouteId, TModule, TData>,
): RouterOutletSelection<TRouteId, TModule, TData> {
return {
status: state.status,
active: state.matches[0],
pending: state.pendingMatches[0],
showPending: false,
};
}
function equalRouterOutletState(
previous: RouterOutletSelection,
next: RouterOutletSelection,
): boolean {
return (
previous.status === next.status &&
previous.active === next.active &&
previous.pending === next.pending
);
}
function isRenderableModule<TData>(module: unknown): module is RenderableModule<TData> {
return (
typeof module === "object" &&
module !== null &&
"render" in module &&
typeof module.render === "function"
);
}
function measureRoutedRender<T>(routeId: string, render: () => T): T {
const startedAt = globalThis.performance?.now() ?? 0;
const result = render();
const durationMs = Math.round((globalThis.performance?.now() ?? startedAt) - startedAt);
if (durationMs >= 16) {
console.debug("[openclaw] routed render", { routeId, durationMs });
}
return result;
}
function renderPending() {
return html`
<section class="card lazy-view-state lazy-view-state--loading" role="status">
<div class="card-title">${t("lazyView.loadingTitle")}</div>
<div class="card-sub">${t("common.loading")}</div>
</section>
`;
}
function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
router: Router<TRouteId, TLoadContext, TModule, TData>,
retryContext: TLoadContext | undefined,
error: unknown,
routeId: TRouteId,
render?: () => unknown,
) {
const routeError = error instanceof Error ? error.message : String(error);
return html`
${render?.() ?? nothing}
<div class="callout danger" role="alert">
<strong>${t("lazyView.errorTitle")}</strong>
<div>${routeError}</div>
<button
class="btn btn--sm"
@click=${() =>
retryContext === undefined
? undefined
: void router.revalidate(retryContext, routeId).catch(() => undefined)}
>
${t("lazyView.retry")}
</button>
</div>
`;
}
export function renderRouterOutlet<TRouteId extends string, TLoadContext, TModule, TData = unknown>(
router: Router<TRouteId, TLoadContext, TModule, TData>,
selection: RouterOutletSelection<TRouteId, TModule, TData>,
options: RouterOutletOptions<TLoadContext> = {},
): unknown {
const pending = selection.pending;
const renderedMatch = selectRenderedRouteMatch(selection.active, pending);
if (renderedMatch?.status === "notFound") {
return nothing;
}
if (renderedMatch?.status === "redirected") {
return nothing;
}
if (!renderedMatch) {
return nothing;
}
const routeId = renderedMatch.routeId;
if (!renderedMatch?.module) {
return renderedMatch.error
? renderError<TRouteId, TLoadContext, TModule, TData>(
router,
options.retryContext,
renderedMatch.error,
routeId,
)
: selection.showPending
? renderPending()
: nothing;
}
const routeModule = renderedMatch.module;
if (!isRenderableModule<TData>(routeModule)) {
return renderedMatch.error
? renderError<TRouteId, TLoadContext, TModule, TData>(
router,
options.retryContext,
renderedMatch.error,
routeId,
)
: null;
}
const renderedPage = () =>
measureRoutedRender(routeId, () => routeModule.render(renderedMatch.data));
return renderedMatch.error
? renderError<TRouteId, TLoadContext, TModule, TData>(
router,
options.retryContext,
renderedMatch.error,
routeId,
renderedPage,
)
: renderedPage();
}
class RouterOutletDirective extends AsyncDirective {
private router?: Router<string, unknown, unknown, unknown>;
private retryContext: unknown;
private unsubscribe?: () => void;
private boundaryOptions?: RouterOutletBoundaryOptions;
private notFoundScheduled = false;
private pendingMatchId?: string;
private pendingTimer?: ReturnType<typeof globalThis.setTimeout>;
private pendingSelection?: RouterOutletSelection;
private showPending = false;
override render(
router: unknown,
retryContext: unknown,
boundaryOptions: RouterOutletBoundaryOptions,
) {
const nextRouter = router as Router<string, unknown, unknown, unknown>;
this.updateSubscription(nextRouter);
this.router = nextRouter;
this.retryContext = retryContext;
this.boundaryOptions = boundaryOptions;
return this.renderSelection(selectRouterOutletState(nextRouter.getState()));
}
override disconnected() {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.clearPendingTimer();
this.pendingSelection = undefined;
this.boundaryOptions = undefined;
this.retryContext = undefined;
this.notFoundScheduled = false;
}
override reconnected() {
if (this.router) {
this.updateSubscription(this.router);
}
}
private updateSubscription(router: Router<string, unknown, unknown, unknown>) {
if (this.router === router && this.unsubscribe) {
return;
}
this.unsubscribe?.();
this.unsubscribe = router.subscribeSelector(
selectRouterOutletState,
(selection) => {
if (this.isConnected) {
this.setValue(this.renderSelection(selection));
}
},
equalRouterOutletState,
);
}
private renderSelection(selection: RouterOutletSelection) {
this.pendingSelection = selection;
const pending = selection.pending;
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
const needsPendingFallback = coldPending && !selection.active;
if (!needsPendingFallback) {
this.clearPendingTimer();
this.pendingMatchId = undefined;
this.showPending = false;
} else if (this.pendingMatchId !== pending.id) {
this.clearPendingTimer();
this.pendingMatchId = pending.id;
this.showPending = false;
this.pendingTimer = globalThis.setTimeout(() => {
this.pendingTimer = undefined;
const pendingSelection = this.pendingSelection;
if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) {
return;
}
this.showPending = true;
this.setValue(this.renderSelection(pendingSelection));
}, PENDING_UI_DELAY_MS);
}
if (selection.status === "notFound") {
if (!this.notFoundScheduled) {
this.notFoundScheduled = true;
queueMicrotask(() => {
this.notFoundScheduled = false;
this.boundaryOptions?.onNotFound?.();
});
}
} else {
this.notFoundScheduled = false;
}
const router = this.router;
if (!router) {
return nothing;
}
return renderRouterOutlet(
router,
{ ...selection, showPending: this.showPending },
{
retryContext: this.retryContext,
},
);
}
private clearPendingTimer() {
if (this.pendingTimer !== undefined) {
globalThis.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined;
}
}
}
const routerOutletDirective = directive(RouterOutletDirective);
export function routerOutlet<TRouteId extends string, TModule, TData, TContext>(
router: Router<TRouteId, TContext, TModule, TData>,
boundaryOptions: RouterOutletBoundaryOptions,
options: RouterOutletOptions<TContext> = {},
): unknown {
return routerOutletDirective(router, options.retryContext, boundaryOptions);
}
export class OpenClawRouterOutlet<
TRouteId extends string = string,
TLoadContext = unknown,
TModule = unknown,
TData = unknown,
> extends LitElement {
@property({ attribute: false }) router?: Router<TRouteId, TLoadContext, TModule, TData>;
@property({ attribute: false }) retryContext?: TLoadContext;
@property({ attribute: false }) onNotFound?: () => void;
override createRenderRoot() {
return this;
}
override render() {
if (!this.router) {
return nothing;
}
return routerOutlet(
this.router,
{ onNotFound: this.onNotFound },
{
retryContext: this.retryContext,
},
);
}
}
if (!customElements.get("openclaw-router-outlet")) {
customElements.define("openclaw-router-outlet", OpenClawRouterOutlet);
}

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

View 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
View 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 (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
recentSessionsCollapsed?: boolean; // Collapse recent sessions list in sidebar
borderRadius: number; // Corner roundness (0100, 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
}
}

View 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
View 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
View 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";
}

View 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();
});
});

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

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

View File

@@ -0,0 +1,93 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
const SW_READY_TIMEOUT = 10_000;
function swReady(): Promise<ServiceWorkerRegistration> {
return Promise.race([
navigator.serviceWorker.ready,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT);
}),
]);
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const output = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) {
output[i] = raw.charCodeAt(i);
}
return output;
}
export async function getExistingSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator)) {
return null;
}
const registration = await swReady();
return await registration.pushManager.getSubscription();
}
export async function subscribeToWebPush(
client: GatewayBrowserClient,
): Promise<{ subscriptionId: string }> {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error(`Notification permission ${permission}`);
}
const vapidRes = await client.request("push.web.vapidPublicKey", {});
const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey;
if (!vapidPublicKey) {
throw new Error("Failed to retrieve VAPID public key");
}
const registration = await swReady();
const pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer,
});
const subscription = pushSubscription.toJSON();
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys.auth) {
throw new Error("Invalid push subscription from browser");
}
try {
return (await client.request("push.web.subscribe", {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
})) as { subscriptionId: string };
} catch (error) {
try {
await pushSubscription.unsubscribe();
} catch {
// The Gateway error remains the actionable failure.
}
throw error;
}
}
export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise<void> {
const registration = await swReady();
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
return;
}
try {
await client.request("push.web.unsubscribe", {
endpoint: subscription.endpoint,
});
} catch {
// Local unsubscribe still prevents a stale browser subscription.
}
await subscription.unsubscribe();
}
export async function sendTestWebPush(client: GatewayBrowserClient): Promise<void> {
await client.request("push.web.test", {});
}

147
ui/src/app/web-push.ts Normal file
View File

@@ -0,0 +1,147 @@
// Application-owned browser push subscription lifecycle.
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationGateway } from "./gateway.ts";
export type WebPushSnapshot = {
supported: boolean;
permission: NotificationPermission | "unsupported";
subscribed: boolean;
loading: boolean;
error: string | null;
};
export type WebPushCapability = {
readonly snapshot: WebPushSnapshot;
subscribe: (listener: (snapshot: WebPushSnapshot) => void) => () => void;
enable: () => Promise<void>;
disable: () => Promise<void>;
sendTest: () => Promise<void>;
dispose: () => void;
};
function isWebPushSupported(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
"Notification" in window
);
}
function webPushError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createWebPushCapability(gateway: ApplicationGateway): WebPushCapability {
const supported = isWebPushSupported();
let snapshot: WebPushSnapshot = {
supported,
permission: supported ? Notification.permission : "unsupported",
subscribed: false,
loading: false,
error: null,
};
let disposed = false;
let wasConnected = false;
let operation: Promise<void> | null = null;
const listeners = new Set<(snapshot: WebPushSnapshot) => void>();
const publish = (patch: Partial<WebPushSnapshot>) => {
if (disposed) {
return;
}
snapshot = { ...snapshot, ...patch };
for (const listener of listeners) {
listener(snapshot);
}
};
const readExistingSubscription = async () => {
if (!supported) {
return null;
}
const { getExistingSubscription } = await import("./web-push.runtime.ts");
const subscription = await getExistingSubscription();
publish({ subscribed: subscription !== null });
return subscription;
};
const reconcile = async (client: GatewayBrowserClient) => {
try {
const subscription = await readExistingSubscription();
const json = subscription?.toJSON();
if (!json?.endpoint || !json.keys?.p256dh || !json.keys.auth) {
return;
}
await client.request("push.web.subscribe", {
endpoint: json.endpoint,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
});
} catch {
// Existing subscriptions are reconciled best-effort after reconnect.
}
};
const run = (action: (client: GatewayBrowserClient) => Promise<void>) => {
const client = gateway.snapshot.client;
if (!supported || !client || operation) {
return operation ?? Promise.resolve();
}
publish({ loading: true, error: null });
operation = action(client)
.catch((error: unknown) => {
publish({ error: webPushError(error) });
})
.finally(() => {
operation = null;
publish({
loading: false,
permission: "Notification" in window ? Notification.permission : "unsupported",
});
});
return operation;
};
void readExistingSubscription().catch(() => {});
const stopGateway = gateway.subscribe((gatewaySnapshot) => {
const client = gatewaySnapshot.client;
const connected = gatewaySnapshot.connected && client !== null;
if (connected && !wasConnected && client) {
void reconcile(client);
}
wasConnected = connected;
});
return {
get snapshot() {
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
enable: () =>
run(async (client) => {
const { subscribeToWebPush } = await import("./web-push.runtime.ts");
await subscribeToWebPush(client);
publish({ subscribed: true });
}),
disable: () =>
run(async (client) => {
const { unsubscribeFromWebPush } = await import("./web-push.runtime.ts");
await unsubscribeFromWebPush(client);
publish({ subscribed: false });
}),
sendTest: () =>
run(async (client) => {
const { sendTestWebPush } = await import("./web-push.runtime.ts");
await sendTestWebPush(client);
}),
dispose() {
disposed = true;
stopGateway();
listeners.clear();
},
};
}