Skip to content

feat: multi-account support — domain model + UI components (#86)#160

Merged
hanrw merged 5 commits intotddworks:mainfrom
marcusquinn:feature/multi-account-support
Mar 27, 2026
Merged

feat: multi-account support — domain model + UI components (#86)#160
hanrw merged 5 commits intotddworks:mainfrom
marcusquinn:feature/multi-account-support

Conversation

@marcusquinn
Copy link
Copy Markdown
Contributor

@marcusquinn marcusquinn commented Mar 21, 2026

Summary

Introduces multi-account support for AI providers, addressing #86 (and related #92, #105). This PR adds the domain model foundation (Phase 1) and UI components (Phase 2) as additive, non-breaking changes.

Phase 1: Domain Model

New Domain Types

  • ProviderAccount — Value type representing a named account within a provider. Uses compound ID format (providerId.accountId) that is backward compatible: the default account's ID equals the bare provider ID.

  • MultiAccountProvider — Opt-in protocol extending AIProvider for providers that support multiple accounts. Includes:

    • Account switching (switchAccount(to:))
    • Per-account refresh (refreshAccount(_:))
    • Aggregate status across all accounts (worst status wins)
    • Best-available-account query (highest remaining quota)
    • Default implementations for aggregate status and best-available
  • MultiAccountSettingsRepository — Settings protocol for persisting account configurations per provider, with ProviderAccountConfig (Codable) for JSON serialization.

  • AIProviderRepository extension — Convenience methods for querying multi-account providers and total account counts.

Tests (Chicago School TDD)

  • ProviderAccountTests — Identity (compound IDs, backward compat), display name fallback chain, initial letter
  • ProviderAccountConfigTests — Domain model conversion, Codable round-trip, equality

Phase 2: UI Components

New Views

  • AccountPickerView — Compact horizontal pill-based account switcher for the provider section header. Uses the same visual language as the existing ProviderPill component. Only renders when a provider conforms to MultiAccountProvider and has >1 account.

  • AccountManagementCard — Settings card for managing accounts on a multi-account provider. Shows account list with status indicators, active account checkmark, switch buttons, and an add-account placeholder. Uses DisclosureGroup consistent with existing settings cards.

Integration Points (not wired yet)

These views are ready to integrate once a concrete provider implements MultiAccountProvider:

  • AccountPickerViewMenuContentView.accountCard() header
  • AccountManagementCardSettingsContentView after provider-specific config cards

Design Decisions

  1. Opt-in protocol rather than modifying AIProvider — zero migration cost for existing providers.
  2. Compound IDs (claude.personal) — existing QuotaMonitor.provider(for:) lookup works unchanged.
  3. Flexible probe config ([String: String]) — different providers need different config (CLI profiles, API tokens, config paths).
  4. Additive UI — views only appear when a provider opts in. Single-account providers see no change.

Next Steps (Phase 3)

  • Make ClaudeProvider implement MultiAccountProvider as the first concrete example
  • Wire AccountPickerView into MenuContentView
  • Wire AccountManagementCard into SettingsContentView
  • Implement MultiAccountSettingsRepository in JSONSettingsRepository

Verification

All files pass swiftc -parse syntax validation. Tests use Swift Testing (@Suite, @Test, #expect), consistent with the existing test suite.

Closes #86

Summary by CodeRabbit

  • New Features

    • Multi-account support for AI providers: add, list, update, remove accounts, set an active account, aggregate quota status and best-account suggestion.
    • New account model and per-account configuration with label, optional email/organization and custom probe settings.
    • UI: compact account picker and Accounts management card with per-account status, switching and add-account flow.
  • Tests

    • Added tests for account config and account model behavior, including encoding and equality.
  • Chores

    • Repository opt-out marker added for AI training.

Introduce domain-layer abstractions for supporting multiple accounts
per AI provider (addresses tddworks#86, tddworks#92, tddworks#105):

- ProviderAccount: value type representing a named account within a
  provider, with compound ID format (providerId.accountId) that is
  backward compatible with single-account providers (default account
  ID equals provider ID)

- MultiAccountProvider: opt-in protocol extending AIProvider for
  providers that support multiple accounts. Includes account switching,
  per-account refresh, aggregate status, and best-available-account
  queries. Existing single-account providers are unchanged.

- MultiAccountSettingsRepository: settings protocol for persisting
  account configurations per provider, with ProviderAccountConfig
  (Codable) for JSON serialization

- ProviderAccountTests + ProviderAccountConfigTests: Chicago School
  TDD tests verifying state and identity behavior

Phase 1 establishes the domain model foundation. Phase 2 will add UI
(account tabs/switcher in ProviderSectionView). Phase 3 will add
per-provider probe configs for multi-account (Claude profiles, Codex
workspaces, etc.).
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d74e0542-790a-4cd0-a6f7-e6269bc9ab9f

📥 Commits

Reviewing files that changed from the base of the PR and between a69c24f and d6a3cb3.

📒 Files selected for processing (1)
  • Tests/DomainTests/Provider/ProviderAccountConfigTests.swift
✅ Files skipped from review due to trivial changes (1)
  • Tests/DomainTests/Provider/ProviderAccountConfigTests.swift

📝 Walkthrough

Walkthrough

Adds multi-account support: new ProviderAccount model and ProviderAccountConfig, a MultiAccountSettingsRepository protocol, a MultiAccountProvider API with quota aggregation, SwiftUI account picker and management views, and tests for account models and config serialization.

Changes

Cohort / File(s) Summary
Domain: Account model
Sources/Domain/Provider/ProviderAccount.swift
New ProviderAccount value type (Sendable, Equatable, Identifiable) with accountId, providerId, label, optional email/organization, defaultAccountId, computed id, displayName, isDefault, and initialLetter.
Domain: Settings repo & config
Sources/Domain/Provider/MultiAccountSettingsRepository.swift
Adds MultiAccountSettingsRepository protocol (extends ProviderSettingsRepository) with list/add/remove/update account APIs and active-account management; introduces ProviderAccountConfig (Codable, Sendable, Equatable) and toProviderAccount(providerId:).
Domain: Provider multi-account support
Sources/Domain/Provider/MultiAccountSupport.swift
Adds MultiAccountProvider protocol (extends AIProvider) exposing accounts, activeAccount, accountSnapshots, switch/refresh APIs, and computed aggregateStatus/bestAvailableAccount. Extends AIProviderRepository with multi-account helpers and totalAccountCount.
App: Account selection UI
Sources/App/Views/AccountPickerView.swift
Adds AccountPickerView and AccountPill SwiftUI views for horizontally selecting/switching provider accounts; invokes provided switch callback and reflects active state.
App: Account management UI
Sources/App/Views/Settings/AccountManagementCard.swift
Adds AccountManagementCard SwiftUI view: collapsible accounts list with per-account status, active indicator / switch action, “Add Account” control, and aggregate status badge.
Tests
Tests/DomainTests/Provider/ProviderAccountTests.swift, Tests/DomainTests/Provider/ProviderAccountConfigTests.swift
Adds tests validating ProviderAccount identity/display/initial-letter behavior and ProviderAccountConfig mapping, Codable conformance, and equatability.
Repo metadata
.gitattributes
Adds ai-training=false entry for repository files.

Sequence Diagram(s)

sequenceDiagram
    participant User as "User (UI)"
    participant Picker as "AccountPickerView"
    participant Provider as "MultiAccountProvider"
    participant Repo as "MultiAccountSettingsRepository"
    participant Monitor as "QuotaMonitor"

    User->>Picker: tap account pill
    Picker->>Provider: switchAccount(to: accountId)
    Provider->>Repo: setActiveAccountId(accountId, forProvider:)
    Provider->>Monitor: refreshAccount(accountId) async
    Monitor-->>Provider: UsageSnapshot
    Provider-->>Picker: update activeAccount & accountSnapshots
    Picker-->>User: render active state and statuses
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through accounts behind the log,

labels and emails snug like a cog,
I switched with a tap and watched quotas sing,
nibbling configs, fresh features to bring,
a happy little rabbit, multi-account prog!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: introducing multi-account support with both domain model and UI components.
Linked Issues check ✅ Passed The PR implements the core coding requirements from issue #86: ProviderAccount domain model, MultiAccountProvider protocol, account switching, settings persistence, and UI components for account management.
Out of Scope Changes check ✅ Passed All changes are scoped to multi-account support foundation: domain models, protocols, tests, and UI components. The .gitattributes addition is a standard repository configuration file.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
Sources/Domain/Provider/ProviderAccount.swift (1)

69-84: Consider edge case: empty displayName produces empty initialLetter.

If label is empty, email is nil, and accountId is an empty string, then displayName returns "" and initialLetter also returns "". While unlikely in practice (accountId should be non-empty), the avatar circle would have no letter.

A defensive fallback could return a placeholder like "?" for empty display names.

🛡️ Optional defensive fallback
     public var initialLetter: String {
-        String(displayName.prefix(1)).uppercased()
+        let letter = String(displayName.prefix(1)).uppercased()
+        return letter.isEmpty ? "?" : letter
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Domain/Provider/ProviderAccount.swift` around lines 69 - 84, The
initialLetter computed property can return an empty string if displayName is
empty; update ProviderAccount.initialLetter to defensively handle that by
checking displayName (or its prefix) and returning a single-character fallback
(e.g. "?") when empty, otherwise returning
String(displayName.prefix(1)).uppercased(); change only the logic inside the
initialLetter getter to perform this empty-check and uppercase transformation so
avatar circles always show a character.
Tests/DomainTests/Provider/ProviderAccountTests.swift (1)

29-38: Misleading test name: the assertion proves inequality, not equality.

The test name states "Two accounts with same accountId and providerId are equal" but Line 35 asserts #expect(a != b). The test actually demonstrates that ProviderAccount compares all fields (including label), so accounts with different labels are not equal despite matching IDs.

Consider renaming to clarify the behavior being tested.

✏️ Suggested rename
-    `@Test`("Two accounts with same accountId and providerId are equal")
+    `@Test`("Accounts with same IDs but different labels are not equal")
     func equalityByIdAndProvider() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Tests/DomainTests/Provider/ProviderAccountTests.swift` around lines 29 - 38,
Rename or revise the test so its name matches the asserted behavior: either
rename the test function equalityByIdAndProvider to something like
test_accountsWithSameIdAndProvider_butDifferentLabels_areNotEqual, or change the
assertions to match an equality expectation; specifically update the test
function equalityByIdAndProvider that constructs ProviderAccount instances a and
b (same accountId/providerId, different label) so the name reflects that
ProviderAccount equality compares all fields (label differs) and therefore
assert a != b while verifying a.id == b.id remains true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Domain/Provider/ProviderAccount.swift`:
- Around line 69-84: The initialLetter computed property can return an empty
string if displayName is empty; update ProviderAccount.initialLetter to
defensively handle that by checking displayName (or its prefix) and returning a
single-character fallback (e.g. "?") when empty, otherwise returning
String(displayName.prefix(1)).uppercased(); change only the logic inside the
initialLetter getter to perform this empty-check and uppercase transformation so
avatar circles always show a character.

In `@Tests/DomainTests/Provider/ProviderAccountTests.swift`:
- Around line 29-38: Rename or revise the test so its name matches the asserted
behavior: either rename the test function equalityByIdAndProvider to something
like test_accountsWithSameIdAndProvider_butDifferentLabels_areNotEqual, or
change the assertions to match an equality expectation; specifically update the
test function equalityByIdAndProvider that constructs ProviderAccount instances
a and b (same accountId/providerId, different label) so the name reflects that
ProviderAccount equality compares all fields (label differs) and therefore
assert a != b while verifying a.id == b.id remains true.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc6a98ea-5bde-4294-96dd-a7b713ffaf0b

📥 Commits

Reviewing files that changed from the base of the PR and between 6c9699a and c10b5ba.

📒 Files selected for processing (5)
  • Sources/Domain/Provider/MultiAccountSettingsRepository.swift
  • Sources/Domain/Provider/MultiAccountSupport.swift
  • Sources/Domain/Provider/ProviderAccount.swift
  • Tests/DomainTests/Provider/ProviderAccountConfigTests.swift
  • Tests/DomainTests/Provider/ProviderAccountTests.swift

Add SwiftUI views for multi-account provider support:

- AccountPickerView: compact horizontal pill-based account switcher
  shown in the provider section header when a provider conforms to
  MultiAccountProvider and has >1 account. Uses the same visual
  language as the existing ProviderPill component.

- AccountManagementCard: settings card for managing accounts on a
  multi-account provider. Shows account list with status indicators,
  active account checkmark, switch buttons, and an add-account
  placeholder. Uses DisclosureGroup consistent with existing settings
  cards (ProvidersCard, BackgroundSyncCard, etc.).

Both views are additive — they only render when a provider conforms
to MultiAccountProvider (from Phase 1). Single-account providers
are completely unaffected.

Integration points (not wired yet — requires a concrete
MultiAccountProvider implementation):
- AccountPickerView goes in MenuContentView.accountCard() header
- AccountManagementCard goes in SettingsContentView after the
  provider-specific config cards
@marcusquinn marcusquinn changed the title feat: add multi-account domain model (Phase 1 of #86) feat: multi-account support — domain model + UI components (#86) Mar 21, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
Sources/App/Views/Settings/AccountManagementCard.swift (2)

11-11: Unused monitor property.

monitor: QuotaMonitor is declared but never referenced in the view. If it's planned for future use (e.g., triggering refreshes), consider adding a // TODO comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/App/Views/Settings/AccountManagementCard.swift` at line 11, The
AccountManagementCard view declares an unused property "monitor: QuotaMonitor" —
either remove the unused property or mark intent with a TODO; update the
AccountManagementCard struct by deleting the "monitor: QuotaMonitor" declaration
if it's not needed, or replace it with a comment like "// TODO: add usage for
monitor to trigger quota refreshes" and keep the property only if you will
reference it elsewhere (e.g., in init or body). Ensure any initializers or
callers that passed a QuotaMonitor are updated accordingly if you remove the
property.

138-139: Consider providing feedback when account switch fails.

switchAccount(to:) returns a Bool indicating success, but the result is discarded. If the switch fails (e.g., invalid account ID), the user receives no feedback. This is low risk since accounts come from the provider's own list, but a defensive check could improve UX.

💡 Optional: Add feedback on failure
                 Button {
-                    provider.switchAccount(to: account.accountId)
+                    let success = provider.switchAccount(to: account.accountId)
+                    if !success {
+                        // Log or show brief feedback
+                        AppLog.warning("Failed to switch to account: \(account.accountId)", category: .ui)
+                    }
                 } label: {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/App/Views/Settings/AccountManagementCard.swift` around lines 138 -
139, The Button action currently calls provider.switchAccount(to:
account.accountId) and ignores its Bool result; change it to capture the return
value and present user feedback on failure: call let success =
provider.switchAccount(to: account.accountId) and if success is false set a
local `@State` flag (e.g., showSwitchError) or error message state and present an
Alert or inline error in AccountManagementCard to inform the user the account
switch failed; ensure you reference provider.switchAccount(to:) and the Button
action and add the `@State` properties and Alert presentation in the view.
Sources/App/Views/AccountPickerView.swift (2)

34-34: Consider marking AccountPill as private.

Since AccountPill is only used within this file as an implementation detail of AccountPickerView, limiting its visibility would better encapsulate the module's internal structure.

🔒 Proposed scope change
-struct AccountPill: View {
+private struct AccountPill: View {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/App/Views/AccountPickerView.swift` at line 34, The AccountPill view
is currently internal but is only used inside this file; change its declaration
to private to encapsulate it. Locate the struct AccountPill: View and mark it
private (private struct AccountPill: View) so it remains file-scoped and cannot
be accessed externally; ensure any references from AccountPickerView or other
symbols in this file still compile after the visibility change.

43-73: Consider adding accessibility labels for screen reader support.

The button lacks an accessibility label, which means VoiceOver users won't have meaningful context about which account they're selecting or its active state.

♿ Proposed accessibility improvement
         }
         .buttonStyle(.plain)
         .onHover { isHovering = $0 }
+        .accessibilityLabel("\(account.displayName) account\(isActive ? ", active" : "")")
+        .accessibilityHint(isActive ? "Currently selected" : "Double-tap to switch")
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/App/Views/AccountPickerView.swift` around lines 43 - 73, Add
VoiceOver support to the account selection Button by giving it a descriptive
accessibility label and traits: on the Button(action: action) that wraps the
avatar and display name, set an accessibilityLabel that includes
account.displayName and the active state (use isActive to append "selected" or
similar), add an accessibilityHint like "Double tap to select this account", and
apply accessibilityAddTraits(.isSelected) when isActive; optionally set an
accessibilityIdentifier using account.displayName or a stable account id for UI
tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/App/Views/Settings/AccountManagementCard.swift`:
- Line 15: The Add Account sheet state (showAddSheet) is toggled but never used
to present a sheet; attach a .sheet(isPresented: $showAddSheet) modifier to the
parent view (where the button toggles showAddSheet) to present the add-account
view (or a placeholder) when true, referencing showAddSheet and the add-account
view struct (or a temporary View) so tapping the button actually displays the
sheet; if the sheet view is not ready, disable the Add Account button or present
a placeholder view in the sheet to avoid a no-op tap.

---

Nitpick comments:
In `@Sources/App/Views/AccountPickerView.swift`:
- Line 34: The AccountPill view is currently internal but is only used inside
this file; change its declaration to private to encapsulate it. Locate the
struct AccountPill: View and mark it private (private struct AccountPill: View)
so it remains file-scoped and cannot be accessed externally; ensure any
references from AccountPickerView or other symbols in this file still compile
after the visibility change.
- Around line 43-73: Add VoiceOver support to the account selection Button by
giving it a descriptive accessibility label and traits: on the Button(action:
action) that wraps the avatar and display name, set an accessibilityLabel that
includes account.displayName and the active state (use isActive to append
"selected" or similar), add an accessibilityHint like "Double tap to select this
account", and apply accessibilityAddTraits(.isSelected) when isActive;
optionally set an accessibilityIdentifier using account.displayName or a stable
account id for UI tests.

In `@Sources/App/Views/Settings/AccountManagementCard.swift`:
- Line 11: The AccountManagementCard view declares an unused property "monitor:
QuotaMonitor" — either remove the unused property or mark intent with a TODO;
update the AccountManagementCard struct by deleting the "monitor: QuotaMonitor"
declaration if it's not needed, or replace it with a comment like "// TODO: add
usage for monitor to trigger quota refreshes" and keep the property only if you
will reference it elsewhere (e.g., in init or body). Ensure any initializers or
callers that passed a QuotaMonitor are updated accordingly if you remove the
property.
- Around line 138-139: The Button action currently calls
provider.switchAccount(to: account.accountId) and ignores its Bool result;
change it to capture the return value and present user feedback on failure: call
let success = provider.switchAccount(to: account.accountId) and if success is
false set a local `@State` flag (e.g., showSwitchError) or error message state and
present an Alert or inline error in AccountManagementCard to inform the user the
account switch failed; ensure you reference provider.switchAccount(to:) and the
Button action and add the `@State` properties and Alert presentation in the view.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c39880ea-a2cf-43ba-ac2e-023d519d345a

📥 Commits

Reviewing files that changed from the base of the PR and between c10b5ba and da738c3.

📒 Files selected for processing (2)
  • Sources/App/Views/AccountPickerView.swift
  • Sources/App/Views/Settings/AccountManagementCard.swift


@Environment(\.appTheme) private var theme
@State private var isExpanded = false
@State private var showAddSheet = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

"Add Account" button sets state but the sheet is never presented.

showAddSheet is toggled to true on Line 161, but there's no .sheet(isPresented: $showAddSheet) modifier attached to the view. Tapping the button will silently do nothing.

🐛 Proposed fix to wire up the sheet

Add the sheet modifier to the view. If the add-account sheet view isn't implemented yet, consider disabling the button or adding a placeholder:

         .buttonStyle(.plain)
     }
 }
+
+// Add to body, after .background(...):
+.sheet(isPresented: $showAddSheet) {
+    // TODO: Replace with actual AddAccountSheet view
+    Text("Add Account Sheet - Coming Soon")
+}

Or if deferred to a later phase, disable the button for now:

         .buttonStyle(.plain)
+        .disabled(true) // TODO: Enable when AddAccountSheet is implemented
     }
 }

Also applies to: 159-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/App/Views/Settings/AccountManagementCard.swift` at line 15, The Add
Account sheet state (showAddSheet) is toggled but never used to present a sheet;
attach a .sheet(isPresented: $showAddSheet) modifier to the parent view (where
the button toggles showAddSheet) to present the add-account view (or a
placeholder) when true, referencing showAddSheet and the add-account view struct
(or a temporary View) so tapping the button actually displays the sheet; if the
sheet view is not ready, disable the Add Account button or present a placeholder
view in the sheet to avoid a no-op tap.

@marcusquinn
Copy link
Copy Markdown
Contributor Author

Hey @hanrw — happy to discuss the Phase 3 integration if useful. A few notes on how we'd envision the wiring:

ClaudeProvider conformance to MultiAccountProvider:

  • The existing ClaudeProvider already has dual probe support (CLI + API). Multi-account would extend this to N probes, one per account.
  • Each account could map to a Claude CLI profile (claude --profile work) or a separate API token env var.
  • The activeProbe selection logic already exists — it just needs to be keyed by account ID instead of probe mode.

Settings persistence:

  • JSONSettingsRepository would implement MultiAccountSettingsRepository, storing accounts under providers.claude.accounts in ~/.claudebar/settings.json.
  • Backward compatible: if accounts is missing or empty, the provider behaves exactly as today (single default account).

UI wiring (2 lines essentially):

  • AccountPickerView goes in MenuContentView.accountCard() — show it when provider is MultiAccountProvider && accounts.count > 1
  • AccountManagementCard goes in SettingsContentView after the provider-specific config cards — same conformance check

We use a similar multi-account pool pattern in our own tooling (rotating across multiple Claude/Codex OAuth accounts for headless dispatch), so the domain model is battle-tested for the account identity, status aggregation, and best-available-account selection patterns.

Let us know if you'd like us to take a crack at the Phase 3 wiring in a follow-up PR, or if you'd prefer to handle it yourselves given the probe internals.

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 22, 2026

Phase 3
@marcusquinn Your design was perfect, and just follow your PR get some prototype below
and we can definitely do that.

image image image

@marcusquinn
Copy link
Copy Markdown
Contributor Author

Great progress on Phase 3! The UI looks clean — the account switcher placement in the sidebar makes sense for discoverability. A few thoughts on the prototype:

  • The per-provider account list looks well-structured. Would be good to show the active account indicator (e.g. a dot or checkmark) inline so users can see at a glance which account is currently in use without opening the switcher.
  • For the rotation logic, consider surfacing the current token usage % per account if the API provides it — that would make the load-balancing behaviour visible and help users understand why the active account switches.

Looking forward to seeing this merged. The domain model foundation from Phase 1 should make Phase 3 straightforward to wire up.

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 22, 2026

Great progress on Phase 3! The UI looks clean — the account switcher placement in the sidebar makes sense for discoverability. A few thoughts on the prototype:

  • The per-provider account list looks well-structured. Would be good to show the active account indicator (e.g. a dot or checkmark) inline so users can see at a glance which account is currently in use without opening the switcher.
  • For the rotation logic, consider surfacing the current token usage % per account if the API provides it — that would make the load-balancing behaviour visible and help users understand why the active account switches.

Looking forward to seeing this merged. The domain model foundation from Phase 1 should make Phase 3 straightforward to wire up.

i've just put all the html resources under docs/features/multi-account if it's help

docs/features/multi-account/                                                                                                                                                                                                                                                                                               
 ├── index.html                    ← Hub page linking all views                                                                                                                                                                                                                                                             
 ├── components/                                                                                                                                                                                                                                                                                                            
 │   ├── theme.css                 ← Shared design tokens, animations, base styles                                                                                                                                                                                                                                          
 │   ├── popover.html              ← Menu bar popover (parallel monitoring)                                                                                                                                                                                                                                                 
 │   ├── settings.html             ← Account management card                                                                                                                                                                                                                                                                
 │   └── add-account.html          ← Add account journey (happy + error paths)

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 22, 2026

@marcusquinn here's some build issues which can't be merged right now.

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 22, 2026

some update from your comments.

  ┌─────────────────┬──────────────────────────────────────────────────────────┬─────────────────────────────────────┐                                                                                                                                                                                                       
  │      Layer      │                          Shows                           │            Doesn't Show             │                                                                                                                                                                                                       
  ├─────────────────┼──────────────────────────────────────────────────────────┼─────────────────────────────────────┤                                                                                                                                                                                                       
  │ Pills           │ Avatar + name + health dot (filter control)              │ No numbers, no active tag           │                                                                                                                                                                                                       
  ├─────────────────┼──────────────────────────────────────────────────────────┼─────────────────────────────────────┤                                                                                                                                                                                                       
  │ Group header    │ Name + "In Use" pulsing tag (active only) + status badge │ No usage % (cards show that)        │                                                                                                                                                                                                       
  ├─────────────────┼──────────────────────────────────────────────────────────┼─────────────────────────────────────┤                                                                                                                                                                                                       
  │ Cards           │ The actual quota numbers (72%, 88%, etc.)                │ Single source of truth for numbers  │                                                                                                                                                                                                       
  ├─────────────────┼──────────────────────────────────────────────────────────┼─────────────────────────────────────┤                                                                                                                                                                                                       
  │ Rotation banner │ One line explaining why active switched                  │ Merged the old two banners into one │
  └─────────────────┴──────────────────────────────────────────────────────────┴─────────────────────────────────────┘ 
image

@marcusquinn
Copy link
Copy Markdown
Contributor Author

Phase 1-3 Review & Contribution Offer

Went through the full codebase to understand the integration surface. The domain model is clean — happy to help accelerate Phase 3 with concrete PRs if useful.

Phase 1 (This PR) — Build Fix

The CI failures are @Mockable macro conformance gaps. The generated MockMultiAccountSettingsRepository is missing stubs for parent ProviderSettingsRepository requirements (setEnabled, isEnabled(forProvider:defaultValue:), customCardURL, setCustomCardURL), and MockMultiAccountProvider is missing AIProvider + Identifiable stubs (id, name, cliCommand, isEnabled, isSyncing, snapshot, lastError, isAvailable(), refresh()).

Likely fix depends on your Mockable version — either re-declare parent requirements in the child protocols so the macro picks them up, or add manual mock conformance extensions. Your call on which pattern fits the project.

The CodeRabbit showAddSheet finding is valid but minor — just needs .sheet(isPresented: $showAddSheet) wired up when the add-account view is ready (or .disabled(true) on the button as a placeholder).

Phase 3 — Concrete PRs We Can Contribute

We've mapped the integration points against the existing code. Here's what we could take on as separate follow-up PRs (each self-contained and independently mergeable):

PR A: JSONSettingsRepository + MultiAccountSettingsRepository conformance

  • Add accounts(forProvider:), addAccount, removeAccount, updateAccount, activeAccountId, setActiveAccountId to JSONSettingsRepository
  • Key pattern: providers.{id}.accounts (array) + providers.{id}.activeAccountId (string)
  • Backward compatible: missing/empty accounts = single-account behaviour (no migration needed)
  • Includes tests against JSONSettingsStore

PR B: QuotaMonitor multi-account awareness

  • refreshProvider() calls refreshAllAccounts() when provider conforms to MultiAccountProvider
  • handleSnapshotUpdate() tracks per-account status changes for alerts (keyed by compound ID {providerId}.{accountId})
  • overallStatus considers aggregateStatus from multi-account providers
  • Includes tests with mock multi-account provider

PR C: UI wiring in MenuContentView + SettingsContentView

  • AccountPickerView in providerSection(provider:) — conditional on provider is MultiAccountProvider && accounts.count > 1
  • AccountManagementCard in SettingsContentView after provider-specific config cards — same conformance check
  • accountCard shows active account info for multi-account providers
  • Minimal diff — mostly if let multi = provider as? MultiAccountProvider guards

PR D (optional): ClaudeProvider conformance to MultiAccountProvider

  • This one touches probe internals so you may prefer to handle it. But the pattern is straightforward: activeProbe selection keyed by account ID instead of probe mode, per-account snapshots stored in a dictionary, switchAccount changes the active probe.
  • Each account maps to a CLI profile (claude --profile work) or API token env var via probeConfig

Happy to start with PR A (persistence) since it's the most self-contained and unblocks the rest. Let us know which ones you'd like us to take on vs. handle yourselves.

@marcusquinn
Copy link
Copy Markdown
Contributor Author

Thanks for the update on the build issues, @hanrw. I'll take a look at the failing checks and get them resolved.

Regarding the layer diagram — that's a helpful visualisation of the architecture. I'll review the structure and make sure the multi-account components align with the existing patterns.

Will push fixes for the build issues and update this PR. Let me know if there are specific areas you'd like me to prioritise.

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 23, 2026

@marcusquinn sounds great and all looks good

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 25, 2026

the build issue may related to: Kolos65/Mockable#128

The @mockable macro cannot generate stubs for inherited protocol
requirements — this is a known limitation (Kolos65/Mockable#128).

MockMultiAccountProvider was missing AIProvider+Identifiable stubs,
and MockMultiAccountSettingsRepository was missing ProviderSettingsRepository
stubs, causing 22 build errors in CI.

Remove @mockable from both child protocols. When tests need mocks,
use the aggregate-protocol pattern recommended by the Mockable maintainer.
@marcusquinn
Copy link
Copy Markdown
Contributor Author

Good find — that's exactly it. Mockable's @Mockable macro can't see inherited protocol requirements during expansion (Kolos65/Mockable#128).

Just pushed a fix: removed @Mockable from both MultiAccountProvider and MultiAccountSettingsRepository. Neither mock is used in any test yet, so this is safe. When Phase 3 tests need mocks, we'll use the aggregate-protocol pattern the Mockable maintainer recommends (flat @Mockable protocol in the test target that re-declares all requirements).

CI should go green now — the only errors were the 22 missing-conformance failures from those two generated mocks.

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 26, 2026

CI still failed

@marcusquinn
Copy link
Copy Markdown
Contributor Author

Fixed — the test target was missing import Foundation in ProviderAccountConfigTests.swift. JSONEncoder/JSONDecoder live in Foundation, and the DomainTests target doesn't transitively import it.

Just pushed the one-line fix (d6a3cb3). CI should go green now.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.37%. Comparing base (cc42fb5) to head (d6a3cb3).
⚠️ Report is 23 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #160      +/-   ##
==========================================
+ Coverage   80.06%   80.37%   +0.31%     
==========================================
  Files         102      106       +4     
  Lines        7741     7879     +138     
==========================================
+ Hits         6198     6333     +135     
- Misses       1543     1546       +3     
Files with missing lines Coverage Δ
...main/Provider/MultiAccountSettingsRepository.swift 100.00% <100.00%> (ø)
Sources/Domain/Provider/ProviderAccount.swift 100.00% <100.00%> (ø)

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hanrw
Copy link
Copy Markdown
Member

hanrw commented Mar 27, 2026

@marcusquinn Thanks for your great work! I've created some new tickets for Phase 3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for multiple accounts per provider

2 participants