fix(mac): speed up channels settings

This commit is contained in:
Peter Steinberger
2026-05-17 06:31:45 +01:00
parent 38b3e73622
commit 06ec6b0fca
8 changed files with 229 additions and 35 deletions

View File

@@ -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.

View File

@@ -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<String> = [
"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)

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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<T: Decodable>(_ 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

View File

@@ -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 {

View File

@@ -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?