Skip to content

feat: AI Diagnostic Assistant settings + Settings sidebar refactor - #50

Merged
dereknex merged 2 commits into
mainfrom
ai-settings-clean
Jul 1, 2026
Merged

feat: AI Diagnostic Assistant settings + Settings sidebar refactor#50
dereknex merged 2 commits into
mainfrom
ai-settings-clean

Conversation

@dereknex

@dereknex dereknex commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Add a configurable AI Diagnostic Assistant to Kubebar Settings with Keychain-backed API keys and manual Test Connection. Redesign the Settings window from a stacked TabView into a sidebar/detail layout.

AI Diagnostic Assistant

  • Provider picker: OpenAI, Anthropic, Google Gemini, OpenAI-compatible (no Ollama)
  • Keychain-backed API key storage (never in AppConfig)
  • Manual Test Connection using minimal provider probe (no K8s data sent)
  • Injectably tested credential store, HTTP client, and connection tester
  • Safe failure messages that never leak keys or raw errors

Settings Sidebar Layout

  • macOS-style sidebar with App pages (General, Kubernetes, Notifications, AI Assistant) and Contexts
  • Detail pane with ScrollView, global footer save action
  • Enum-based page selection model with backward-compatible .appSettings alias
  • Keyboard shortcuts Cmd+1Cmd+4 for App pages
  • Config‑state warning icons for unconfigured contexts

UI Polish

  • Merged AI sections into Provider configuration + Connection
  • Widened form fields (280pt → 360pt)
  • Raised window height (560pt → 620pt)
  • Unified sidebar row alignment via custom HStack helper
  • Fixed invalid k.stack SF Symbol

Verification

  • 318 tests, 31 suites pass
  • swift build + swift-quality-gate.sh local pass
  • rtk git diff --check clean
  • App launches via compile-and-run.sh

Notes

  • AI output does not affect HealthEvaluator or menu health categories
  • API keys stored exclusively in macOS Keychain, not in AppConfig
  • Test Connection is manual and sends no Kubernetes data
  • Future AI diagnostic input limited to user‑approved warning text only
  • Layout quality is HITL (no automated snapshot tests)

@changeset-bot

changeset-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3632062

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions github-actions Bot added size: XL Very large change risk: low Low-risk change scope:app scope:tests scope:project tier: maintainer Maintainer-authored change and removed size: XL Very large change risk: low Low-risk change labels Jul 1, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the Settings UI into a native macOS-style sidebar/detail layout with dedicated App-level pages (General, Kubernetes, Notifications, and AI Assistant) and Context watchlist pages. It also introduces the AI Diagnostic Assistant feature, enabling users to configure an AI provider, store API keys securely in the macOS Keychain, and manually run connection tests. The reviewer's feedback focuses on improving robustness and idiomatic Swift usage, including: saving the API key to the Keychain before writing configuration to disk to prevent sync issues; tracking and cancelling active connection test tasks to avoid race conditions; setting appropriate Keychain accessibility attributes for login startup; implementing global keyboard shortcuts via hidden background buttons rather than list rows; and simplifying struct mutations by making configuration properties mutable.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 243 to +256
try configStore.save(completedConfig)

