diff --git a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift index 8fffaf38..38f79c6c 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/AccountContextMenuView.swift @@ -9,6 +9,10 @@ struct AccountContextMenuView: View { store.auth } + private var usagePresentation: AccountUsageSummaryPresentation { + AccountUsageSummaryPresentation(account: account) + } + private func requestDestructiveAccountAction() { if auth.selectedAccount == account { store.requestSignOutActiveAccount(requiresConfirmation: store.hasRunningReviewRuns) @@ -23,13 +27,17 @@ struct AccountContextMenuView: View { store.requestSwitchAccountFromUserAction(account) } .disabled(store.switchActionIsDisabled(for: account)) - - Button("Refresh", systemImage: "arrow.clockwise") { - refreshRateLimits() + + if usagePresentation.showsRateLimitControls { + Button("Refresh", systemImage: "arrow.clockwise") { + refreshRateLimits() + } } } - Section{ - AccountRateLimitsSectionView(account:account) + if usagePresentation.showsRateLimitControls { + Section{ + AccountRateLimitsSectionView(account:account) + } } Section{ Button("Sign Out", systemImage: "rectangle.portrait.and.arrow.right", role:.destructive) { diff --git a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift index 3d616294..468a194d 100644 --- a/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift +++ b/Sources/ReviewUI/Sidebar/Accounts/ReviewMonitorAccountRowView.swift @@ -37,7 +37,7 @@ struct ReviewMonitorAccountRowView: View { account: account ) } label: { - AccountRateLimitGaugesView( + AccountUsageSummaryView( account: account ) .textScale(.secondary) diff --git a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift b/Sources/ReviewUI/Status/AccountUsageSummaryView.swift similarity index 63% rename from Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift rename to Sources/ReviewUI/Status/AccountUsageSummaryView.swift index d051fe97..a19c4260 100644 --- a/Sources/ReviewUI/Status/AccountRateLimitGaugesView.swift +++ b/Sources/ReviewUI/Status/AccountUsageSummaryView.swift @@ -1,7 +1,43 @@ import SwiftUI import CodexReviewKit -struct AccountRateLimitGaugesView: View { +enum AccountUsageSummaryPresentation: Equatable { + case loading + case gauges + case provider(title: String, systemImage: String) + + @MainActor + init(account: CodexReviewAccount?) { + guard let account else { + self = .loading + return + } + guard account.capabilities.supportsRateLimitRefresh == false else { + self = account.rateLimits.isEmpty ? .loading : .gauges + return + } + + self = switch account.kind { + case .chatGPT: + .provider(title: "Using ChatGPT", systemImage: "person.crop.circle.fill") + case .apiKey: + .provider(title: "Using API Key", systemImage: "key.fill") + case .amazonBedrock: + .provider(title: "Using Amazon Bedrock", systemImage: "cloud.fill") + } + } + + var showsRateLimitControls: Bool { + switch self { + case .loading, .gauges: + true + case .provider: + false + } + } +} + +struct AccountUsageSummaryView: View { var account: CodexReviewAccount? private static let placeholderRateLimits: [CodexReviewAccount.RateLimitWindow] = [ @@ -23,12 +59,10 @@ struct AccountRateLimitGaugesView: View { rateLimits.isEmpty ? Self.placeholderRateLimits : rateLimits } - private var showsRedactedRateLimits: Bool { - rateLimits.isEmpty - } - var body: some View { - Group { + let presentation = AccountUsageSummaryPresentation(account: account) + switch presentation { + case .loading, .gauges: VStack(spacing:0) { ForEach(displayedRateLimits) { window in RateLimitWindowGaugeView(window: window) @@ -37,8 +71,16 @@ struct AccountRateLimitGaugesView: View { } } } - .redacted(reason: showsRedactedRateLimits ? .placeholder : []) - .animation(.easeInOut, value: showsRedactedRateLimits) + .redacted(reason: presentation == .loading ? .placeholder : []) + .animation(.easeInOut, value: presentation == .loading) + case .provider(let title, let systemImage): + Label { + Text(title) + .lineLimit(1) + } icon: { + Image(systemName: systemImage) + } + .foregroundStyle(.secondary) } } } @@ -95,11 +137,23 @@ extension CodexReviewAccount.RateLimitWindow { #if DEBUG #Preview("Account Rate Limit Gauges") { - AccountRateLimitGaugesView(account: makeAccountRateLimitGaugesPreviewAccount()) + AccountUsageSummaryView(account: makeAccountRateLimitGaugesPreviewAccount()) .padding() .frame(width: 320) } +#Preview("API Key Usage") { + AccountUsageSummaryView( + account: CodexReviewAccount( + accountKey: "api-key", + email: "API Key", + kind: .apiKey + ) + ) + .padding() + .frame(width: 320) +} + @MainActor private func makeAccountRateLimitGaugesPreviewAccount() -> CodexReviewAccount { let account = CodexReviewAccount(email: "review@example.com", planType: "pro") diff --git a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift index deb060aa..2bad7284 100644 --- a/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift +++ b/Sources/ReviewUI/Status/ReviewMonitorServerStatusAccessoryViewController.swift @@ -172,10 +172,15 @@ struct StatusView: View { var body: some View { let currentAccount = store.auth.selectedAccount + let usagePresentation = AccountUsageSummaryPresentation(account: currentAccount) VStack{ Menu { - Section(currentAccount?.reviewMonitorIdentityName ?? "") { - AccountRateLimitsSectionView(account: currentAccount) + if let currentAccount, + usagePresentation.showsRateLimitControls + { + Section(currentAccount.reviewMonitorIdentityName) { + AccountRateLimitsSectionView(account: currentAccount) + } } if let showSettings { Section{ @@ -196,7 +201,7 @@ struct StatusView: View { } } } label: { - AccountRateLimitGaugesView(account: currentAccount) + AccountUsageSummaryView(account: currentAccount) .transition(.blurReplace) .animation(.default, value: currentAccount) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift b/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift index ebbbcf21..5664e876 100644 --- a/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift @@ -86,6 +86,61 @@ struct ReviewMonitorAddAccountActionTests { #expect(bedrockAccount.reviewMonitorIdentityName == "Amazon Bedrock") } + @Test func usageSummaryDistinguishesLoadingGaugesAndProviderModes() { + let loadingAccount = CodexReviewAccount( + accountKey: "chatgpt@example.com", + email: "chatgpt@example.com", + kind: .chatGPT + ) + let gaugesAccount = CodexReviewAccount( + accountKey: "gauges@example.com", + email: "gauges@example.com", + kind: .chatGPT + ) + gaugesAccount.updateRateLimits([ + (windowDurationMinutes: 300, usedPercent: 25, resetsAt: nil), + ]) + let apiKeyAccount = CodexReviewAccount( + accountKey: "api-key", + email: "API Key", + kind: .apiKey + ) + let bedrockAccount = CodexReviewAccount( + accountKey: "bedrock", + email: "Amazon Bedrock", + kind: .amazonBedrock + ) + let capabilityDrivenAccount = CodexReviewAccount( + accountKey: "chatgpt-without-limits", + email: "chatgpt-without-limits@example.com", + kind: .chatGPT, + capabilities: .noCodexRateLimits + ) + + #expect(AccountUsageSummaryPresentation(account: nil) == .loading) + #expect(AccountUsageSummaryPresentation(account: loadingAccount) == .loading) + #expect(AccountUsageSummaryPresentation(account: gaugesAccount) == .gauges) + #expect( + AccountUsageSummaryPresentation(account: apiKeyAccount) + == .provider(title: "Using API Key", systemImage: "key.fill") + ) + #expect( + AccountUsageSummaryPresentation(account: bedrockAccount) + == .provider(title: "Using Amazon Bedrock", systemImage: "cloud.fill") + ) + #expect( + AccountUsageSummaryPresentation(account: capabilityDrivenAccount) + == .provider(title: "Using ChatGPT", systemImage: "person.crop.circle.fill") + ) + #expect(AccountUsageSummaryPresentation.loading.showsRateLimitControls) + #expect(AccountUsageSummaryPresentation.gauges.showsRateLimitControls) + #expect( + AccountUsageSummaryPresentation + .provider(title: "Using API Key", systemImage: "key.fill") + .showsRateLimitControls == false + ) + } + @Test func operationFailureUsesTheAccountActionAlertFlow() async throws { let store = CodexReviewStore.makePreviewStore() diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 7b539632..15ec1303 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -714,6 +714,31 @@ struct ReviewUITests { #expect(accountsViewController.hasTemporaryContextMenuForTesting == false) } + @Test func apiKeyAccountContextMenuOmitsRateLimitActions() throws { + let apiKeyAccount = CodexReviewAccount( + accountKey: "api-key", + email: "API Key", + kind: .apiKey + ) + let store = CodexReviewStore.makePreviewStore() + store.loadForTesting( + serverState: .running, + account: apiKeyAccount, + persistedAccounts: [apiKeyAccount] + ) + let displayedAPIKeyAccount = try #require(store.auth.persistedAccounts.first) + + let menu = NSHostingMenu( + rootView: AccountContextMenuView( + store: store, + account: displayedAPIKeyAccount + ) + ) + let presentedTitles = menu.items.map(\.title).filter { $0.isEmpty == false } + + #expect(presentedTitles == ["API Key", "Switch", "Sign Out"]) + } + @Test func accountOutlineRowsRejectUserSelection() async throws { let activeAccount = CodexReviewAccount(email: "active@example.com", planType: "pro") let otherAccount = CodexReviewAccount(email: "other@example.com", planType: "plus")