diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index f949e7da97db..25300ab7751c 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -287,9 +287,11 @@ stays visible with a floating amber "Gateway connection lost — Reconnecting…
bar while the client retries automatically with backoff (800 ms up to 15 s). Live updates and
actions pause until the connection returns; **Retry now** in the pill forces an immediate attempt.
-The login gate only appears when there is no established session yet (first open, page reload
-before connect) or when the Gateway actively rejects the credentials (bad token/password, revoked
-pairing) — states that need your input rather than waiting.
+When this browser already holds credentials (a configured token/password or an approved device
+token), first opens and reloads show a small animated OpenClaw mark while the connection is
+established instead of flashing the login gate. The login gate only appears when no credentials
+are stored yet or when the Gateway actively rejects them (bad token/password, revoked pairing) —
+states that need your input rather than waiting.
## PWA install and web push
diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts
index 144abb358b7e..ef02ed478375 100644
--- a/ui/src/api/gateway.ts
+++ b/ui/src/api/gateway.ts
@@ -26,6 +26,7 @@ import {
loadDeviceAuthToken,
storeDeviceAuthToken,
loadOrCreateDeviceIdentity,
+ peekStoredDeviceIdentityId,
signDevicePayload,
} from "../lib/nodes/index.ts";
import { generateUUID } from "../lib/uuid.ts";
@@ -478,6 +479,52 @@ async function buildGatewayConnectDevice(params: {
};
}
+// Operator connects only trust stored device tokens that can at least read;
+// weaker tokens go through the pairing upgrade flow instead of silent auth.
+function storedDeviceTokenScopesAllowRead(role: string, scopes: readonly string[]): boolean {
+ return (
+ role !== CONTROL_UI_OPERATOR_ROLE ||
+ scopes.includes("operator.read") ||
+ scopes.includes("operator.write") ||
+ scopes.includes("operator.admin")
+ );
+}
+
+/**
+ * True when the next connect() from this browser would present stored
+ * credentials: an explicit token/password, or a readable stored device token
+ * in a secure context. Render gating only — lets the app paint a connecting
+ * state instead of flashing the login gate while a likely-authenticated first
+ * attempt is in flight. connect() remains the source of truth for auth.
+ */
+export function hasStoredGatewayAuth(params: {
+ gatewayUrl: string;
+ token?: string;
+ password?: string;
+}): boolean {
+ if (params.token?.trim() || params.password?.trim()) {
+ return true;
+ }
+ // Mirrors buildConnectPlan: insecure contexts skip device identity, so a
+ // stored device token would not be presented and must not suppress the gate.
+ if (typeof crypto === "undefined" || !crypto.subtle) {
+ return false;
+ }
+ const deviceId = peekStoredDeviceIdentityId();
+ if (!deviceId) {
+ return false;
+ }
+ const storedEntry = loadDeviceAuthToken({
+ deviceId,
+ gatewayUrl: params.gatewayUrl,
+ role: CONTROL_UI_OPERATOR_ROLE,
+ });
+ if (!storedEntry) {
+ return false;
+ }
+ return storedDeviceTokenScopesAllowRead(CONTROL_UI_OPERATOR_ROLE, storedEntry.scopes);
+}
+
export function shouldRetryWithDeviceToken(params: DeviceTokenRetryDecision): boolean {
return (
!params.deviceTokenRetryBudgetUsed &&
@@ -1048,12 +1095,10 @@ export class GatewayBrowserClient {
gatewayUrl: this.opts.url,
role: params.role,
});
- const storedScopes = storedEntry?.scopes ?? [];
- const storedTokenCanRead =
- params.role !== CONTROL_UI_OPERATOR_ROLE ||
- storedScopes.includes("operator.read") ||
- storedScopes.includes("operator.write") ||
- storedScopes.includes("operator.admin");
+ const storedTokenCanRead = storedDeviceTokenScopesAllowRead(
+ params.role,
+ storedEntry?.scopes ?? [],
+ );
const storedToken = storedTokenCanRead ? storedEntry?.token : undefined;
const shouldUseDeviceRetryToken =
this.pendingDeviceTokenRetry &&
diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts
index 71c8ead723c9..71c0fe540faf 100644
--- a/ui/src/app/app-host.ts
+++ b/ui/src/app/app-host.ts
@@ -2,7 +2,7 @@ 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 { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts";
import type { AgentsListResult } from "../api/types.ts";
import "../components/app-sidebar.ts";
import "../components/app-topbar.ts";
@@ -39,6 +39,7 @@ import {
} from "./context.ts";
import { hasOperatorAdminAccess } from "./operator-access.ts";
import type { ApplicationOverlaySnapshot } from "./overlays.ts";
+import { controlUiPublicAssetPath } from "./public-assets.ts";
import { selectRenderedRouteMatch } from "./router-outlet.ts";
type ShellRouteState = {
@@ -99,6 +100,20 @@ function resolveTerminalThemeMode(): "dark" | "light" {
return document.documentElement.dataset.themeMode === "light" ? "light" : "dark";
}
+// The mascot SVG animates via SMIL, so it must load through
—
+// inlining the markup would freeze it (see ui/public/favicon.svg).
+function renderConnectingSplash(basePath: string) {
+ return html`
+
+
+
+ `;
+}
+
function isTerminalAvailable(
snapshot: ApplicationContext["gateway"]["snapshot"],
terminalEnabled: boolean,
@@ -131,6 +146,10 @@ export class OpenClawApp extends LitElement {
@state() private terminalClient: GatewayBrowserClient | null = null;
private readonly terminalOnly = isTerminalOnlyView();
+ // Fixed at page load: whether this browser held credentials (token,
+ // password, or stored device token) before the first connect attempt.
+ // Later manual gate submissions are covered by loginGatePinned instead.
+ private initialAuthPresent = false;
private runtime: ApplicationRuntime | undefined;
private context: ApplicationContext | undefined;
private readonly contextProvider = new ContextProvider(this, {
@@ -147,6 +166,7 @@ export class OpenClawApp extends LitElement {
super.connectedCallback();
this.runtime = bootstrapApplication();
this.context = this.runtime.context;
+ this.initialAuthPresent = hasStoredGatewayAuth(this.context.gateway.connection);
this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null;
this.contextProvider.setValue(this.context);
this.syncLoginConnection();
@@ -262,7 +282,24 @@ export class OpenClawApp extends LitElement {
}
// Transport drops after an established session keep the shell mounted
// (offline banner + client auto-retry); the login gate is reserved for
- // first connects, credential rejections, and manual gate submissions.
+ // credential-less first connects, credential rejections, and manual gate
+ // submissions. A first connect backed by stored credentials paints the
+ // connecting splash instead of flashing the login gate; the gate returns
+ // the moment the attempt fails (lastError set on every close).
+ const initialConnectPending =
+ this.initialAuthPresent &&
+ !this.gatewayConnected &&
+ !this.gatewayReconnecting &&
+ !this.loginGatePinned &&
+ this.gatewayLastError === null &&
+ context.gateway.snapshot.client !== null;
+ if (initialConnectPending) {
+ return html`
+
+ ${renderConnectingSplash(context.basePath)} ${gatewayUrlConfirmation}
+
+ `;
+ }
const showLoginGate =
!this.gatewayConnected && (this.loginGatePinned || !this.gatewayReconnecting);
if (showLoginGate) {
diff --git a/ui/src/e2e/initial-connect-splash.e2e.test.ts b/ui/src/e2e/initial-connect-splash.e2e.test.ts
new file mode 100644
index 000000000000..4c58d9f31ba9
--- /dev/null
+++ b/ui/src/e2e/initial-connect-splash.e2e.test.ts
@@ -0,0 +1,113 @@
+// Control UI tests cover the initial-connect splash shown instead of the
+// login gate while a first connect backed by stored credentials is in flight.
+import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
+import {
+ canRunPlaywrightChromium,
+ installMockGateway,
+ resolvePlaywrightChromiumExecutablePath,
+ startControlUiE2eServer,
+ type ControlUiE2eServer,
+} from "../test-helpers/control-ui-e2e.ts";
+
+const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
+const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
+const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
+const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
+
+let browser: Browser;
+let server: ControlUiE2eServer;
+const openContexts = new Set();
+
+async function createPage(): Promise {
+ const context = await browser.newContext({ viewport: { height: 900, width: 1280 } });
+ openContexts.add(context);
+ return await context.newPage();
+}
+
+describeControlUiE2e("Control UI initial connect splash E2E", () => {
+ beforeAll(async () => {
+ if (!chromiumAvailable) {
+ throw new Error(
+ `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`,
+ );
+ }
+ server = await startControlUiE2eServer();
+ browser = await chromium.launch({ executablePath: chromiumExecutablePath });
+ });
+
+ afterAll(async () => {
+ await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
+ await browser?.close();
+ await server?.close();
+ });
+
+ afterEach(async () => {
+ await Promise.all([...openContexts].map((context) => context.close().catch(() => {})));
+ openContexts.clear();
+ });
+
+ it("shows the splash instead of the login gate while a configured token connects", async () => {
+ const page = await createPage();
+ const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
+
+ await page.goto(`${server.baseUrl}#token=e2e-shared-token`);
+ await gateway.waitForRequest("connect");
+ await page.locator(".connect-splash").waitFor();
+ expect(await page.locator("openclaw-login-gate").count()).toBe(0);
+
+ await gateway.resolveDeferred("connect");
+ await page.locator("openclaw-app-shell").waitFor();
+ expect(await page.locator(".connect-splash").count()).toBe(0);
+ });
+
+ it("keeps the login gate for first connects without stored credentials", async () => {
+ const page = await createPage();
+ const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
+
+ await page.goto(server.baseUrl);
+ await gateway.waitForRequest("connect");
+ await page.locator("openclaw-login-gate").waitFor();
+ expect(await page.locator(".connect-splash").count()).toBe(0);
+ });
+
+ it("falls back to the login gate when stored credentials are rejected", async () => {
+ const page = await createPage();
+ const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
+
+ await page.goto(`${server.baseUrl}#token=stale-token`);
+ await gateway.waitForRequest("connect");
+ await page.locator(".connect-splash").waitFor();
+
+ await gateway.rejectDeferred("connect", {
+ code: "UNAUTHORIZED",
+ message: "unauthorized: gateway token mismatch",
+ details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH },
+ });
+ await page.locator("openclaw-login-gate").waitFor();
+ expect(await page.locator(".connect-splash").count()).toBe(0);
+ });
+
+ it("uses the splash for a stored device token on reload", async () => {
+ const page = await createPage();
+ const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
+
+ // First visit has no credentials: the login gate owns the pending connect.
+ await page.goto(server.baseUrl);
+ await gateway.waitForRequest("connect");
+ await page.locator("openclaw-login-gate").waitFor();
+ await gateway.resolveDeferred("connect");
+ await page.locator("openclaw-app-shell").waitFor();
+
+ // The hello stored a device token, so the reload connect is authenticated
+ // and must paint the splash instead of flashing the gate.
+ await page.reload();
+ await gateway.waitForRequest("connect");
+ await page.locator(".connect-splash").waitFor();
+ expect(await page.locator("openclaw-login-gate").count()).toBe(0);
+
+ await gateway.resolveDeferred("connect");
+ await page.locator("openclaw-app-shell").waitFor();
+ });
+});
diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts
index ba757b029b8c..24d25efbc527 100644
--- a/ui/src/lib/nodes/index.ts
+++ b/ui/src/lib/nodes/index.ts
@@ -651,6 +651,26 @@ async function generateIdentity(): Promise {
};
}
+/**
+ * Synchronous identity probe for render gating: reads the stored device id
+ * without creating, repairing, or fingerprint-verifying an identity, so a
+ * "do we hold credentials?" check stays side-effect free before connect().
+ */
+export function peekStoredDeviceIdentityId(): string | null {
+ try {
+ const raw = getSafeLocalStorage()?.getItem(DEVICE_IDENTITY_STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw) as StoredIdentity;
+ return parsed?.version === 1 && typeof parsed.deviceId === "string" && parsed.deviceId
+ ? parsed.deviceId
+ : null;
+ } catch {
+ return null;
+ }
+}
+
export async function loadOrCreateDeviceIdentity(): Promise {
const storage = getSafeLocalStorage();
try {
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css
index b2af1ce1717c..2a5e8b1f4be3 100644
--- a/ui/src/styles/components.css
+++ b/ui/src/styles/components.css
@@ -1,3 +1,47 @@
+/* ===========================================
+ Connect Splash (first connect with stored credentials)
+ =========================================== */
+
+.connect-splash {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ height: 100dvh;
+ max-height: 100%;
+ background: var(--bg);
+ /* Stay invisible past fast handshakes so a quick connect paints nothing. */
+ opacity: 0;
+ animation: fade-in 0.3s var(--ease-out) 0.2s forwards;
+}
+
+.connect-splash__logo {
+ width: 44px;
+ height: 44px;
+ animation: connect-splash-wiggle 1.8s ease-in-out infinite;
+}
+
+@keyframes connect-splash-wiggle {
+ 0%,
+ 100% {
+ transform: rotate(-6deg);
+ }
+ 50% {
+ transform: rotate(6deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .connect-splash {
+ opacity: 1;
+ animation: none;
+ }
+
+ .connect-splash__logo {
+ animation: none;
+ }
+}
+
/* ===========================================
Login Gate
=========================================== */
@@ -633,7 +677,6 @@
color: var(--danger);
}
-
/* ===========================================
Status Dot - With glow for emphasis
=========================================== */