From 5de08664ac9340db0c5894bc86dfe3ca1e601dc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 22:20:12 -0700 Subject: [PATCH] improve(ui): skip discarded workspace loading during first-run setup (#126967) * perf(ui): defer workspace loading during first-run setup Wait for the initial model-setup decision before starting the default Chat router, and load workspace chrome only when a workspace route is visible. Keep the existing loading mascot visible while the Gateway decides the first-run destination. Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-76a1-76f1-8758-25d0466913af * fix(ui): release first-run gate on terminal connect failure Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-76a1-76f1-8758-25d0466913af * fix(ui): dedupe sidebar lazy preload Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-76a1-76f1-8758-25d0466913af --------- Co-authored-by: Amp --- ui/src/app/app-host-pairing-access.test.ts | 4 ++ ui/src/app/app-host.ts | 25 ++++++++-- ui/src/app/app-root.ts | 1 - ui/src/app/app-shell-view.ts | 5 ++ ui/src/app/bootstrap.test.ts | 8 ++-- ui/src/app/bootstrap.ts | 47 ++++++++++++++----- ...chat-flow.sidebar-presentation.e2e.test.ts | 2 +- ui/src/e2e/initial-connect-splash.e2e.test.ts | 45 ++++++++++++++++++ ui/src/pages/model-setup/first-run.ts | 37 +++++++++++++-- 9 files changed, 148 insertions(+), 26 deletions(-) diff --git a/ui/src/app/app-host-pairing-access.test.ts b/ui/src/app/app-host-pairing-access.test.ts index 66af16372c6f..f1a8c32ec5ce 100644 --- a/ui/src/app/app-host-pairing-access.test.ts +++ b/ui/src/app/app-host-pairing-access.test.ts @@ -102,6 +102,10 @@ function createPairingShell(params: { } as unknown as ApplicationContext; const shell = document.createElement("openclaw-app-shell") as PairingShell; shell.runtime = { context, router: {} } as ApplicationRuntime; + shell.routeState = { + routeId: "chat", + location: { pathname: "/chat", search: "", hash: "" }, + }; const container = document.createElement("div"); const renderSidebar = () => { diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 42fe2ddb06dd..22d0968fbc1e 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -3,7 +3,11 @@ import type { GatewayBrowserClient, GatewayEventFrame } from "../api/gateway.ts" import "../components/app-topbar.ts"; import "../components/macos-titlebar-controls.ts"; import "../components/modal-dialog.ts"; -import { formatDocumentTitle, titleForRoute } from "../app-navigation.ts"; +import { + formatDocumentTitle, + isSettingsNavigationRoute, + titleForRoute, +} from "../app-navigation.ts"; import "../components/resizable-divider.ts"; import "../components/sidebar-update-card.ts"; import "../components/update-banner.ts"; @@ -90,9 +94,15 @@ type AppSidebarElement = HTMLElement & { dismissTransientMenus: () => boolean; }; +const APP_SIDEBAR_TAG = "openclaw-app-sidebar"; // Stable references so the sidebar's enabledRouteIds property does not churn // on every shell render. const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard"); +const APP_SIDEBAR_ELEMENT = { + tagName: APP_SIDEBAR_TAG, + label: APP_SIDEBAR_TAG, + loadModule: () => import("../components/app-sidebar.ts"), +} satisfies OptionalCustomElement; i18n.setLocaleLoadRecovery({ isUnrecoverableError: isStaleChunkImportError, @@ -161,7 +171,7 @@ class OpenClawShell // Desktop and modal navigation are two slots for the same live sidebar. // Moving its element preserves session controllers and the resident pet // instead of resetting their lifecycle at every responsive breakpoint. - readonly navigationSidebar = document.createElement("openclaw-app-sidebar") as AppSidebarElement; + readonly navigationSidebar = document.createElement(APP_SIDEBAR_TAG) as AppSidebarElement; // Where "Back to app" / Escape leaves the settings takeover; falls back to // chat (the app default route) when settings was the entry point. lastWorkspaceLocation: { routeId: RouteId; pathname: string; search: string } | null = null; @@ -265,6 +275,12 @@ class OpenClawShell return routeSearch === undefined ? this.onboarding : resolveOnboardingMode(routeSearch); } + private get workspaceChromeVisible(): boolean { + const routeId = this.routeState.routeId; + // Hidden workspace chrome must not preload its sidebar and panel graphs. + return routeId !== undefined && !isSettingsNavigationRoute(routeId) && !this.onboardingMode; + } + storedOutboxScopeHost(context: ApplicationContext): StoredOutboxScopeHost { const gatewaySnapshot = context.gateway.snapshot; return { @@ -633,7 +649,7 @@ class OpenClawShell return; } const gatewaySnapshot = context.gateway?.snapshot; - if (gatewaySnapshot) { + if (gatewaySnapshot && this.workspaceChromeVisible) { const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot); // Scope-aware: openclaw.chat is operator.admin; advertisement alone would // show read-scoped clients a control the store then refuses to use. @@ -727,6 +743,9 @@ class OpenClawShell } override render() { + if (this.workspaceChromeVisible) { + this.lazyCustomElements.preload(APP_SIDEBAR_ELEMENT); + } return renderApplicationShell(this); } } diff --git a/ui/src/app/app-root.ts b/ui/src/app/app-root.ts index ba4c3aff5b42..2a9d25a700b1 100644 --- a/ui/src/app/app-root.ts +++ b/ui/src/app/app-root.ts @@ -124,7 +124,6 @@ export class OpenClawApp extends OpenClawLightDomElement { override connectedCallback() { super.connectedCallback(); - void import("../components/app-sidebar.ts"); void import("../components/session-progress-hovercard-registration.ts"); this.resetLoginSensitivePresentation(); this.runtime = bootstrapApplication(); diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 14fb307c1e70..bdfa28951861 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -226,6 +226,11 @@ export function renderApplicationShell(host: ShellViewHost) { if (!context || !runtime) { return nothing; } + if (host.routeState.routeId === undefined) { + return html`
+ +
`; + } const gatewaySnapshot = context.gateway.snapshot; const gatewayConnected = gatewaySnapshot.phase === "connected"; const operatorAccess = readGatewayOperatorAccess(gatewaySnapshot); diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts index e9aa514b9618..12a9e8e4df9c 100644 --- a/ui/src/app/bootstrap.test.ts +++ b/ui/src/app/bootstrap.test.ts @@ -772,7 +772,7 @@ describe("normalizeInitialApplicationLocation", () => { sessionKey: "main", lastActiveSessionKey: "main", }); - window.history.replaceState({}, "", "/"); + window.history.replaceState({}, "", "/settings/appearance"); const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); const pushState = vi.spyOn(window.history, "pushState"); const replaceState = vi.spyOn(window.history, "replaceState"); @@ -915,7 +915,7 @@ describe("normalizeInitialApplicationLocation", () => { sessionKey: "main", lastActiveSessionKey: "main", }); - window.history.replaceState({}, "", "/"); + window.history.replaceState({}, "", "/settings/appearance"); const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); const routerStarted = deferred(); const routerStart = vi.spyOn(runtime.router, "start").mockReturnValue(routerStarted.promise); @@ -937,7 +937,7 @@ describe("normalizeInitialApplicationLocation", () => { } }); - it("resolves runtime startup when the bare default route is not found", async () => { + it("resolves runtime startup when the initial route is not found", async () => { const previousSettings = loadSettings(); const previousUrl = window.location.href; saveSettings({ @@ -945,7 +945,7 @@ describe("normalizeInitialApplicationLocation", () => { sessionKey: "main", lastActiveSessionKey: "main", }); - window.history.replaceState({}, "", "/"); + window.history.replaceState({}, "", "/settings/about"); const runtime = bootstrapApplication({ sessionPathBuilderReady: Promise.resolve() }); const routerStart = vi .spyOn(runtime.router, "start") diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 97667a2637a7..bec8e50d1b01 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -324,6 +324,12 @@ export function bootstrapApplication( !releasedSessionQuery && firstRunDefaultLanding && !parseAgentSessionKey(settings.sessionKey); + let resolveInitialFirstRunDecision: (() => void) | null = null; + const initialFirstRunDecision = deferInitialLocationUntilGateway + ? new Promise((resolve) => { + resolveInitialFirstRunDecision = resolve; + }) + : null; const initialLocationReady = ( documentMode || focusLocation ? Promise.resolve(applicationLocation) @@ -552,20 +558,35 @@ export function bootstrapApplication( }, () => sessionPathBuilderReady, ]; - if (!deferInitialLocationUntilGateway) { - steps.push(() => - startModelSetupFirstRunRedirectAfterLocation({ - context, - enabled: firstRunRedirectEnabled, - history, - initialLocationReady, - }), - ); - } + // Resolve first-run setup before routing: the default Chat route owns the + // workspace graph, which setup users would otherwise fetch and discard. + steps.push(() => + startModelSetupFirstRunRedirectAfterLocation({ + context, + enabled: firstRunRedirectEnabled, + history, + initialLocationReady: deferInitialLocationUntilGateway + ? Promise.resolve(applicationLocation) + : initialLocationReady, + ...(deferInitialLocationUntilGateway + ? { + redirect: () => + history.replace({ + ...locationForRoute("model-setup", basePath), + search: "?firstRun=1", + }), + onInitialDecision: () => resolveInitialFirstRunDecision?.(), + } + : {}), + }), + ); steps.push(() => { void config.refresh({ skipWithoutAuthCandidate: true }); }); if (startsApplicationRouter) { + if (initialFirstRunDecision) { + steps.push(() => initialFirstRunDecision); + } steps.push(async () => { const pendingNavigation = pendingRouterStartNavigation; pendingRouterStartNavigation = null; @@ -579,12 +600,12 @@ export function bootstrapApplication( } if (deferInitialLocationUntilGateway) { steps.push(() => { - // The bare /chat route remains not-found while disconnected. Its shell - // fallback is gated on the same connected defaults, so both paths converge. + // The router claims the connected Gateway session before persisted + // location normalization can install a competing retained Chat pane. startupLifecycle.trackDisposer( startModelSetupFirstRunRedirectAfterLocation({ context, - enabled: firstRunRedirectEnabled, + enabled: false, history, initialLocationReady, installLocation: async (location) => { diff --git a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts index 9b3e34cb000f..38295a65b490 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -378,7 +378,7 @@ suite.define(() => { }, ]), }, - sessionKey: busyKey, + sessionKey: plainKey, }); try { diff --git a/ui/src/e2e/initial-connect-splash.e2e.test.ts b/ui/src/e2e/initial-connect-splash.e2e.test.ts index 8fca0a6f7526..922f384b4f90 100644 --- a/ui/src/e2e/initial-connect-splash.e2e.test.ts +++ b/ui/src/e2e/initial-connect-splash.e2e.test.ts @@ -212,6 +212,51 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { expect(await loginGateMounted()).toBe(false); }); + it("does not load the discarded workspace before a first-run setup redirect", async () => { + const page = await createPage(); + const workspaceModules = new Set([ + "/src/components/app-sidebar.ts", + "/src/components/browser/browser-panel.ts", + "/src/components/custodian/custodian-panel.ts", + "/src/components/desktop/desktop-panel.ts", + "/src/components/terminal/terminal-panel-registration.ts", + "/src/pages/chat/chat-page.ts", + ]); + const requestedWorkspaceModules = new Set(); + page.on("request", (request) => { + const pathname = new URL(request.url()).pathname; + if (workspaceModules.has(pathname)) { + requestedWorkspaceModules.add(pathname); + } + }); + const gateway = await installMockGateway(page, { + deferredMethods: ["openclaw.setup.detect"], + featureMethods: [ + "browser.request", + "desktop.observe", + "openclaw.chat", + "openclaw.setup.detect", + "terminal.open", + ], + terminalEnabled: true, + }); + + await page.goto(server.baseUrl); + await gateway.waitForRequest("openclaw.setup.detect"); + await page.locator(".connect-splash").waitFor(); + expect([...requestedWorkspaceModules]).toEqual([]); + + await gateway.resolveDeferred("openclaw.setup.detect", { + candidates: [], + manualProviders: [], + setupComplete: false, + workspace: "/tmp/openclaw-e2e", + }); + await page.getByRole("heading", { name: "Connect a verified AI model" }).waitFor(); + expect(new URL(page.url()).pathname).toBe("/settings/model-setup"); + expect([...requestedWorkspaceModules]).toEqual([]); + }); + it("falls back to the login gate when stored credentials are rejected", async () => { const page = await createPage(); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); diff --git a/ui/src/pages/model-setup/first-run.ts b/ui/src/pages/model-setup/first-run.ts index 05f8db76a8f1..c9bc313b70d5 100644 --- a/ui/src/pages/model-setup/first-run.ts +++ b/ui/src/pages/model-setup/first-run.ts @@ -39,6 +39,8 @@ export async function startModelSetupFirstRunRedirectAfterLocation(params: { initialLocationReady: Promise; installLocation?: (location: RouteLocation) => void | Promise; shouldInstallLocation?: () => boolean; + redirect?: () => void; + onInitialDecision?: () => void; }): Promise<() => void> { const initialLocation = await params.initialLocationReady; if ( @@ -52,17 +54,23 @@ export async function startModelSetupFirstRunRedirectAfterLocation(params: { } } if (!params.enabled) { + params.onInitialDecision?.(); return () => undefined; } return startModelSetupFirstRunRedirect({ context: params.context, isStillDefaultLanding: () => locationsMatch(params.history.location(), initialLocation), + redirect: + params.redirect ?? (() => params.context.replace("model-setup", { search: "?firstRun=1" })), + onInitialDecision: params.onInitialDecision ?? (() => undefined), }); } function startModelSetupFirstRunRedirect(params: { context: ApplicationContext; isStillDefaultLanding: () => boolean; + redirect: () => void; + onInitialDecision: () => void; }): () => void { let detection: | { @@ -73,16 +81,33 @@ function startModelSetupFirstRunRedirect(params: { | undefined; let redirected = false; let disposed = false; + let initialDecisionSettled = false; + const settleInitialDecision = () => { + if (!initialDecisionSettled) { + initialDecisionSettled = true; + params.onInitialDecision(); + } + }; const handleSnapshot: Parameters["gateway"]["subscribe"]>[0] = ( snapshot, ) => { + if (redirected) { + return; + } + if (snapshot.phase !== "connected" || !snapshot.client) { + // A build fence can move a previously authenticated client straight into + // reconnecting or reload-required, while a terminal first attempt returns + // to stopped. Do not hold the router when the shell needs to present recovery. + if (snapshot.hello || snapshot.phase === "reload-required" || snapshot.phase === "stopped") { + settleInitialDecision(); + } + return; + } if ( - redirected || - snapshot.phase !== "connected" || - !snapshot.client || !hasOperatorAdminAccess(snapshot.hello?.auth ?? null) || isGatewayMethodAdvertised(snapshot, "openclaw.setup.detect") !== true ) { + settleInitialDecision(); return; } const agentId = params.context.agentSelection.state.selectedId; @@ -118,8 +143,9 @@ function startModelSetupFirstRunRedirect(params: { cacheModelSetupDetection(connection, result); if (!result.setupComplete && !redirected && params.isStillDefaultLanding()) { redirected = true; - params.context.replace("model-setup", { search: "?firstRun=1" }); + params.redirect(); } + settleInitialDecision(); }) .catch(() => { if (disposed || detection !== attempt) { @@ -130,6 +156,8 @@ function startModelSetupFirstRunRedirect(params: { detection = { ...attempt, phase: attempt.attempts < 2 ? "retry-ready" : "settled" }; if (detection.phase === "retry-ready" && params.isStillDefaultLanding()) { handleSnapshot(params.context.gateway.snapshot); + } else { + settleInitialDecision(); } }); }; @@ -142,5 +170,6 @@ function startModelSetupFirstRunRedirect(params: { disposed = true; unsubscribe(); unsubscribeSelection(); + settleInitialDecision(); }; }