From 9aed01f245852423d82fc2a69ff2df109bec4b8c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 12 Oct 2026 14:33:56 +0700 Subject: [PATCH] fix(ai-providers): probe only configured providers in Settings > AI --- CHANGELOG.md | 1 + .../Core/AI/AIProviderAccountProbes.swift | 17 ++++++++ TablePro/Views/Settings/AISettingsView.swift | 32 +++++++++++--- .../AI/AIProviderAccountProbesTests.swift | 43 +++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 TablePro/Core/AI/AIProviderAccountProbes.swift create mode 100644 TableProTests/Core/AI/AIProviderAccountProbesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3944d16251..0edf911ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JSON and PHP tree values colored differently from the same values in the row inspector. - Autocomplete icon colors ignoring the theme. - Theme color wells and the theme preview unreadable to VoiceOver. +- Settings > AI reading sign-in tokens and running command line tools for providers that were never added. ## [0.80.0] - 2026-10-11 diff --git a/TablePro/Core/AI/AIProviderAccountProbes.swift b/TablePro/Core/AI/AIProviderAccountProbes.swift new file mode 100644 index 0000000000..a1746146c8 --- /dev/null +++ b/TablePro/Core/AI/AIProviderAccountProbes.swift @@ -0,0 +1,17 @@ +// +// AIProviderAccountProbes.swift +// TablePro +// + +import Foundation + +/// A sign-in status in Settings > AI costs a Keychain read or a spawned CLI, so it is checked only +/// for a provider the user has added, and not at all while AI is off. +enum AIProviderAccountProbes { + static let probedTypes: Set = [.chatgptCodex, .cursor, .xai, .claudeAgent] + + static func types(toProbe settings: AISettings) -> Set { + guard settings.enabled else { return [] } + return Set(settings.providers.map(\.type)).intersection(probedTypes) + } +} diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index a6f56f7479..7024041a7f 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -35,13 +35,9 @@ struct AISettingsView: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) - .task { refreshKeyAvailability() } - .task { await chatGPTCodexService.refreshAuthState() } - .task { await cursorAgentService.refreshStatus() } - .task { await xaiService.refreshAuthState() } - .task { await claudeAgentService.performRefresh() } - .onChange(of: settings.providers.map(\.id)) { _ in - refreshKeyAvailability() + .task(id: settings.enabled ? settings.providers.map(\.id) : []) { refreshKeyAvailability() } + .task(id: AIProviderAccountProbes.types(toProbe: settings)) { + await refreshAccountStatus(of: AIProviderAccountProbes.types(toProbe: settings)) } .sheet(item: editingProviderBinding) { provider in AIProviderDetailSheet( @@ -441,7 +437,29 @@ struct AISettingsView: View { return String(localized: "Not configured") } + private func refreshAccountStatus(of types: Set) async { + let chatGPT = chatGPTCodexService + let cursor = cursorAgentService + let xai = xaiService + let claudeAgent = claudeAgentService + await withTaskGroup(of: Void.self) { group in + if types.contains(.chatgptCodex) { + group.addTask { await chatGPT.refreshAuthState() } + } + if types.contains(.cursor) { + group.addTask { await cursor.refreshStatus() } + } + if types.contains(.xai) { + group.addTask { await xai.refreshAuthState() } + } + if types.contains(.claudeAgent) { + group.addTask { await claudeAgent.performRefresh() } + } + } + } + private func refreshKeyAvailability() { + guard settings.enabled else { return } var ids: Set = [] for provider in settings.providers where provider.authStyle.usesAPIKey { if let key = AIKeyStorage.shared.loadAPIKey(for: provider.id), !key.isEmpty { diff --git a/TableProTests/Core/AI/AIProviderAccountProbesTests.swift b/TableProTests/Core/AI/AIProviderAccountProbesTests.swift new file mode 100644 index 0000000000..d48cb5d155 --- /dev/null +++ b/TableProTests/Core/AI/AIProviderAccountProbesTests.swift @@ -0,0 +1,43 @@ +// +// AIProviderAccountProbesTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct AIProviderAccountProbesTests { + private func settings(enabled: Bool = true, _ types: AIProviderType...) -> AISettings { + AISettings(enabled: enabled, providers: types.map { AIProviderConfig(type: $0) }) + } + + @Test("Nothing is probed when no provider is configured") + func noProvidersProbesNothing() { + #expect(AIProviderAccountProbes.types(toProbe: settings()).isEmpty) + } + + @Test("Nothing is probed while AI is off, even with sign-in providers configured") + func aiOffProbesNothing() { + let configured = settings(enabled: false, .claudeAgent, .chatgptCodex, .cursor, .xai) + #expect(AIProviderAccountProbes.types(toProbe: configured).isEmpty) + } + + @Test("Only the configured sign-in providers are probed") + func onlyConfiguredProvidersAreProbed() { + let configured = settings(.claudeAgent, .openAI, .gemini) + #expect(AIProviderAccountProbes.types(toProbe: configured) == [.claudeAgent]) + } + + @Test("Every configured sign-in provider is probed once") + func everyConfiguredSignInProviderIsProbed() { + let configured = settings(.chatgptCodex, .cursor, .xai, .claudeAgent, .claudeAgent) + #expect(AIProviderAccountProbes.types(toProbe: configured) == [.chatgptCodex, .cursor, .xai, .claudeAgent]) + } + + @Test("API key providers never need an account probe") + func apiKeyProvidersAreNotProbed() { + let configured = settings(.claude, .openAI, .openRouter, .gemini, .ollama, .custom, .copilot) + #expect(AIProviderAccountProbes.types(toProbe: configured).isEmpty) + } +}