+ ${state.updateStatusBanner
+ ? html`
+ ${state.updateStatusBanner.text}
+
`
+ : nothing}
${state.updateAvailable &&
state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion &&
!isUpdateBannerDismissed(state.updateAvailable)
diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts
index c1e990ef01b..c647524519a 100644
--- a/ui/src/ui/app-settings.ts
+++ b/ui/src/ui/app-settings.ts
@@ -744,7 +744,7 @@ function buildAttentionItems(host: SettingsAppHost) {
// Use the same predicate as the Overview card so the two stay in sync.
// Without this, a `missing` provider shows up on the card but never
// produces the re-auth attention callout.
- const monitored = modelAuth.providers.filter(isMonitoredAuthProvider);
+ const monitored = (modelAuth.providers ?? []).filter(isMonitoredAuthProvider);
const expiredProviders = monitored.filter(
(p) => p.status === "expired" || p.status === "missing",
);
diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts
index ca7d09b05bf..af9d79ae47d 100644
--- a/ui/src/ui/app-view-state.ts
+++ b/ui/src/ui/app-view-state.ts
@@ -176,6 +176,8 @@ export type AppViewState = {
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
+ pendingUpdateExpectedVersion: string | null;
+ updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null;
communicationsFormMode: "form" | "raw";
communicationsSearchQuery: string;
communicationsActiveSection: string | null;
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index a0cb27fbe27..9e96a408d1e 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -284,6 +284,8 @@ export class OpenClawApp extends LitElement {
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null;
+ @state() pendingUpdateExpectedVersion: string | null = null;
+ @state() updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null = null;
@state() communicationsFormMode: "form" | "raw" = "form";
@state() communicationsSearchQuery = "";
@state() communicationsActiveSection: string | null = null;
diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts
index 6f926b2db4b..ea5274127e4 100644
--- a/ui/src/ui/controllers/agents.test.ts
+++ b/ui/src/ui/controllers/agents.test.ts
@@ -73,6 +73,8 @@ function createSaveState(): {
configSearchQuery: "",
configActiveSection: null,
configActiveSubsection: null,
+ pendingUpdateExpectedVersion: null,
+ updateStatusBanner: null,
lastError: null,
},
request,
diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts
index ae19523ecd1..1ccd11b5984 100644
--- a/ui/src/ui/controllers/config.test.ts
+++ b/ui/src/ui/controllers/config.test.ts
@@ -37,6 +37,8 @@ function createState(): ConfigState {
configValid: null,
connected: false,
lastError: null,
+ pendingUpdateExpectedVersion: null,
+ updateStatusBanner: null,
updateRunning: false,
};
}
@@ -554,6 +556,44 @@ describe("runUpdate", () => {
await runUpdate(state);
- expect(state.lastError).toBe("Update error: network unavailable");
+ expect(state.updateStatusBanner).toEqual({
+ tone: "danger",
+ text: "Update error: network unavailable. See the gateway logs for the exact failure and retry once the cause is fixed.",
+ });
+ });
+
+ it("surfaces skipped updates with actionable guidance", async () => {
+ const request = vi.fn().mockResolvedValue({
+ ok: false,
+ result: { status: "skipped", reason: "dirty" },
+ });
+ const state = createState();
+ state.connected = true;
+ state.client = { request } as unknown as ConfigState["client"];
+
+ await runUpdate(state);
+
+ expect(state.updateStatusBanner).toEqual({
+ tone: "warn",
+ text: "Update skipped: dirty. Commit or stash changes, then retry.",
+ });
+ });
+
+ it("stores the expected post-update version when update.run succeeds", async () => {
+ const request = vi.fn().mockResolvedValue({
+ ok: true,
+ result: {
+ status: "ok",
+ after: { version: "2.0.0" },
+ },
+ });
+ const state = createState();
+ state.connected = true;
+ state.client = { request } as unknown as ConfigState["client"];
+
+ await runUpdate(state);
+
+ expect(state.pendingUpdateExpectedVersion).toBe("2.0.0");
+ expect(state.updateStatusBanner).toBeNull();
});
});
diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts
index 35e6c49a46f..ae8d6ba8291 100644
--- a/ui/src/ui/controllers/config.ts
+++ b/ui/src/ui/controllers/config.ts
@@ -34,6 +34,8 @@ export type ConfigState = {
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
+ pendingUpdateExpectedVersion: string | null;
+ updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null;
lastError: string | null;
};
@@ -138,6 +140,39 @@ function serializeFormForSubmit(state: ConfigState): string {
type ConfigSubmitMethod = "config.set" | "config.apply";
type ConfigSubmitBusyKey = "configSaving" | "configApplying";
+function resolveUpdateStatusBanner(params: { status?: string; reason?: string }): {
+ tone: "danger" | "warn" | "info";
+ text: string;
+} {
+ const status = (params.status ?? "error").trim() || "error";
+ const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error";
+ const tone = status === "skipped" ? "warn" : "danger";
+ const guidance =
+ {
+ dirty: "Commit or stash changes, then retry.",
+ "no-upstream": "Set an upstream branch, then retry.",
+ "not-git-install":
+ "Not a git checkout. Run `openclaw update` from the CLI for a global reinstall.",
+ "not-openclaw-root":
+ "Run the update from an OpenClaw checkout or use the CLI global reinstall path.",
+ "deps-install-failed": "Dependency install failed. Fix the install error and retry.",
+ "build-failed": "Build failed. Fix the build error and retry.",
+ "ui-build-failed": "The control UI rebuild failed. Fix the UI build error and retry.",
+ "global-install-failed":
+ "The global package install did not verify on disk. Retry or reinstall from the CLI.",
+ "restart-disabled": "The update was not applied because gateway restarts are disabled. Enable restarts in config, then retry — or run `openclaw update` from the CLI.",
+ "restart-unavailable":
+ "This global install cannot be safely replaced while restarts are disabled and no supervisor is present.",
+ "restart-unhealthy":
+ "The replacement process never became healthy. The previous process stayed up so you can recover.",
+ "doctor-failed": "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.",
+ }[reason] ?? "See the gateway logs for the exact failure and retry once the cause is fixed.";
+ return {
+ tone,
+ text: `Update ${status}: ${reason}. ${guidance}`,
+ };
+}
+
async function submitConfigChange(
state: ConfigState,
method: ConfigSubmitMethod,
@@ -193,20 +228,27 @@ export async function runUpdate(state: ConfigState) {
}
state.updateRunning = true;
state.lastError = null;
+ state.updateStatusBanner = null;
try {
const res = await state.client.request<{
ok?: boolean;
- result?: { status?: string; reason?: string };
+ result?: { status?: string; reason?: string; after?: { version?: string | null } };
}>("update.run", {
sessionKey: state.applySessionKey,
});
- if (res && res.ok === false) {
- const status = res.result?.status ?? "error";
- const reason = res.result?.reason ?? "Update failed.";
- state.lastError = `Update ${status}: ${reason}`;
+ const status = res.result?.status ?? (res.ok === true ? "ok" : "error");
+ if (status === "ok" && res.ok === true) {
+ state.pendingUpdateExpectedVersion = res.result?.after?.version ?? null;
+ return;
}
+ state.pendingUpdateExpectedVersion = null;
+ state.updateStatusBanner = resolveUpdateStatusBanner({
+ status,
+ reason: res.result?.reason,
+ });
} catch (err) {
state.lastError = String(err);
+ state.pendingUpdateExpectedVersion = null;
} finally {
state.updateRunning = false;
}