Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,26 @@ nonisolated enum BridgeAIProviderSecretKind: String, Sendable {
case oauthRefreshToken
}

private final nonisolated class BridgeAIProviderStoreURLOverride: @unchecked Sendable {
private let lock = NSLock()
private var url: URL?

func set(_ url: URL?) {
lock.withLock {
self.url = url
}
}

func get() -> URL? {
lock.withLock {
url
}
}
}

nonisolated enum BridgeAIProviderSecretStore {
private static let storeURLOverride = BridgeAIProviderStoreURLOverride()

private struct StoreFile: Codable {
var settings = BridgeAIProviderSettings()
var secrets: [String: String] = [:]
Expand Down Expand Up @@ -36,6 +55,7 @@ nonisolated enum BridgeAIProviderSecretStore {
store.settings = settings
try saveStore(store)
}.value
await notifyAIProviderStoreDidChange()
}

static func readSecret(
Expand Down Expand Up @@ -64,6 +84,7 @@ nonisolated enum BridgeAIProviderSecretStore {
store.secrets[account] = trimmed
try saveStore(store)
}.value
await notifyAIProviderStoreDidChange()
}

static func hasSecret(
Expand All @@ -78,7 +99,17 @@ nonisolated enum BridgeAIProviderSecretStore {
"\(provider.rawValue).\(kind.rawValue)"
}

private static func notifyAIProviderStoreDidChange() async {
await MainActor.run {
NotificationCenter.default.post(name: .aiProviderSettingsDidChange, object: nil)
}
}

private static var storeURL: URL {
if let override = resolvedStoreURLOverride {
return override
}

let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support", isDirectory: true)
Expand All @@ -88,6 +119,14 @@ nonisolated enum BridgeAIProviderSecretStore {
.appendingPathComponent("secrets.json", isDirectory: false)
}

static func setStoreURLForTesting(_ url: URL?) {
storeURLOverride.set(url)
}

private static var resolvedStoreURLOverride: URL? {
storeURLOverride.get()
}

private static func loadStore() throws -> StoreFile {
let url = storeURL
guard FileManager.default.fileExists(atPath: url.path) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,25 @@ extension ChatEditorViewModel {
}

func loadSelectedModel() async {
await loadSelectedModel(isCurrentReload: { true })
}

func loadSelectedModelIfCurrent(generation: UInt64) async {
await loadSelectedModel(isCurrentReload: { self.isCurrentSelectedModelReload(generation: generation) })
}

private func loadSelectedModel(isCurrentReload: () -> Bool) async {
var settings = await BridgeAIProviderSecretStore.readSettings()
guard isCurrentReload() else { return }

availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings)
if settings.selectedModelProvider == "openai",
settings[.openAI].authMethod == .oauth,
BridgeAIProviderRegistry.displayModel(provider: "openai-codex", id: settings.selectedModelID) != nil
{
settings.selectedModelProvider = "openai-codex"
try? await BridgeAIProviderSecretStore.saveSettings(settings)
guard isCurrentReload() else { return }
}
availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings)
guard hasAvailableModelSelection else {
Expand Down
31 changes: 30 additions & 1 deletion macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ final class ChatEditorViewModel {
var voicePendingWaveformPeak: Double = 0
@ObservationIgnored
private var quoteFocusRequestCounter: Int = 0
@ObservationIgnored
private var aiProviderSettingsCancellable: AnyCancellable?
@ObservationIgnored
private var aiProviderSettingsReloadTask: Task<Void, Never>?
@ObservationIgnored
private var aiProviderSettingsReloadGeneration: UInt64 = 0

/// Skill selected for the current conversation (delegated to Chat)
var selectedSkill: Skill? {
Expand Down Expand Up @@ -125,7 +131,30 @@ final class ChatEditorViewModel {
text: String = ""
) {
self.text = text
Task { await loadSelectedModel() }
setupAIProviderSettingsObservation()
scheduleSelectedModelReload()
}

private func setupAIProviderSettingsObservation() {
aiProviderSettingsCancellable = NotificationCenter.default
.publisher(for: .aiProviderSettingsDidChange)
Comment on lines +138 to +140
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.scheduleSelectedModelReload()
}
}

private func scheduleSelectedModelReload() {
aiProviderSettingsReloadGeneration += 1
let generation = aiProviderSettingsReloadGeneration
aiProviderSettingsReloadTask?.cancel()
aiProviderSettingsReloadTask = Task { [weak self] in
await self?.loadSelectedModelIfCurrent(generation: generation)
}
}

func isCurrentSelectedModelReload(generation: UInt64) -> Bool {
!Task.isCancelled && generation == aiProviderSettingsReloadGeneration
}

// MARK: - State Helpers
Expand Down
4 changes: 4 additions & 0 deletions macos/OpenBridge/Notifications/NotificationNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ extension Notification.Name {
static let skillActivationRequested = Notification.Name("skillActivationRequested")
static let skillInventoryDidChange = Notification.Name("skillInventoryDidChange")

// MARK: - AI Providers

static let aiProviderSettingsDidChange = Notification.Name("aiProviderSettingsDidChange")

// MARK: - Shortcuts

static let keyboardShortcutsShortcutDidChange = Notification.Name("KeyboardShortcuts_shortcutByNameDidChange")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import ComposerEditor
import Foundation
@testable import OpenBridge
import Testing

@MainActor
struct ChatEditorViewModelProviderSettingsTests {
@Test
func `provider settings notification refreshes composer model state`() async throws {
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
.appendingPathComponent("secrets.json", isDirectory: false)
BridgeAIProviderSecretStore.setStoreURLForTesting(storeURL)
defer {
BridgeAIProviderSecretStore.setStoreURLForTesting(nil)
try? FileManager.default.removeItem(at: storeURL.deletingLastPathComponent())
}

let viewModel = ChatEditorViewModel()
#expect(viewModel.hasAvailableModelSelection == false)

var settings = BridgeAIProviderSettings(selectedModelProvider: "openai", selectedModelID: "gpt-5")
var openAIConfig = settings[.openAI]
openAIConfig.isEnabled = true
openAIConfig.authMethod = .apiKey
settings[.openAI] = openAIConfig

try await BridgeAIProviderSecretStore.saveSettings(settings)
try await waitUntil {
viewModel.hasAvailableModelSelection
&& viewModel.selectedModelProvider == "openai"
&& viewModel.selectedModelID == "gpt-5"
}

#expect(viewModel.composerRealModelSelectorConfig?.selectedModelTitle == "GPT-5")
}

private func waitUntil(
timeout: Duration = .seconds(2),
condition: @MainActor @escaping () -> Bool
) async throws {
let start = ContinuousClock.now
while !condition() {
if ContinuousClock.now - start > timeout {
Issue.record("Timed out waiting for condition.")
return
}
try await Task.sleep(nanoseconds: 50_000_000)
}
}
}