if runtimeState.setupState.aiDiagnosticAssistant.hasAPIKeyDraft {
do {
try aiCredentialStore.saveAPIKey(
runtimeState.setupState.aiDiagnosticAssistant.apiKeyDraft,
for: completedConfig.aiDiagnosticAssistant.provider
)
} catch {
runtimeState.markConfigurationSaveFailed(Self.aiCredentialSaveFailureMessage)
publishRuntimeState()
return false
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Save the API key to the Keychain before saving the non-secret configuration to disk, and ensure the API key draft is trimmed of any accidental whitespaces or newlines. If the Keychain save fails, the transaction should abort, preventing the configuration on disk from becoming out of sync (e.g., updated to a new provider but missing the corresponding API key).

Suggested change
try configStore.save(completedConfig)
if runtimeState.setupState.aiDiagnosticAssistant.hasAPIKeyDraft {
do {
try aiCredentialStore.saveAPIKey(
runtimeState.setupState.aiDiagnosticAssistant.apiKeyDraft,
for: completedConfig.aiDiagnosticAssistant.provider
)
} catch {
runtimeState.markConfigurationSaveFailed(Self.aiCredentialSaveFailureMessage)
publishRuntimeState()
return false
}
}
if runtimeState.setupState.aiDiagnosticAssistant.hasAPIKeyDraft {
do {
try aiCredentialStore.saveAPIKey(
runtimeState.setupState.aiDiagnosticAssistant.apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines),
for: completedConfig.aiDiagnosticAssistant.provider
)
} catch {
runtimeState.markConfigurationSaveFailed(Self.aiCredentialSaveFailureMessage)
publishRuntimeState()
return false
}
}
try configStore.save(completedConfig)

Comment on lines 61 to +65
private let healthShiftAlertNotifier: any HealthShiftAlertNotifying
private let networkReachability: any NetworkReachability
private let podLogStreamer: any PodLogStreaming
private let aiCredentialStore: any AIProviderCredentialStoring
private let aiConnectionTester: AIProviderConnectionTester?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent race conditions and redundant network requests when the user rapidly clicks the "Test Connection" button, track the active connection test task so it can be cancelled before starting a new one.

Suggested change
private let healthShiftAlertNotifier: any HealthShiftAlertNotifying
private let networkReachability: any NetworkReachability
private let podLogStreamer: any PodLogStreaming
private let aiCredentialStore: any AIProviderCredentialStoring
private let aiConnectionTester: AIProviderConnectionTester?
private let healthShiftAlertNotifier: any HealthShiftAlertNotifying
private let networkReachability: any NetworkReachability
private let podLogStreamer: any PodLogStreaming
private let aiCredentialStore: any AIProviderCredentialStoring
private let aiConnectionTester: AIProviderConnectionTester?
private var aiConnectionTestTask: Task<Void, Never>?

Comment on lines +352 to +371
func testAIConnection() {
guard let tester = aiConnectionTester else {
return
}

let provider = runtimeState.setupState.aiDiagnosticAssistant.config.provider
let config = runtimeState.setupState.aiDiagnosticAssistant.config
let draft = runtimeState.setupState.aiDiagnosticAssistant.hasAPIKeyDraft
? runtimeState.setupState.aiDiagnosticAssistant.apiKeyDraft
: nil

runtimeState.setupState.applyAIDiagnosticAssistantTestConnectionResult(nil)
publishRuntimeState()

Task {
let result = await tester.testConnection(config: config, provider: provider, apiKeyOverride: draft)
runtimeState.setupState.applyAIDiagnosticAssistantTestConnectionResult(result)
publishRuntimeState()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Cancel any active connection test task before starting a new one to prevent race conditions and redundant network requests under rapid clicking.

    func testAIConnection() {
        guard let tester = aiConnectionTester else {
            return
        }

        aiConnectionTestTask?.cancel()

        let provider = runtimeState.setupState.aiDiagnosticAssistant.config.provider
        let config = runtimeState.setupState.aiDiagnosticAssistant.config
        let draft = runtimeState.setupState.aiDiagnosticAssistant.hasAPIKeyDraft
            ? runtimeState.setupState.aiDiagnosticAssistant.apiKeyDraft
            : nil

        runtimeState.setupState.applyAIDiagnosticAssistantTestConnectionResult(nil)
        publishRuntimeState()

        aiConnectionTestTask = Task {
            let result = await tester.testConnection(config: config, provider: provider, apiKeyOverride: draft)
            guard !Task.isCancelled else { return }
            runtimeState.setupState.applyAIDiagnosticAssistantTestConnectionResult(result)
            publishRuntimeState()
        }
    }

Comment on lines +37 to +64
func saveAPIKey(_ key: String, for provider: AIProvider) throws {
let data = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: provider.rawValue
]

let attributes: [String: Any] = [
kSecValueData as String: data
]

let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)

switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
var addQuery = query
addQuery[kSecValueData as String] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
throw AIProviderCredentialStoreError.cannotSave
}
default:
throw AIProviderCredentialStoreError.cannotSave
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Explicitly set kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlock when saving or updating the API key. Since Kubebar supports "Start at Login", the app might launch and attempt to access the Keychain before the user has fully unlocked their session. Using kSecAttrAccessibleAfterFirstUnlock ensures the credential store remains accessible and reliable in these scenarios.

    func saveAPIKey(_ key: String, for provider: AIProvider) throws {
        let data = Data(key.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: Self.service,
            kSecAttrAccount as String: provider.rawValue
        ]

        let attributes: [String: Any] = [
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
        ]

        let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)

        switch updateStatus {
        case errSecSuccess:
            return
        case errSecItemNotFound:
            var addQuery = query
            addQuery[kSecValueData as String] = data
            addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
            let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
            if addStatus != errSecSuccess {
                throw AIProviderCredentialStoreError.cannotSave
            }
        default:
            throw AIProviderCredentialStoreError.cannotSave
        }
    }

