mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
76bb7ff2b6
commit
5de08664ac
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"] });
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user