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 <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-20 22:20:12 -07:00
committed by GitHub
parent 76bb7ff2b6
commit 5de08664ac
9 changed files with 148 additions and 26 deletions
@@ -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 = () => {
+22 -3
View File
@@ -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<RouteId>): 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);
}
}
-1
View File
@@ -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();
+5
View File
@@ -226,6 +226,11 @@ export function renderApplicationShell(host: ShellViewHost) {
if (!context || !runtime) {
return nothing;
}
if (host.routeState.routeId === undefined) {
return html`<main class="connect-splash" role="status" aria-label=${t("common.loading")}>
<openclaw-mascot mood="thinking" .size=${120}></openclaw-mascot>
</main>`;
}
const gatewaySnapshot = context.gateway.snapshot;
const gatewayConnected = gatewaySnapshot.phase === "connected";
const operatorAccess = readGatewayOperatorAccess(gatewaySnapshot);
+4 -4
View File
@@ -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<void>();
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")
+34 -13
View File
@@ -324,6 +324,12 @@ export function bootstrapApplication(
!releasedSessionQuery &&
firstRunDefaultLanding &&
!parseAgentSessionKey(settings.sessionKey);
let resolveInitialFirstRunDecision: (() => void) | null = null;
const initialFirstRunDecision = deferInitialLocationUntilGateway
? new Promise<void>((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) => {
@@ -378,7 +378,7 @@ suite.define(() => {
},
]),
},
sessionKey: busyKey,
sessionKey: plainKey,
});
try {
@@ -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<string>();
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"] });
+33 -4
View File
@@ -39,6 +39,8 @@ export async function startModelSetupFirstRunRedirectAfterLocation(params: {
initialLocationReady: Promise<RouteLocation>;
installLocation?: (location: RouteLocation) => void | Promise<void>;
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<RouteId>;
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<ApplicationContext<RouteId>["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();
};
}