Comment on lines 64 to 74
var body: some View {
VStack(alignment: .leading, spacing: 14) {
settingsTabs
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
settingsSidebar
Divider()
settingsDetail
}
footer
}
.padding(.horizontal, 24)
.padding(.top, 18)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Implement global keyboard shortcuts using hidden background buttons on the main container. This ensures the shortcuts work reliably and globally within the Settings window, as applying .keyboardShortcut directly to non-button list rows does not automatically trigger selection.

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack(spacing: 0) {
                settingsSidebar
                Divider()
                settingsDetail
            }
            footer
        }
        .background(
            ForEach(Array(SetupFlowState.appPages.enumerated()), id: \\.element.id) { index, page in
                Button("") {
                    selectedSettingsTabIDBinding.wrappedValue = page.id
                }
                .keyboardShortcut(KeyEquivalent(Character("\\(index + 1)")), modifiers: .command)
                .buttonStyle(.plain)
                .opacity(0)
                .frame(width: 0, height: 0)
            }
        )
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
    }

Comment on lines +79 to +88
ForEach(Array(SetupFlowState.appPages.enumerated()), id: \.element.id) { index, page in
sidebarRow(
title: page.title,
icon: page.systemImageName,
tabID: page.id,
help: page.helpText,
warning: false
)
.keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the .keyboardShortcut modifier from the individual list rows since they are not buttons and will not trigger selection automatically. The shortcuts are more reliably handled via background buttons on the main container.

                ForEach(Array(SetupFlowState.appPages.enumerated()), id: \\.element.id) { index, page in
                    sidebarRow(
                        title: page.title,
                        icon: page.systemImageName,
                        tabID: page.id,
                        help: page.helpText,
                        warning: false
                    )
                }

Comment on lines +9 to +39
public struct AIDiagnosticAssistantConfig: Codable, Equatable, Sendable {
public let provider: AIProvider
public var modelID: String
public let baseURL: String?

public init(
provider: AIProvider = .openAI,
modelID: String = "",
baseURL: String? = nil
) {
self.provider = provider
self.modelID = modelID
self.baseURL = baseURL
}

public func with(modelID: String) -> AIDiagnosticAssistantConfig {
AIDiagnosticAssistantConfig(
provider: provider,
modelID: modelID,
baseURL: baseURL
)
}

public func with(baseURL: String?) -> AIDiagnosticAssistantConfig {
AIDiagnosticAssistantConfig(
provider: provider,
modelID: modelID,
baseURL: baseURL
)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Make all properties of AIDiagnosticAssistantConfig mutable (var) instead of a mix of let and var with custom with methods. Since AIDiagnosticAssistantConfig is a struct (value type), mutating its properties is safe and idiomatic in Swift. This simplifies the model, removes boilerplate code, and makes updates consistent across all fields.

public struct AIDiagnosticAssistantConfig: Codable, Equatable, Sendable {
    public var provider: AIProvider
    public var modelID: String
    public var baseURL: String?

    public init(
        provider: AIProvider = .openAI,
        modelID: String = "",
        baseURL: String? = nil
    ) {
        self.provider = provider
        self.modelID = modelID
        self.baseURL = baseURL
    }
}

Comment on lines +349 to +352
public mutating func updateAIDiagnosticAssistant(baseURL: String?) {
aiDiagnosticAssistant.config = aiDiagnosticAssistant.config.with(baseURL: baseURL)
configurationMessage = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Simplify the mutation of baseURL by directly assigning the value to the mutable property, matching the pattern used for modelID.

Suggested change
public mutating func updateAIDiagnosticAssistant(baseURL: String?) {
aiDiagnosticAssistant.config = aiDiagnosticAssistant.config.with(baseURL: baseURL)
configurationMessage = nil
}
public mutating func updateAIDiagnosticAssistant(baseURL: String?) {
aiDiagnosticAssistant.config.baseURL = baseURL
configurationMessage = nil
}

@github-actions github-actions Bot added size: XL Very large change risk: low Low-risk change labels Jul 1, 2026
@dereknex
dereknex merged commit 2c60af0 into main Jul 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: low Low-risk change scope:app scope:project scope:tests size: XL Very large change tier: maintainer Maintainer-authored change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant