From 06ec6b0fca62cb8d97bbf6a4b136f19fe66d75bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 06:31:45 +0100 Subject: [PATCH] fix(mac): speed up channels settings --- CHANGELOG.md | 2 + .../Sources/OpenClaw/ChannelConfigForm.swift | 149 ++++++++++++++++-- .../ChannelsSettings+ChannelState.swift | 12 +- .../OpenClaw/ChannelsSettings+View.swift | 26 +-- .../OpenClaw/ChannelsStore+Lifecycle.swift | 9 +- .../Sources/OpenClaw/ChannelsStore.swift | 19 ++- .../OpenClaw/ConfigSchemaSupport.swift | 19 +++ .../Sources/OpenClaw/SettingsRootView.swift | 28 +++- 8 files changed, 229 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b7a63f4d0..46d29b150be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai - Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525) - Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion. +- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema. +- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting. - Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01. - Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863) - Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models. diff --git a/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift b/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift index d00725be768..ee8d8c42099 100644 --- a/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift +++ b/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift @@ -1,9 +1,27 @@ import SwiftUI +enum ConfigSchemaFormMode { + case full + case channelQuick +} + struct ConfigSchemaForm: View { @Bindable var store: ChannelsStore let schema: ConfigSchemaNode let path: ConfigPath + let mode: ConfigSchemaFormMode + + init( + store: ChannelsStore, + schema: ConfigSchemaNode, + path: ConfigPath, + mode: ConfigSchemaFormMode = .full) + { + self.store = store + self.schema = schema + self.path = path + self.mode = mode + } var body: some View { self.renderNode(self.schema, path: self.path) @@ -12,7 +30,7 @@ struct ConfigSchemaForm: View { private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { let storedValue = self.store.configValue(at: path) let value = storedValue ?? schema.explicitDefault - let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title + let label = self.fieldLabel(for: schema, path: path) let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf @@ -62,18 +80,16 @@ struct ConfigSchemaForm: View { .foregroundStyle(.secondary) } let properties = schema.properties - let sortedKeys = properties.keys.sorted { lhs, rhs in - let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0 - let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0 - if orderA != orderB { return orderA < orderB } - return lhs < rhs - } + let sortedKeys = self.visibleObjectKeys(properties: properties, path: path) ForEach(sortedKeys, id: \ .self) { key in if let child = properties[key] { self.renderNode(child, path: path + [.key(key)]) } } - if schema.allowsAdditionalProperties { + if sortedKeys.isEmpty, self.mode == .channelQuick, self.isChannelRoot(path) { + self.renderChannelQuickEmptyState() + } + if self.shouldRenderAdditionalProperties(schema, path: path, value: value) { self.renderAdditionalProperties(schema, path: path, value: value) } }) @@ -100,6 +116,117 @@ struct ConfigSchemaForm: View { } } + private func fieldLabel(for schema: ConfigSchemaNode, path: ConfigPath) -> String? { + hintForPath(path, hints: self.store.configUiHints)?.label + ?? schema.title + ?? labelForConfigPath(path) + } + + private func visibleObjectKeys( + properties: [String: ConfigSchemaNode], + path: ConfigPath) -> [String] + { + let sortedKeys = properties.keys.sorted { lhs, rhs in + let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0 + let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0 + if orderA != orderB { return orderA < orderB } + return lhs < rhs + } + + guard self.mode == .channelQuick, self.isChannelRoot(path) else { + return sortedKeys + } + + return sortedKeys.filter { key in + guard let child = properties[key] else { return false } + return self.shouldRenderChannelQuickField(key: key, schema: child, path: path + [.key(key)]) + } + } + + private func shouldRenderChannelQuickField( + key: String, + schema: ConfigSchemaNode, + path: ConfigPath) -> Bool + { + if hintForPath(path, hints: self.store.configUiHints)?.advanced == true { + return false + } + if Self.channelQuickKeys.contains(key) { + return self.isSimpleField(schema) + } + return self.store.configValue(at: path) != nil && self.isSimpleField(schema) + } + + private func isSimpleField(_ schema: ConfigSchemaNode) -> Bool { + let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf + let nonNullVariants = variants.filter { !$0.isNullSchema } + if !nonNullVariants.isEmpty { + return nonNullVariants.allSatisfy(self.isSimpleField) + } + if let enumValues = schema.enumValues { + return !enumValues.isEmpty + } + switch schema.schemaType { + case "boolean", "integer", "number", "string": + return true + default: + return false + } + } + + private func shouldRenderAdditionalProperties( + _ schema: ConfigSchemaNode, + path: ConfigPath, + value: Any?) -> Bool + { + guard schema.allowsAdditionalProperties else { return false } + if self.mode != .channelQuick { return true } + guard let dict = value as? [String: Any] else { return false } + let reserved = Set(schema.properties.keys) + return dict.keys.contains { !reserved.contains($0) } + } + + private func isChannelRoot(_ path: ConfigPath) -> Bool { + guard path.count == 2 else { return false } + guard case .key("channels") = path[0] else { return false } + guard case .key = path[1] else { return false } + return true + } + + @ViewBuilder + private func renderChannelQuickEmptyState() -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("No quick settings for this channel.") + .font(.callout.weight(.semibold)) + Text("Use Config for account, guild, action, and policy details.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private static let channelQuickKeys: Set = [ + "apiHash", + "apiId", + "appToken", + "baseUrl", + "botToken", + "configWrites", + "deviceName", + "dmPolicy", + "enabled", + "groupPolicy", + "historyLimit", + "mode", + "nativeCommands", + "nativeSkillCommands", + "phoneNumber", + "signingSecret", + "token", + "url", + "username", + "webhookUrl", + ] + @ViewBuilder private func renderStringField( _ schema: ConfigSchemaNode, @@ -353,7 +480,11 @@ struct ChannelConfigForm: View { if self.store.configSchemaLoading { ProgressView().controlSize(.small) } else if let schema = store.channelConfigSchema(for: channelId) { - ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)]) + ConfigSchemaForm( + store: self.store, + schema: schema, + path: [.key("channels"), .key(self.channelId)], + mode: .channelQuick) } else { Text("Schema unavailable for this channel.") .font(.caption) diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift index 10ca93f73e0..5252d280993 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift @@ -6,7 +6,7 @@ extension ChannelsSettings { _ id: String, as type: T.Type) -> T? { - self.store.snapshot?.decodeChannel(id, as: type) + self.store.decodedChannel(id, as: type) } private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color { @@ -358,12 +358,16 @@ extension ChannelsSettings { } func ensureSelection() { + self.ensureSelection(in: self.orderedChannels) + } + + func ensureSelection(in orderedChannels: [ChannelItem]) { guard let selected = self.selectedChannel else { - self.selectedChannel = self.orderedChannels.first + self.selectedChannel = orderedChannels.first return } - if !self.orderedChannels.contains(selected) { - self.selectedChannel = self.orderedChannels.first + if !orderedChannels.contains(selected) { + self.selectedChannel = orderedChannels.first } } diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift index 9b3976f3bae..cb479cb24d8 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift @@ -2,34 +2,38 @@ import SwiftUI extension ChannelsSettings { var body: some View { - HStack(spacing: 0) { - self.sidebar + let channels = self.orderedChannels + return HStack(spacing: 0) { + self.sidebar(channels: channels) self.detail } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { self.store.start() - self.ensureSelection() + self.ensureSelection(in: channels) } - .onChange(of: self.orderedChannels) { _, _ in - self.ensureSelection() + .onChange(of: channels) { _, newValue in + self.ensureSelection(in: newValue) } .onDisappear { self.store.stop() } } - private var sidebar: some View { - SettingsSidebarScroll { + private func sidebar(channels: [ChannelItem]) -> some View { + let enabled = channels.filter { self.channelEnabled($0) } + let available = channels.filter { !self.channelEnabled($0) } + + return SettingsSidebarScroll { LazyVStack(alignment: .leading, spacing: 8) { - if !self.enabledChannels.isEmpty { + if !enabled.isEmpty { self.sidebarSectionHeader("Configured") - ForEach(self.enabledChannels) { channel in + ForEach(enabled) { channel in self.sidebarRow(channel) } } - if !self.availableChannels.isEmpty { + if !available.isEmpty { self.sidebarSectionHeader("Available") - ForEach(self.availableChannels) { channel in + ForEach(available) { channel in self.sidebarRow(channel) } } diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift index 7e297371307..e764c62b2ef 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift @@ -25,10 +25,10 @@ extension ChannelsStore { guard self.pollTask == nil else { return } self.pollTask = Task.detached { [weak self] in guard let self else { return } + await self.refresh(probe: false) async let schemaLoad: Void = self.loadConfigSchema() async let configLoad: Void = self.loadConfig(force: false) - async let statusRefresh: Void = self.refresh(probe: true) - _ = await (schemaLoad, configLoad, statusRefresh) + _ = await (schemaLoad, configLoad) while !Task.isCancelled { try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) await self.refresh(probe: false) @@ -47,14 +47,15 @@ extension ChannelsStore { defer { self.isRefreshing = false } do { + let statusTimeoutMs = probe ? 8000 : 2500 let params: [String: AnyCodable] = [ "probe": AnyCodable(probe), - "timeoutMs": AnyCodable(8000), + "timeoutMs": AnyCodable(statusTimeoutMs), ] let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded( method: .channelsStatus, params: params, - timeoutMs: 12000) + timeoutMs: probe ? 12000 : 5000) self.snapshot = snap self.lastSuccess = Date() self.lastError = nil diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore.swift b/apps/macos/Sources/OpenClaw/ChannelsStore.swift index 1e60c5b5684..439b716b475 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore.swift @@ -224,7 +224,11 @@ struct ConfigSnapshot: Codable { final class ChannelsStore { static let shared = ChannelsStore() - var snapshot: ChannelsStatusSnapshot? + var snapshot: ChannelsStatusSnapshot? { + didSet { + self.decodedChannelCache.removeAll(keepingCapacity: true) + } + } var lastError: String? var lastSuccess: Date? var isRefreshing = false @@ -255,6 +259,7 @@ final class ChannelsStore { var configRoot: [String: Any] = [:] var configLoaded = false var configSourceKey: String? + @ObservationIgnored private var decodedChannelCache: [String: Any] = [:] func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? { self.snapshot?.channelMeta?.first(where: { $0.id == id }) @@ -297,6 +302,18 @@ final class ChannelsStore { return self.snapshot?.channelOrder ?? [] } + func decodedChannel(_ id: String, as type: T.Type) -> T? { + let key = "\(id)#\(ObjectIdentifier(type))" + if let cached = self.decodedChannelCache[key] as? T { + return cached + } + guard let decoded = self.snapshot?.decodeChannel(id, as: type) else { + return nil + } + self.decodedChannelCache[key] = decoded + return decoded + } + func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) { self.whatsappLoginMessage = result.message self.whatsappLoginConnected = result.connected diff --git a/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift b/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift index 406d908d0b7..1df53053149 100644 --- a/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift +++ b/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift @@ -208,6 +208,25 @@ func isSensitivePath(_ path: ConfigPath) -> Bool { || key.hasSuffix("key") } +func labelForConfigPath(_ path: ConfigPath) -> String? { + for segment in path.reversed() { + if case let .key(key) = segment { + return humanizeConfigKey(key) + } + } + return nil +} + +func humanizeConfigKey(_ key: String) -> String { + key.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1 $2", + options: .regularExpression) + .capitalized +} + func pathKey(_ path: ConfigPath) -> String { path.compactMap { segment -> String? in switch segment { diff --git a/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/apps/macos/Sources/OpenClaw/SettingsRootView.swift index 90ea889483a..e080fa32ff5 100644 --- a/apps/macos/Sources/OpenClaw/SettingsRootView.swift +++ b/apps/macos/Sources/OpenClaw/SettingsRootView.swift @@ -45,6 +45,7 @@ struct SettingsRootView: View { } .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(SettingsWindowChromeConfigurator()) .onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in if let tab = note.object as? SettingsTab { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { @@ -75,12 +76,6 @@ struct SettingsRootView: View { guard !self.isPreview else { return } await self.refreshPerms() } - .task { - guard !self.isPreview else { return } - async let schemaLoad: Void = ChannelsStore.shared.loadConfigSchema() - async let configLoad: Void = ChannelsStore.shared.loadConfig(force: false) - _ = await (schemaLoad, configLoad) - } .task(id: self.state.connectionMode) { guard !self.isPreview else { return } await self.refreshSnapshotPaths() @@ -255,6 +250,27 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable { } } +private struct SettingsWindowChromeConfigurator: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + self.configureWindow(for: view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + self.configureWindow(for: nsView) + } + + private func configureWindow(for view: NSView) { + DispatchQueue.main.async { + guard let window = view.window else { return } + window.styleMask.remove(.fullSizeContentView) + window.titleVisibility = .visible + window.titlebarAppearsTransparent = true + } + } +} + @MainActor enum SettingsTabRouter { private static var pending: SettingsTab?