fix(linux): keep first-run onboarding alive when the gateway restarts (#129502)

* fix(linux): preserve onboarding through gateway restarts

* refactor(ui): keep model setup below file-size limit

* test: isolate plugin metadata and register startup retry coverage
This commit is contained in:
Peter Steinberger
2026-08-25 15:30:16 -07:00
committed by GitHub
parent 34d5309785
commit 18fba50e84
7 changed files with 460 additions and 26 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ The app uses `OPENCLAW_DESKTOP_CLI` when set. Otherwise it checks `~/.openclaw/b
Desktop notifications use each platform's system notification service. macOS 13+ uses Apple's User Notifications framework; Windows uses native system toasts and Linux uses the desktop notification service through `notify-rust`. On macOS, test notifications from a signed `.app` bundle: a direct `cargo run` stays unbundled, so the app disables notifications instead of initializing Apple's framework with no bundle identity.
On first run, release builds automatically install the stable CLI channel, while development builds ask for a release channel and preselect Development. After the CLI install, the app opens Model Setup, automatically checks existing credentials, and verifies a real model response before continuing. A working existing model opens the normal dashboard; newly configured AI access continues into guided onboarding. If no existing credentials work, choose a provider, sign in, or enter an API key in Model Setup. Reconnects and later app launches use the normal dashboard URL.
On first run, release builds automatically install the stable CLI channel, while development builds ask for a release channel and preselect Development. After the CLI install, the app opens Model Setup, automatically checks existing credentials, and verifies a real model response before continuing. A working existing model opens the normal dashboard; newly configured AI access continues into guided onboarding. If no existing credentials work, choose a provider, sign in, or enter an API key in Model Setup. In-progress model setup and guided onboarding survive Gateway restarts. After setup, reconnects and later app launches use the normal dashboard URL.
## Updates
+78 -6
View File
@@ -35,6 +35,28 @@ use tauri_plugin_global_shortcut::{Code, Modifiers};
const CONNECTED_WATCH_INTERVAL: Duration = Duration::from_secs(15);
const RECONNECT_INTERVAL: Duration = Duration::from_secs(3);
fn is_active_onboarding_url(url: &Url) -> bool {
let path = url.path().trim_end_matches('/');
let query_key = if path.ends_with("/settings/model-setup") {
"firstRun"
} else if path.ends_with("/custodian") {
"onboarding"
} else {
return false;
};
url.query_pairs()
.find(|(key, _)| key == query_key)
.is_some_and(|(_, value)| {
if query_key == "firstRun" {
return value == "1";
}
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BuildInfo {
@@ -538,11 +560,19 @@ impl DesktopState {
continue;
}
// Onboarding keeps verification and guided-session state in its live page. Latch it
// for this outage so neither recovery screen nor dashboard reload erases that state.
let preserve_dashboard = main_window(&app)
.ok()
.and_then(|window| window.url().ok())
.is_some_and(|url| is_active_onboarding_url(&url));
let mut displayed_phase = snapshot.phase;
if matches!(
state.show_local(&app, local_mode(&snapshot), false, Some(generation)),
Ok(false)
) {
if !preserve_dashboard
&& matches!(
state.show_local(&app, local_mode(&snapshot), false, Some(generation)),
Ok(false)
)
{
return;
}
state.update_tray(&snapshot);
@@ -561,6 +591,10 @@ impl DesktopState {
if let Ok(ready) = gateway::dashboard(&cli, snapshot) {
app.state::<gateway_ws::GatewayClient>()
.configure(&app, ready.gateway_ws.clone());
if preserve_dashboard {
state.update_tray(&ready.snapshot);
break;
}
match state.navigate_local(
&app,
&ready.dashboard_url,
@@ -577,7 +611,7 @@ impl DesktopState {
Err(_) => {}
}
}
} else if snapshot.phase != displayed_phase {
} else if !preserve_dashboard && snapshot.phase != displayed_phase {
displayed_phase = snapshot.phase;
if matches!(
state.show_local(&app, local_mode(&snapshot), false, Some(generation),),
@@ -603,7 +637,43 @@ fn local_mode(snapshot: &GatewaySnapshot) -> &'static str {
#[cfg(test)]
mod navigation_tests {
use super::{is_release_version, NavigationState};
use super::{is_active_onboarding_url, is_release_version, NavigationState, Url};
#[test]
fn only_active_onboarding_preserves_the_dashboard_during_reconnect() {
for (url, preserve) in [
("http://127.0.0.1/settings/model-setup?firstRun=1", true),
(
"http://127.0.0.1/openclaw/settings/model-setup/?tab=ai&firstRun=1#token=redacted",
true,
),
("http://127.0.0.1/settings/model-setup", false),
("http://127.0.0.1/settings/model-setup?firstRun=0", false),
(
"http://127.0.0.1/settings/model-setup?firstRun=0&firstRun=1",
false,
),
("http://127.0.0.1/settings/providers?firstRun=1", false),
("http://127.0.0.1/custodian?onboarding=1", true),
(
"http://127.0.0.1/openclaw/custodian/?tab=chat&onboarding=YES",
true,
),
("http://127.0.0.1/custodian", false),
("http://127.0.0.1/custodian?onboarding=0", false),
(
"http://127.0.0.1/custodian?onboarding=0&onboarding=1",
false,
),
("http://127.0.0.1/chat?onboarding=1", false),
] {
assert_eq!(
is_active_onboarding_url(&Url::parse(url).expect("dashboard URL")),
preserve,
"unexpected reconnect policy for {url}"
);
}
}
#[test]
fn committed_package_version_is_a_development_build() {
@@ -679,8 +749,10 @@ mod navigation_tests {
assert_eq!(first.path(), "/settings/model-setup");
assert_eq!(first.query(), Some("firstRun=1"));
assert!(is_active_onboarding_url(&first));
assert_eq!(second.path(), "/");
assert_eq!(second.query(), None);
assert!(!is_active_onboarding_url(&second));
}
#[test]
+60 -1
View File
@@ -50,6 +50,15 @@ function fixtureFiles(): Record<string, string> {
const agentEventsPath = JSON.stringify(path.join(repoRoot, "src", "infra", "agent-events.ts"));
const loggingConsolePath = JSON.stringify(path.join(repoRoot, "src", "logging", "console.ts"));
const loggingStatePath = JSON.stringify(path.join(repoRoot, "src", "logging", "state.ts"));
const pluginMetadataStatePath = JSON.stringify(
path.join(repoRoot, "src", "plugins", "current-plugin-metadata-state.ts"),
);
const pluginMetadataRuntimePath = JSON.stringify(
path.join(repoRoot, "src", "plugins", "plugin-metadata-snapshot.runtime.ts"),
);
const pluginMetadataLifecyclePath = JSON.stringify(
path.join(repoRoot, "src", "plugins", "plugin-metadata-lifecycle.ts"),
);
return {
"01-dep.ts": 'export function flavor(): string {\n return "real";\n}\n',
@@ -196,6 +205,56 @@ function fixtureFiles(): Record<string, string> {
"});",
"",
].join("\n"),
"07-a-plugin-metadata.test.ts": [
`import { setCurrentPluginMetadataSnapshotState } from ${pluginMetadataStatePath};`,
`import { getCurrentPluginMetadataSnapshotRuntime, registerPluginMetadataSnapshotReaders } from ${pluginMetadataRuntimePath};`,
`import { registerPluginMetadataProcessMemoLifecycleClear } from ${pluginMetadataLifecyclePath};`,
'import { expect, it } from "vitest";',
'it("seeds process-global plugin metadata readers and their snapshot", () => {',
' const snapshot = { source: "first-file" };',
' const readerKey = Symbol.for("openclaw.pluginMetadataSnapshotReaders");',
' const probeKey = Symbol.for("openclaw.test.pluginMetadataLifecycle");',
" const store = globalThis as Record<PropertyKey, unknown>;",
" const probe = { clears: 0, readers: store[readerKey] };",
" store[probeKey] = probe;",
" registerPluginMetadataProcessMemoLifecycleClear(() => { probe.clears += 1; });",
' setCurrentPluginMetadataSnapshotState(snapshot, "first-file");',
" registerPluginMetadataSnapshotReaders({",
" getCurrentPluginMetadataSnapshot: () => snapshot,",
" resolvePluginMetadataSnapshot: () => snapshot,",
" });",
" expect(getCurrentPluginMetadataSnapshotRuntime({ config: {} })).toBe(snapshot);",
"});",
"",
].join("\n"),
"07-b-plugin-metadata.test.ts": [
`import { getCurrentPluginMetadataSnapshotState } from ${pluginMetadataStatePath};`,
`import { getCurrentPluginMetadataSnapshotRuntime } from ${pluginMetadataRuntimePath};`,
'import { expect, it } from "vitest";',
'it("clears process-global plugin metadata snapshots and reader closures", () => {',
' const readerKey = Symbol.for("openclaw.pluginMetadataSnapshotReaders");',
' const probeKey = Symbol.for("openclaw.test.pluginMetadataLifecycle");',
" const store = globalThis as Record<PropertyKey, unknown>;",
" const probe = store[probeKey] as { clears: number; readers: object };",
" const readers = store[readerKey] as Record<string, unknown>;",
" expect({",
" clears: probe.clears,",
" getterPresent: typeof readers.getCurrentPluginMetadataSnapshot === 'function',",
" resolverPresent: typeof readers.resolvePluginMetadataSnapshot === 'function',",
" readerIdentityRetained: readers === probe.readers,",
" snapshot: getCurrentPluginMetadataSnapshotState().snapshot,",
" readerSnapshot: getCurrentPluginMetadataSnapshotRuntime({ config: {} }),",
" }).toEqual({",
" clears: 1,",
" getterPresent: false,",
" resolverPresent: false,",
" readerIdentityRetained: true,",
" snapshot: undefined,",
" readerSnapshot: undefined,",
" });",
"});",
"",
].join("\n"),
};
}
@@ -251,7 +310,7 @@ it("cleans every shared runner surface between files", async () => {
// The collection failure is intentional. Every behavior test after it must
// pass; any leaked surface turns the summary into a second failure.
expect(output).toContain("synthetic collect failure");
expect(output).toContain("1 failed | 11 passed");
expect(output).toContain("1 failed | 13 passed");
expect(output).not.toContain("first-file");
} finally {
await fs.rm(root, { recursive: true, force: true });
+30 -1
View File
@@ -4,6 +4,8 @@ import { TestRunner, type RunnerTask, type RunnerTestFile, vi } from "vitest";
import { resetAgentEventsForTest } from "../src/infra/agent-events.js";
import { loggingState } from "../src/logging/state.js";
import { clearNamedPluginRuntimeStoresForTest } from "../src/plugin-sdk/runtime-store-registry.js";
import { clearCurrentPluginMetadataSnapshot } from "../src/plugins/current-plugin-metadata-state.js";
import { registerPluginMetadataSnapshotReaders } from "../src/plugins/plugin-metadata-snapshot.runtime.js";
import {
type CustomElementTracking,
dropRepoOwnedCustomElements,
@@ -332,6 +334,31 @@ function resetOpenClawSessionSuspensionState(): void {
api?.resetSessionSuspensionStateForTest?.();
}
function resetOpenClawPluginMetadataState(modules: EvaluatedModules): void {
let clearLifecycle: (() => void) | undefined;
for (const [modulePath, module] of modules.idToModuleMap) {
if (
modulePath.startsWith("mock:") ||
!/\/src\/plugins\/plugin-metadata-lifecycle\.(?:ts|js)(?:\?.*)?$/u.test(modulePath) ||
!module.exports ||
typeof module.exports !== "object"
) {
continue;
}
const clear = Reflect.get(module.exports, "clearPluginMetadataLifecycleCaches");
if (typeof clear === "function") {
clearLifecycle = clear;
}
}
(clearLifecycle ?? clearCurrentPluginMetadataSnapshot)();
// Existing lightweight bridges retain the slot, so reset reader closures in place.
registerPluginMetadataSnapshotReaders({
getCurrentPluginMetadataSnapshot: undefined,
resolvePluginMetadataSnapshot: undefined,
});
}
const SERIALIZED_RESOLVE_MOCKS = Symbol.for("openclaw.serializedResolveMocks");
type SerializedResolveMocksState = {
@@ -473,10 +500,12 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
// Named plugin runtimes intentionally survive duplicate module evaluation in production.
// Clear their shared slots here so one test file cannot lend a partial runtime to the next.
clearNamedPluginRuntimeStoresForTest();
const evaluatedModules = internals.workerState.evaluatedModules as EvaluatedModules;
resetOpenClawPluginMetadataState(evaluatedModules);
dropTrackedRepoOwnedCustomElements();
resetSharedDocumentBody();
vi.resetModules();
internals.moduleRunner?.mocker?.reset?.();
resetEvaluatedModules(internals.workerState.evaluatedModules as EvaluatedModules, true);
resetEvaluatedModules(evaluatedModules, true);
}
}
@@ -88,6 +88,14 @@ export class FirstRunAutoSetup {
}
}
retryDetection(): void {
if (this.host.routeData()?.firstRun && !this.host.actionsDisabled()) {
this.pendingRestart = null;
this.host.setRefreshWarning(null);
this.reset();
}
}
dispose(): void {
this.routeChanged();
}
@@ -123,6 +131,13 @@ export class FirstRunAutoSetup {
return;
}
const configured = pageState.result.setupComplete && pageState.result.configuredModel;
if (this.pendingRestart && !configured) {
this.started = true;
this.host.setRefreshWarning(
`${t("modelSetup.errors.activationFailed")} ${this.pendingRestart.modelRef}. ${t("modelSetup.checkAgain")}.`,
);
return;
}
if (configured && !this.host.canVerify(snapshot.client)) {
this.started = true;
this.host.setVerifyState({
@@ -172,6 +187,10 @@ export class FirstRunAutoSetup {
private async run(owner: FirstRunOwner, detection: SystemAgentSetupDetectResult): Promise<void> {
if (detection.setupComplete && detection.configuredModel) {
if (this.pendingRestart && detection.configuredModel !== this.pendingRestart.modelRef) {
this.failPendingActivation(this.pendingRestart);
return;
}
const outcome = await this.host.verify();
if (!this.owns(owner) || !outcome || "error" in outcome) {
return;
@@ -200,6 +219,13 @@ export class FirstRunAutoSetup {
return;
}
this.attempts.add(targetId);
// Activation can commit before its response; retain exact intent across
// the same Gateway's reconnect without repeating an ambiguous mutation.
this.pendingRestart = {
routeData: owner.routeData,
connection: owner.connection,
modelRef: candidate.modelRef,
};
const outcome = await this.host.activate(candidate, targetId);
if (!this.owns(owner) || !outcome || "error" in outcome) {
return;
@@ -207,14 +233,11 @@ export class FirstRunAutoSetup {
if (!outcome.value.result.ok) {
// A rejected transport may still commit; only a definitive Gateway
// failure permits dispatching another provider activation.
this.pendingRestart = null;
continue;
}
if (outcome.value.result.gatewayRestartRequired && outcome.value.result.modelRef) {
this.pendingRestart = {
routeData: owner.routeData,
connection: owner.connection,
modelRef: outcome.value.result.modelRef,
};
this.pendingRestart.modelRef = outcome.value.result.modelRef;
// Keep the completed attempt closed until a replacement hello owns
// detection and exact-model verification; the old socket is unsafe.
this.host.setActivationState({
@@ -225,6 +248,7 @@ export class FirstRunAutoSetup {
this.host.setRefreshWarning(outcome.value.refreshError ?? t("updates.dialog.restarting"));
return;
}
this.pendingRestart = null;
if (this.host.activationSuccessful() && !outcome.value.refreshError) {
context.navigate("custodian", { search: "?onboarding=1" });
}
@@ -232,6 +256,16 @@ export class FirstRunAutoSetup {
}
}
private failPendingActivation(pending: PendingRestart): void {
this.pendingRestart = null;
this.host.setRefreshWarning(null);
this.host.setVerifyState({
phase: "failed",
status: "unknown",
error: `${t("modelSetup.errors.activationFailed")} ${pending.modelRef}`,
});
}
private finishVerified(owner: FirstRunOwner, modelRef: string): void {
const pending = this.pendingRestart;
if (!pending) {
@@ -245,13 +279,7 @@ export class FirstRunAutoSetup {
pending.connection.hello === owner.connection.hello ||
pending.modelRef !== modelRef
) {
this.pendingRestart = null;
this.host.setRefreshWarning(null);
this.host.setVerifyState({
phase: "failed",
status: "unknown",
error: `${t("modelSetup.errors.activationFailed")} ${pending.modelRef}`,
});
this.failPendingActivation(pending);
return;
}
this.pendingRestart = null;
@@ -224,6 +224,42 @@ describe("ModelSetupPage first-run inference", () => {
expect(context.navigate).toHaveBeenCalledOnce();
});
it("automatically activates newly discovered credentials when first-run setup is checked again", async () => {
const { context, client, request } = createFirstRunContext();
request.mockImplementation(async (method) => {
if (method === "openclaw.setup.detect") {
return {
...detection,
candidates: [candidate("openai-api-key", "openai/newly-available", true)],
};
}
if (method === "openclaw.setup.activate") {
return { ok: true, modelRef: "openai/newly-available", latencyMs: 42, lines: [] };
}
throw new Error(`Unexpected method ${method}`);
});
const { page } = await mountPage(context, {
state: { phase: "ready", result: detection },
client,
firstRun: true,
});
const checkAgain = page.querySelector<HTMLButtonElement>(".model-setup__intro .btn");
expect(checkAgain?.textContent).toContain("Check again");
checkAgain?.click();
await waitForFast(() => {
expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([
["openclaw.setup.detect", { agentId: "main" }],
[
"openclaw.setup.activate",
{ agentId: "main", kind: "openai-api-key", modelRef: "openai/newly-available" },
],
]);
expect(context.navigate).toHaveBeenCalledWith("custodian", { search: "?onboarding=1" });
});
});
it("stops first-run activation after an ambiguous transport failure", async () => {
const { context, client, request } = createFirstRunContext();
request.mockRejectedValue(new Error("Activation connection dropped after dispatch"));
@@ -461,6 +497,214 @@ describe("ModelSetupPage first-run inference", () => {
});
});
it("finishes onboarding when the Gateway reconnects before its activation response", async () => {
const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext();
let resolveActivation:
| ((result: { ok: true; modelRef: string; gatewayRestartRequired: true }) => void)
| undefined;
request.mockImplementation(async (method) => {
if (method === "openclaw.setup.activate") {
return await new Promise<{
ok: true;
modelRef: string;
gatewayRestartRequired: true;
}>((resolve) => {
resolveActivation = resolve;
});
}
if (method === "openclaw.setup.detect") {
return { ...detection, configuredModel: "openai/new", setupComplete: true };
}
if (method === "openclaw.setup.verify") {
return { ok: true, modelRef: "openai/new", latencyMs: 31 };
}
throw new Error(`Unexpected method ${method}`);
});
const { page } = await mountPage(context, {
state: {
phase: "ready",
result: {
...detection,
candidates: [candidate("openai-api-key", "openai/new", true)],
},
},
client,
firstRun: true,
});
await waitForFast(() => expect(resolveActivation).toBeTypeOf("function"));
publishGatewaySnapshot({
...context.gateway.snapshot,
phase: "reconnecting",
hello: null,
});
await page.updateComplete;
publishGatewaySnapshot({
...snapshot,
phase: "connected",
hello: { ...snapshot.hello },
});
await waitForFast(() => {
expect(
request.mock.calls
.map(([method]) => method)
.filter((method) => method.startsWith("openclaw.setup.")),
).toEqual(["openclaw.setup.activate", "openclaw.setup.detect", "openclaw.setup.verify"]);
expect(context.navigate).toHaveBeenCalledWith("custodian", { search: "?onboarding=1" });
});
expect(context.navigate).not.toHaveBeenCalledWith("chat");
resolveActivation?.({ ok: true, modelRef: "openai/new", gatewayRestartRequired: true });
});
it("does not repeat an unconfirmed activation after reconnect without an explicit retry", async () => {
const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext();
let resolveFirstActivation:
| ((result: { ok: true; modelRef: string; gatewayRestartRequired: true }) => void)
| undefined;
let activationCount = 0;
request.mockImplementation(async (method) => {
if (method === "openclaw.setup.activate") {
activationCount += 1;
if (activationCount === 1) {
return await new Promise<{
ok: true;
modelRef: string;
gatewayRestartRequired: true;
}>((resolve) => {
resolveFirstActivation = resolve;
});
}
return { ok: true, modelRef: "openai/new", latencyMs: 31, lines: [] };
}
if (method === "openclaw.setup.detect") {
return {
...detection,
candidates: [candidate("openai-api-key", "openai/new", true)],
};
}
throw new Error(`Unexpected method ${method}`);
});
const { page } = await mountPage(context, {
state: {
phase: "ready",
result: {
...detection,
candidates: [candidate("openai-api-key", "openai/new", true)],
},
},
client,
firstRun: true,
});
await waitForFast(() => expect(resolveFirstActivation).toBeTypeOf("function"));
publishGatewaySnapshot({
...context.gateway.snapshot,
phase: "reconnecting",
hello: null,
});
await page.updateComplete;
publishGatewaySnapshot({
...snapshot,
phase: "connected",
hello: { ...snapshot.hello },
});
await waitForFast(() => {
expect(page.textContent).toContain("The model could not be activated");
expect(page.textContent).toContain("Check again");
});
expect(activationCount).toBe(1);
expect(context.navigate).not.toHaveBeenCalled();
page.querySelector<HTMLButtonElement>(".model-setup__intro .btn")?.click();
await waitForFast(() => {
expect(activationCount).toBe(2);
expect(context.navigate).toHaveBeenCalledWith("custodian", { search: "?onboarding=1" });
});
resolveFirstActivation?.({ ok: true, modelRef: "openai/new", gatewayRestartRequired: true });
});
it("rejects a different committed model before verification or another activation", async () => {
const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext();
let resolveFirstActivation:
| ((result: { ok: true; modelRef: string; gatewayRestartRequired: true }) => void)
| undefined;
let activationCount = 0;
request.mockImplementation(async (method) => {
if (method === "openclaw.setup.activate") {
activationCount += 1;
if (activationCount === 1) {
return await new Promise<{
ok: true;
modelRef: string;
gatewayRestartRequired: true;
}>((resolve) => {
resolveFirstActivation = resolve;
});
}
return { ok: true, modelRef: "openai/expected", latencyMs: 31, lines: [] };
}
if (method === "openclaw.setup.detect") {
return {
...detection,
configuredModel: "anthropic/different",
setupComplete: true,
candidates: [candidate("openai-api-key", "openai/expected", true)],
};
}
if (method === "openclaw.setup.verify") {
return { ok: false, status: "auth", error: "The different model could not be verified" };
}
throw new Error(`Unexpected method ${method}`);
});
const { page } = await mountPage(context, {
state: {
phase: "ready",
result: {
...detection,
candidates: [candidate("openai-api-key", "openai/expected", true)],
},
},
client,
firstRun: true,
});
await waitForFast(() => expect(resolveFirstActivation).toBeTypeOf("function"));
publishGatewaySnapshot({
...context.gateway.snapshot,
phase: "reconnecting",
hello: null,
});
await page.updateComplete;
publishGatewaySnapshot({
...snapshot,
phase: "connected",
hello: { ...snapshot.hello },
});
await waitForFast(() => {
expect(page.textContent).toContain("The model could not be activated");
expect(page.textContent).toContain("openai/expected");
});
expect(
request.mock.calls
.map(([method]) => method)
.filter((method) => method.startsWith("openclaw.setup.")),
).toEqual(["openclaw.setup.activate", "openclaw.setup.detect"]);
expect(activationCount).toBe(1);
expect(context.navigate).not.toHaveBeenCalled();
resolveFirstActivation?.({
ok: true,
modelRef: "openai/expected",
gatewayRestartRequired: true,
});
});
it("does not accept a different verified model after a required Gateway restart", async () => {
const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext();
request.mockImplementation(async (method) => {
+7 -5
View File
@@ -393,10 +393,9 @@ export class ModelSetupPage extends OpenClawLightDomElement {
if (pageState.phase !== "ready") {
return;
}
const available = pageState.result.manualProviders.some(
(provider) => provider.id === this.manualProviderId,
);
if (!available) {
if (
!pageState.result.manualProviders.some((provider) => provider.id === this.manualProviderId)
) {
this.manualProviderId = pageState.result.manualProviders[0]?.id ?? "";
}
}
@@ -683,7 +682,10 @@ export class ModelSetupPage extends OpenClawLightDomElement {
moreSignInOpen: this.moreSignInOpen,
firstRun: this.routeData?.firstRun === true,
iconUrls: this.iconUrls,
onDetect: () => void this.detect(),
onDetect: () => {
this.firstRun.retryDetection();
void this.detect();
},
onVerify: () => void this.verifyConnection(),
onActivateCandidate: (candidate) => this.activateCandidate(candidate),
onStartAuth: (option: AuthOption) => {