From 88c563b241e19090fb20d132ce56044032deea42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 01:44:26 -0700 Subject: [PATCH] fix: address update-card review findings (channel normalization, hidden-nav affordance, prerelease pinning, policy-aware bridge) (#104316) * fix(mac): prerelease-exact launch resolution and policy-aware update ownership * fix(ui): keep update affordance when nav is hidden and fall back on native decline * chore(i18n): sync native inventory --- apps/.i18n/native-source.json | 18 +++---- .../Sources/OpenClaw/CLIInstallPrompter.swift | 12 ++++- .../macos/Sources/OpenClaw/CLIInstaller.swift | 28 ++++------ .../Sources/OpenClaw/CommandResolver.swift | 8 +-- .../Sources/OpenClaw/DashboardManager.swift | 1 + .../OpenClaw/DashboardWindowController.swift | 8 +-- .../Sources/OpenClaw/GatewayEnvironment.swift | 54 +++++++++++++++---- apps/macos/Sources/OpenClaw/MenuBar.swift | 2 +- .../Sources/OpenClaw/OpenClawConfigFile.swift | 7 ++- .../OpenClawIPCTests/CLIInstallerTests.swift | 9 ++++ .../CommandResolverTests.swift | 11 ++++ .../Tests/OpenClawIPCTests/SemverTests.swift | 21 ++++++++ .../UpdateOrchestrationTests.swift | 23 ++++++++ ui/src/app/app-host.test.ts | 53 +++++++++++++++++- ui/src/app/app-host.ts | 43 ++++++++++++++- ui/src/app/native-link-routing.ts | 2 + ui/src/components/sidebar-update-card.test.ts | 27 ++++++++++ ui/src/components/sidebar-update-card.ts | 18 ++++++- ui/src/styles/layout.css | 18 +++++++ 19 files changed, 312 insertions(+), 51 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 9ce541479344..045b5c00c229 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -15187,7 +15187,7 @@ }, { "kind": "conditional-branch", - "line": 173, + "line": 177, "path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift", "source": "CLI install failed", "surface": "apple", @@ -15195,7 +15195,7 @@ }, { "kind": "conditional-branch", - "line": 173, + "line": 177, "path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift", "source": "CLI install finished", "surface": "apple", @@ -15203,7 +15203,7 @@ }, { "kind": "conditional-branch", - "line": 46, + "line": 50, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Stable", "surface": "apple", @@ -15211,7 +15211,7 @@ }, { "kind": "conditional-branch", - "line": 47, + "line": 51, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Beta", "surface": "apple", @@ -15219,7 +15219,7 @@ }, { "kind": "conditional-branch", - "line": 48, + "line": 52, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Dev (Git main)", "surface": "apple", @@ -15227,7 +15227,7 @@ }, { "kind": "conditional-branch", - "line": 101, + "line": 105, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "OpenClaw Gateway \\(version) is ready.", "surface": "apple", @@ -15235,7 +15235,7 @@ }, { "kind": "conditional-branch", - "line": 103, + "line": 107, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "OpenClaw Gateway is not installed yet.", "surface": "apple", @@ -15243,7 +15243,7 @@ }, { "kind": "conditional-branch", - "line": 105, + "line": 109, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "The OpenClaw Gateway could not be verified. Setup will repair it.", "surface": "apple", @@ -15251,7 +15251,7 @@ }, { "kind": "conditional-branch", - "line": 107, + "line": 111, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway \\(found) does not match app \\(required). Setup will update it.", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index 73561438f1c7..bbb4a787969f 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -30,6 +30,7 @@ final class CLIInstallPrompter { launchAgentUsesManagedCLI: Self.launchAgentUsesManagedCLI( programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []), gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(), + installPolicy: CLIInstallPolicy.storedPolicy(), launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) if await self.completePendingManagedRestartIfNeeded(managedStatus: managedStatus) { return @@ -48,6 +49,9 @@ final class CLIInstallPrompter { { return } + // A completed install with an unverified restart owns recovery on + // the next trigger; the stale pre-install status must not prompt again. + if Self.hasPendingManagedRestart() { return } } guard !status.isReady else { return } let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) @@ -269,24 +273,30 @@ final class CLIInstallPrompter { static func managedRepairGatesOpen( launchAgentUsesManagedCLI: Bool, gatewayUpdateChannel: String?, + installPolicy: String?, launchAgentWriteDisabled: Bool) -> Bool { guard !launchAgentWriteDisabled else { return false } guard launchAgentUsesManagedCLI else { return false } + // Exact pins make the app the Gateway version owner; channel policies + // leave updates to gateway update.run instead of Sparkle repair. + guard installPolicy == nil || installPolicy == "exact" else { return false } // Extended-stable pins an intentionally older gateway; moving it to the // app's newer stable version without consent keeps the prompt instead. - return gatewayUpdateChannel?.lowercased() != "extended-stable" + return gatewayUpdateChannel != "extended-stable" } static func shouldAutomaticallyRepair( status: CLIInstaller.Status, launchAgentUsesManagedCLI: Bool, gatewayUpdateChannel: String? = nil, + installPolicy: String? = nil, launchAgentWriteDisabled: Bool = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) -> Bool { guard self.managedRepairGatesOpen( launchAgentUsesManagedCLI: launchAgentUsesManagedCLI, gatewayUpdateChannel: gatewayUpdateChannel, + installPolicy: installPolicy, launchAgentWriteDisabled: launchAgentWriteDisabled) else { return false } guard case let .incompatible(location, found, required) = status else { return false } diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index 43db07c26cfc..c60dd38e72a0 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -18,6 +18,10 @@ enum CLIInstallBuild { } enum CLIInstallPolicy { + static func storedPolicy(defaults: UserDefaults = .standard) -> String? { + defaults.string(forKey: cliInstallPolicyKey) + } + static func requiredGatewayVersionString( appVersion: String?, isDebug: Bool, @@ -26,7 +30,7 @@ enum CLIInstallPolicy { guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else { return appVersion } - return switch defaults.string(forKey: cliInstallPolicyKey) { + return switch self.storedPolicy(defaults: defaults) { case "stable", "beta", "dev": nil case "exact", nil: appVersion default: appVersion @@ -235,35 +239,21 @@ enum CLIInstaller { expectedVersion: String?) -> Status { let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(output) - guard let normalized, let installed = Semver.parse(normalized) else { + guard let normalized, Semver.parse(normalized) != nil else { return .unusable(location: location) } - guard let required = Semver.parse(expectedVersion) else { + guard Semver.parse(expectedVersion) != nil else { return .ready(location: location, version: normalized) } - let requiresExactVersion = Self.isPrerelease(expectedVersion) || Self.isPrerelease(normalized) - if requiresExactVersion, normalized != expectedVersion { + guard Semver.satisfiesExpectedGatewayVersion(installed: normalized, expected: expectedVersion) else { return .incompatible( location: location, found: normalized, - required: expectedVersion ?? required.description) - } - guard installed.compatible(with: required) else { - return .incompatible( - location: location, - found: normalized, - required: expectedVersion ?? required.description) + required: expectedVersion ?? "unknown") } return .ready(location: location, version: normalized) } - private static func isPrerelease(_ version: String?) -> Bool { - guard let version = version?.lowercased() else { return false } - return ["alpha", "beta"].contains { lane in - version.contains("-\(lane).") || version.contains(".\(lane).") - } - } - static func probeEnvironment( location: String, processEnvironment: [String: String] = ProcessInfo.processInfo.environment, diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index e99b6ae39425..811364fbf31c 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -126,12 +126,14 @@ enum CommandResolver { { guard let executable = defaults.string(forKey: cliValidatedExecutableKey), fileManager.isExecutableFile(atPath: executable), - let validatedVersion = Semver.parse(defaults.string(forKey: cliValidatedVersionKey)) + let validatedVersion = defaults.string(forKey: cliValidatedVersionKey), + Semver.parse(validatedVersion) != nil else { return nil } - guard let required = Semver.parse(requiredVersion) else { return executable } - return validatedVersion.compatible(with: required) ? executable : nil + return Semver.satisfiesExpectedGatewayVersion( + installed: validatedVersion, + expected: requiredVersion) ? executable : nil } private static func openclawManagedPaths(home: URL) -> [String] { diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index 335e2e381886..050d7785f118 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -30,6 +30,7 @@ final class DashboardManager { launchAgentUsesManagedCLI: CLIInstallPrompter.launchAgentUsesManagedCLI( programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []), gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(), + installPolicy: CLIInstallPolicy.storedPolicy(), launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) } diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 6c0fe6030154..9aa20504c4c0 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -376,11 +376,13 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, return } // Eligibility is cached at window setup, but update.channel or launchd - // ownership can change while the dashboard stays open. Revalidate here; - // dropping the bridge makes the Control UI's next click fall back to - // the direct gateway update flow. + // ownership can change while the dashboard stays open. Revalidate here. guard DashboardManager.updateBridgeEnabled(mode: AppStateStore.shared.connectionMode) else { self.setUpdateBridgeEnabled(false) + // JS treated its posted message as handled; return this click to + // the gateway updater after withdrawing the native bridge. + self.webView.evaluateJavaScript( + "window.dispatchEvent(new CustomEvent('openclaw:native-update-declined'))") return } updater.checkForUpdates(nil) diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index a33aa8b5799f..fee99fd51c07 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -41,6 +41,33 @@ struct Semver: Comparable, CustomStringConvertible { // Same major and not older than required. self.major == required.major && self >= required } + + static func satisfiesExpectedGatewayVersion(installed: String, expected: String?) -> Bool { + let installed = installed.trimmingCharacters(in: .whitespacesAndNewlines) + let expected = expected?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let installedVersion = Self.parse(installed) else { return false } + guard let expectedVersion = Self.parse(expected) else { return true } + if Self.isPrerelease(installed) || Self.isPrerelease(expected) { + return installed == expected + } + return installedVersion.compatible(with: expectedVersion) + } + + static func isPrerelease(_ version: String?) -> Bool { + guard let version = version?.lowercased() else { return false } + let versionCore = version.split( + separator: "+", + maxSplits: 1, + omittingEmptySubsequences: false)[0] + if ["alpha", "beta"].contains(where: { lane in + versionCore.contains("-\(lane).") || versionCore.contains(".\(lane).") + }) { + return true + } + guard let separator = versionCore.firstIndex(of: "-") else { return false } + let suffix = versionCore[versionCore.index(after: separator)...] + return !suffix.split(separator: ".").allSatisfy { Int($0) != nil } + } } enum GatewayEnvironmentKind: Equatable { @@ -142,24 +169,27 @@ enum GatewayEnvironment { message: "openclaw CLI not found in PATH; install the CLI.") } - let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } + let installedRaw = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } ?? self.readLocalGatewayVersion(projectRoot: projectRoot) + let installed = Semver.parse(installedRaw) - if let expected, let installed, !installed.compatible(with: expected) { + if let expected, let installedRaw, installed != nil, + !Semver.satisfiesExpectedGatewayVersion(installed: installedRaw, expected: expectedString) + { let expectedText = expectedString ?? expected.description return GatewayEnvironmentStatus( - kind: .incompatible(found: installed.description, required: expectedText), + kind: .incompatible(found: installedRaw, required: expectedText), nodeVersion: runtime.version.description, - gatewayVersion: installed.description, + gatewayVersion: installedRaw, requiredGateway: expectedText, message: """ - Gateway version \(installed.description) is incompatible with app \(expectedText); + Gateway version \(installedRaw) is incompatible with app \(expectedText); install or update the global package. """) } let gatewayLabel = gatewayBin != nil ? "global" : "local" - let gatewayVersionText = installed?.description ?? "unknown" + let gatewayVersionText = installedRaw ?? "unknown" // Avoid repeating "(local)" twice; if using the local entrypoint, show the path once. let localPathHint = gatewayBin == nil && projectEntrypoint != nil ? " (local: \(projectEntrypoint ?? "unknown"))" @@ -312,7 +342,7 @@ enum GatewayEnvironment { return normalized } - private static func readGatewayVersion(binary: String) -> Semver? { + private static func readGatewayVersion(binary: String) -> String? { let start = Date() let process = Process() process.executableURL = URL(fileURLWithPath: binary) @@ -339,7 +369,10 @@ enum GatewayEnvironment { """) } let raw = String(data: data, encoding: .utf8) - return Semver.parse(self.normalizeGatewayVersionOutput(raw)) + guard let normalized = self.normalizeGatewayVersionOutput(raw), + Semver.parse(normalized) != nil + else { return nil } + return normalized } catch { let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) self.logger.error( @@ -352,13 +385,14 @@ enum GatewayEnvironment { } } - private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? { + private static func readLocalGatewayVersion(projectRoot: URL) -> String? { let pkg = projectRoot.appendingPathComponent("package.json") guard let data = try? Data(contentsOf: pkg) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let version = json["version"] as? String else { return nil } - return Semver.parse(version) + guard Semver.parse(version) != nil else { return nil } + return version.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 6fdf6d4cc4f5..80a5d5fed338 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -679,7 +679,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { } func allowedSparkleChannels(forGatewayUpdateChannel channel: String?) -> Set { - switch channel?.lowercased() { + switch channel { case "beta", "dev": ["beta"] default: diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 9e9aa53c5cad..d8e8178fa905 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -205,7 +205,12 @@ enum OpenClawConfigFile { static func gatewayUpdateChannel() -> String? { let root = self.loadDict() let update = root["update"] as? [String: Any] - return update?["channel"] as? String + return self.normalizedGatewayUpdateChannel(update?["channel"] as? String) + } + + static func normalizedGatewayUpdateChannel(_ channel: String?) -> String? { + let normalized = channel?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized?.isEmpty == false ? normalized : nil } static func browserControlEnabled(defaultValue: Bool = true) -> Bool { diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index 55c5edb7bfb1..929da999000f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -93,11 +93,13 @@ struct CLIInstallerTests { let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } + #expect(CLIInstallPolicy.storedPolicy(defaults: defaults) == nil) #expect(CLIInstallPolicy.requiredGatewayVersionString( appVersion: "2026.7.2", isDebug: true, defaults: defaults) == "2026.7.2") defaults.set("beta", forKey: cliInstallPolicyKey) + #expect(CLIInstallPolicy.storedPolicy(defaults: defaults) == "beta") #expect(CLIInstallPolicy.requiredGatewayVersionString( appVersion: "2026.7.2", isDebug: true, @@ -144,6 +146,13 @@ struct CLIInstallerTests { location: location, found: "2026.7.3-beta.2", required: "2026.7.3")) + #expect(CLIInstaller.classifyVersion( + location: location, + output: "2026.7.3\n", + expectedVersion: "2026.7.3-beta.2") == .incompatible( + location: location, + found: "2026.7.3", + required: "2026.7.3-beta.2")) #expect(CLIInstaller.classifyVersion( location: location, output: "2026.7.3-alpha.1\n", diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index f27730547c91..aee70ffb40fb 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -233,6 +233,17 @@ import Testing defaults: defaults, fileManager: .default, requiredVersion: "2026.8.0") == nil) + + defaults.set("2026.7.3-beta.1", forKey: cliValidatedVersionKey) + #expect(CommandResolver.validatedOpenClawExecutable( + defaults: defaults, + fileManager: .default, + requiredVersion: "2026.7.3") == nil) + defaults.set("2026.7.3", forKey: cliValidatedVersionKey) + #expect(CommandResolver.validatedOpenClawExecutable( + defaults: defaults, + fileManager: .default, + requiredVersion: "2026.7.3-beta.1") == nil) } @Test func `builds SSH command for remote mode`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift b/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift index 19b9f4496025..51c630533d20 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift @@ -18,4 +18,25 @@ struct SemverTests { let v = Semver(major: 3, minor: 2, patch: 1) #expect(v.description == "3.2.1") } + + @Test func `expected prerelease pins require exact raw version`() { + #expect(Semver.satisfiesExpectedGatewayVersion( + installed: "2026.7.2-beta.1", + expected: "2026.7.2-beta.1")) + #expect(!Semver.satisfiesExpectedGatewayVersion( + installed: "2026.7.2", + expected: "2026.7.2-beta.1")) + #expect(!Semver.satisfiesExpectedGatewayVersion( + installed: "2026.7.2-beta.1", + expected: "2026.7.2")) + #expect(Semver.satisfiesExpectedGatewayVersion( + installed: "2026.7.3", + expected: "2026.7.2")) + #expect(!Semver.satisfiesExpectedGatewayVersion( + installed: "2026.7.2-rc.1", + expected: "2026.7.2")) + #expect(Semver.satisfiesExpectedGatewayVersion( + installed: "2026.7.2+build-foo", + expected: "2026.7.2")) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift index 0ce1bfaa8896..e53f42447b62 100644 --- a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift @@ -8,6 +8,9 @@ struct UpdateOrchestrationTests { @Test func `Sparkle channels follow the Gateway update channel`() { #expect(allowedSparkleChannels(forGatewayUpdateChannel: "beta") == ["beta"]) #expect(allowedSparkleChannels(forGatewayUpdateChannel: "dev") == ["beta"]) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: + OpenClawConfigFile.normalizedGatewayUpdateChannel(" BETA \n")) == ["beta"]) + #expect(OpenClawConfigFile.normalizedGatewayUpdateChannel(" \n") == nil) #expect(allowedSparkleChannels(forGatewayUpdateChannel: "stable").isEmpty) #expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable").isEmpty) #expect(allowedSparkleChannels(forGatewayUpdateChannel: "future").isEmpty) @@ -138,23 +141,43 @@ struct UpdateOrchestrationTests { #expect(CLIInstallPrompter.managedRepairGatesOpen( launchAgentUsesManagedCLI: true, gatewayUpdateChannel: nil, + installPolicy: nil, launchAgentWriteDisabled: false)) #expect(CLIInstallPrompter.managedRepairGatesOpen( launchAgentUsesManagedCLI: true, gatewayUpdateChannel: "beta", + installPolicy: "exact", launchAgentWriteDisabled: false)) #expect(!CLIInstallPrompter.managedRepairGatesOpen( launchAgentUsesManagedCLI: false, gatewayUpdateChannel: nil, + installPolicy: nil, launchAgentWriteDisabled: false)) #expect(!CLIInstallPrompter.managedRepairGatesOpen( launchAgentUsesManagedCLI: true, gatewayUpdateChannel: "extended-stable", + installPolicy: nil, launchAgentWriteDisabled: false)) #expect(!CLIInstallPrompter.managedRepairGatesOpen( launchAgentUsesManagedCLI: true, gatewayUpdateChannel: nil, + installPolicy: nil, launchAgentWriteDisabled: true)) + #expect(!CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: nil, + installPolicy: "stable", + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: nil, + installPolicy: "beta", + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: nil, + installPolicy: "dev", + launchAgentWriteDisabled: false)) } @Test func `pending managed restart marker round trips`() { diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 067e19fd0772..9bd2bac47f2f 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -1,13 +1,14 @@ /* @vitest-environment jsdom */ +import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { navigationSurfaceIsHidden, renderFloatingUpdateCard } from "./app-host.ts"; import type { ApplicationContext, ApplicationGateway, ApplicationGatewaySnapshot, } from "./context.ts"; -import "./app-host.ts"; type AppLifecycleState = { loginToken: string; @@ -215,3 +216,53 @@ describe("OpenClaw shell keyboard shortcuts", () => { expect(navigate).not.toHaveBeenCalled(); }); }); + +describe("OpenClaw shell update affordance", () => { + it("renders a floating card only while desktop navigation is collapsed", () => { + const container = document.createElement("div"); + const updateAvailable = { + currentVersion: "2026.7.1", + latestVersion: "2026.7.2", + channel: "stable", + }; + const shared = { + onboarding: false, + updateAvailable, + updateRunning: false, + onUpdate: vi.fn(), + }; + + const collapsed = navigationSurfaceIsHidden({ + navCollapsed: true, + navDrawerOpen: false, + mobileNavLayout: false, + }); + render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: collapsed }), container); + expect(container.querySelector("openclaw-sidebar-update-card")).not.toBeNull(); + + const visible = navigationSurfaceIsHidden({ + navCollapsed: false, + navDrawerOpen: false, + mobileNavLayout: false, + }); + render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: visible }), container); + expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull(); + }); + + it("treats the mobile navigation surface as hidden while its drawer is closed", () => { + expect( + navigationSurfaceIsHidden({ + navCollapsed: false, + navDrawerOpen: false, + mobileNavLayout: true, + }), + ).toBe(true); + expect( + navigationSurfaceIsHidden({ + navCollapsed: false, + navDrawerOpen: true, + mobileNavLayout: true, + }), + ).toBe(false); + }); +}); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index c9cf19cfe171..f7aaae2915a8 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -12,6 +12,7 @@ import "../components/github-link-hovercard.ts"; import "../components/login-gate.ts"; import "../components/browser/browser-panel.ts"; import "../components/resizable-divider.ts"; +import "../components/sidebar-update-card.ts"; import "../components/terminal/terminal-panel.ts"; import "../components/tooltip.ts"; import "../components/update-banner.ts"; @@ -137,6 +138,32 @@ function isMobileNavLayout(): boolean { return globalThis.matchMedia?.("(max-width: 1100px)").matches ?? false; } +export function navigationSurfaceIsHidden(params: { + navCollapsed: boolean; + navDrawerOpen: boolean; + mobileNavLayout: boolean; +}): boolean { + return params.mobileNavLayout ? !params.navDrawerOpen : params.navCollapsed; +} + +export function renderFloatingUpdateCard(params: { + navigationSurfaceHidden: boolean; + onboarding: boolean; + updateAvailable: ApplicationContext["overlays"]["snapshot"]["updateAvailable"]; + updateRunning: boolean; + onUpdate: () => void; +}) { + if (!params.navigationSurfaceHidden || params.onboarding) { + return nothing; + } + return html``; +} + class OpenClawApp extends OpenClawLightDomElement { // Pinned while a connect submitted from the visible login gate is in // flight, so a failed manual attempt cannot flash the shell in between. @@ -836,6 +863,11 @@ class OpenClawShell extends OpenClawLightDomElement { // stays persisted for when the viewport returns to the desktop layout. // The settings sidebar has a fixed width, so the collapse state pauses too. const navCollapsed = navigationSnapshot.navCollapsed && !navDrawerOpen && !settingsTakeover; + const navigationSurfaceHidden = navigationSurfaceIsHidden({ + navCollapsed, + navDrawerOpen, + mobileNavLayout: isMobileNavLayout(), + }); const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX); // One storage read per render; theme.refresh() re-renders on pref changes. const uiSettings = loadSettings(); @@ -897,7 +929,7 @@ class OpenClawShell extends OpenClawLightDomElement { context.config.current.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? "", - updateAvailable: overlaySnapshot.updateAvailable, + updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable, updateRunning: overlaySnapshot.updateRunning, onUpdate: () => void context.overlays.runUpdate(), searchQuery: this.settingsSearchQuery, @@ -927,7 +959,7 @@ class OpenClawShell extends OpenClawLightDomElement { gatewaySnapshot.hello?.server?.version ?? null} .devGitBranch=${context.config.current.devGitBranch} - .updateAvailable=${overlaySnapshot.updateAvailable} + .updateAvailable=${navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable} .updateRunning=${overlaySnapshot.updateRunning} .onUpdate=${() => void context.overlays.runUpdate()} .onOpenPalette=${this.openPalette} @@ -985,6 +1017,13 @@ class OpenClawShell extends OpenClawLightDomElement { statusBanner: overlaySnapshot.updateStatusBanner, }} > + ${renderFloatingUpdateCard({ + navigationSurfaceHidden, + onboarding: this.onboarding, + updateAvailable: overlaySnapshot.updateAvailable, + updateRunning: overlaySnapshot.updateRunning, + onUpdate: () => void context.overlays.runUpdate(), + })} { expect(onUpdate).not.toHaveBeenCalled(); }); + it("returns a declined native click to the gateway while connected", async () => { + const element = await mount({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }); + const onUpdate = vi.fn(); + element.onUpdate = onUpdate; + + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); + expect(onUpdate).toHaveBeenCalledOnce(); + + element.updateRunning = true; + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); + expect(onUpdate).toHaveBeenCalledOnce(); + + element.updateRunning = false; + element.updateAvailable = null; + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); + expect(onUpdate).toHaveBeenCalledOnce(); + + element.remove(); + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); + expect(onUpdate).toHaveBeenCalledOnce(); + }); + it("disables the action while updating", async () => { const element = await mount({ currentVersion: "1.0.0", diff --git a/ui/src/components/sidebar-update-card.ts b/ui/src/components/sidebar-update-card.ts index 7e44cc28dfe0..14d1d2c84ec4 100644 --- a/ui/src/components/sidebar-update-card.ts +++ b/ui/src/components/sidebar-update-card.ts @@ -1,7 +1,7 @@ import { html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { UpdateAvailable } from "../api/types.ts"; -import { postNativeUpdate } from "../app/native-link-routing.ts"; +import { NATIVE_UPDATE_DECLINED_EVENT, postNativeUpdate } from "../app/native-link-routing.ts"; import { t } from "../i18n/index.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; @@ -53,6 +53,22 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { @property({ attribute: false }) onUpdate: () => void = () => undefined; @state() private dismissedUpdateKey: string | null = null; + private readonly handleNativeUpdateDeclined = () => { + if (this.updateAvailable && !this.updateRunning) { + this.onUpdate(); + } + }; + + override connectedCallback() { + super.connectedCallback(); + window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined); + } + + override disconnectedCallback() { + window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined); + super.disconnectedCallback(); + } + override render() { const update = this.updateAvailable; if ( diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index e2ff7fd852c2..fbaa87a6cd40 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -810,6 +810,24 @@ html.openclaw-native-macos .shell-nav-expand { margin: 0 8px 10px; } +.sidebar-update-card--floating .sidebar-update-card { + width: min(360px, calc(100% - 32px)); + margin: 12px 16px 0; +} + +@media (min-width: 1101px) { + /* Keep the card beside the collapsed-nav control so their hit targets stay distinct. */ + .sidebar-update-card--floating .sidebar-update-card { + width: min(360px, calc(100% - 48px)); + margin-left: 32px; + } + + /* Native dashboard actions must also clear AppKit's drag strip. */ + html.openclaw-native-macos .sidebar-update-card--floating .sidebar-update-card { + margin-top: calc(var(--openclaw-native-titlebar-height, 50px) + 2px); + } +} + .sidebar-update-card__action { width: 100%; min-height: 62px;