Compare commits

..

3 Commits

Author SHA1 Message Date
AndyMik90 1475fe5151 fix(core): prevent duplicate profile creation and clean up existing duplicates
- saveProfile() now checks for existing profile with same email before
  creating a new entry, reusing the existing profile instead
- Add deduplicateExistingProfiles() startup migration that collapses
  duplicate profiles per email, keeping active/default profiles
- Cleans up accountPriorityOrder references to removed duplicates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:30:16 +01:00
AndyMik90 32fc3522e8 fix(core): harden profile deduplication logic and add tests
- Replace console.log with this.debugLog() (invisible in production)
- Merge isActive and needsReauthentication flags when dedup replaces
  a profile, preventing active profile from disappearing in UI
- Add missing availabilityScore sort on early-exit dedup path
- Rename seenEmails → profileKeys to reflect mixed key semantics
- Add 9 unit tests for deduplicateProfilesByEmail()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:51:29 +01:00
AndyMik90 33ed7d33fe fix(ui): deduplicate usage profiles and make dropdowns scrollable
- Deduplicate ProfileUsageSummary entries by email in usage-monitor.ts
  so multiple ClaudeProfileManager profiles sharing the same underlying
  OAuth account (different configDir) collapse to one entry
- Add max-h-[80vh] overflow-y-auto to all UsageIndicator PopoverContent
  instances so the dropdown scrolls with many accounts
- Add max-h-[60vh] overflow-y-auto to GitHubSetupModal "Connect to AI"
  provider list so Continue/Skip buttons stay visible
- Restructure onboarding AccountsStep with pinned header/footer and
  independently scrollable provider list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:25:35 +01:00
27 changed files with 1326 additions and 1303 deletions
+6 -6
View File
@@ -42,12 +42,12 @@
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
| **Windows** | [Auto-Claude-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Auto-Claude-2.8.0-beta.5-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+48 -48
View File
@@ -50,27 +50,27 @@
"typecheck": "tsc --noEmit --incremental"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.77",
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/azure": "^3.0.42",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/groq": "^3.0.29",
"@ai-sdk/mcp": "^1.0.25",
"@ai-sdk/mistral": "^3.0.24",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35",
"@ai-sdk/xai": "^3.0.67",
"@anthropic-ai/sdk": "^0.78.0",
"@ai-sdk/amazon-bedrock": "^4.0.61",
"@ai-sdk/anthropic": "^3.0.45",
"@ai-sdk/azure": "^3.0.31",
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/groq": "^3.0.24",
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/mistral": "^2.0.28",
"@ai-sdk/openai": "^3.0.30",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/xai": "^3.0.57",
"@anthropic-ai/sdk": "^0.71.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@libsql/client": "^0.17.0",
"@lydell/node-pty": "^1.1.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@openrouter/ai-sdk-provider": "^2.3.1",
"@modelcontextprotocol/sdk": "^1.26.0",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
@@ -84,78 +84,78 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.10.0",
"@sentry/electron": "^7.5.0",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-virtual": "^3.13.22",
"@tanstack/react-virtual": "^3.13.13",
"@tavily/core": "^0.7.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"ai": "^6.0.116",
"ai": "^6.0.91",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"dotenv": "^17.2.3",
"electron-log": "^5.4.3",
"electron-updater": "^6.8.3",
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"minimatch": "^10.2.4",
"motion": "^12.36.0",
"electron-updater": "^6.6.2",
"i18next": "^25.7.3",
"lucide-react": "^0.562.0",
"minimatch": "^10.1.1",
"motion": "^12.23.26",
"proper-lockfile": "^4.1.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"tailwind-merge": "^3.5.0",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"web-tree-sitter": "^0.26.7",
"xstate": "^5.28.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"web-tree-sitter": "^0.26.5",
"xstate": "^5.26.0",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "2.4.7",
"@biomejs/biome": "2.3.11",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@electron/rebuild": "^4.0.3",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/dom": "^10.4.1",
"@electron/rebuild": "^4.0.2",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.1.0",
"@types/minimatch": "^6.0.0",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/node": "^25.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"autoprefixer": "^10.4.22",
"cross-env": "^10.1.0",
"electron": "40.0.0",
"electron-builder": "^26.8.1",
"electron-builder": "^26.4.0",
"electron-vite": "^5.0.0",
"husky": "^9.1.7",
"jsdom": "^27.3.0",
"lint-staged": "^16.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vitest": "^4.1.0"
"vitest": "^4.0.16"
},
"overrides": {
"electron-builder-squirrel-windows": "^26.0.12",
"dmg-builder": "^26.0.12",
"@electron/rebuild": "4.0.3"
"@electron/rebuild": "4.0.2"
},
"build": {
"appId": "com.aperant.app",
@@ -67,8 +67,8 @@ vi.mock('../claude-profile/profile-utils', () => ({
getEmailFromConfigDir: vi.fn(),
}));
vi.mock('../terminal/output-parser', () => ({}));
vi.mock('../terminal/session-handler', () => ({}));
vi.mock('./output-parser', () => ({}));
vi.mock('./session-handler', () => ({}));
vi.mock('./pty-manager', () => ({
writeToPty: vi.fn(),
-1
View File
@@ -147,7 +147,6 @@ export const MODEL_PROVIDER_MAP: Record<string, SupportedProvider> = {
'llama-': 'groq',
'grok-': 'xai',
'glm-': 'zai',
'mascarade-': 'mascarade',
} as const;
// ============================================
@@ -156,24 +156,6 @@ function createProviderInstance(config: ProviderConfig) {
});
}
case SupportedProvider.Mascarade: {
// Mascarade LLM orchestration engine — OpenAI-compatible API
// Default: http://localhost:8100/v1 (Tower) or configurable
let mascaradeBaseURL = baseURL ?? 'http://localhost:8100/v1';
if (!mascaradeBaseURL.endsWith('/v1')) {
mascaradeBaseURL = mascaradeBaseURL.replace(/\/+$/, '') + '/v1';
}
return createOpenAICompatible({
name: 'mascarade',
apiKey: apiKey ?? 'mascarade-local',
baseURL: mascaradeBaseURL,
headers: {
...headers,
'Authorization': `Bearer ${apiKey ?? 'mascarade-local'}`,
},
});
}
default: {
const _exhaustive: never = provider;
throw new Error(`Unsupported provider: ${_exhaustive}`);
@@ -21,7 +21,6 @@ export const SupportedProvider = {
OpenRouter: 'openrouter',
ZAI: 'zai',
Ollama: 'ollama',
Mascarade: 'mascarade',
} as const;
export type SupportedProvider = (typeof SupportedProvider)[keyof typeof SupportedProvider];
+104 -8
View File
@@ -105,6 +105,10 @@ export class ClaudeProfileManager {
// This repairs emails that were truncated due to ANSI escape codes in terminal output
this.migrateCorruptedEmails();
// Deduplicate profiles that share the same email
// This cleans up duplicates created by repeated auth logins
this.deduplicateExistingProfiles();
// Populate missing subscription metadata for existing profiles
// This reads subscriptionType and rateLimitTier from Keychain credentials
this.populateSubscriptionMetadata();
@@ -206,6 +210,68 @@ export class ClaudeProfileManager {
}
}
/**
* Deduplicate existing profiles that share the same email.
* Keeps one profile per email, preferring: active > default > most recently created.
* Updates activeProfileId if the active profile was merged away.
*/
private deduplicateExistingProfiles(): void {
const emailMap = new Map<string, ClaudeProfile[]>();
for (const profile of this.data.profiles) {
if (!profile.email) continue;
const key = profile.email.toLowerCase();
const group = emailMap.get(key) || [];
group.push(profile);
emailMap.set(key, group);
}
const duplicateGroups = [...emailMap.values()].filter(g => g.length > 1);
if (duplicateGroups.length === 0) return;
const removedIds = new Set<string>();
for (const group of duplicateGroups) {
// Pick the winner: active profile > default > first in list
const winner =
group.find(p => p.id === this.data.activeProfileId) ||
group.find(p => p.isDefault) ||
group[0];
for (const profile of group) {
if (profile.id === winner.id) continue;
removedIds.add(profile.id);
}
// Merge: ensure winner has best metadata
if (!winner.isDefault) {
const defaultInGroup = group.find(p => p.isDefault);
if (defaultInGroup) winner.isDefault = true;
}
}
if (removedIds.size === 0) return;
const before = this.data.profiles.length;
this.data.profiles = this.data.profiles.filter(p => !removedIds.has(p.id));
// Fix activeProfileId if it was removed
if (this.data.activeProfileId && removedIds.has(this.data.activeProfileId)) {
const defaultProfile = this.data.profiles.find(p => p.isDefault);
this.data.activeProfileId = defaultProfile?.id || this.data.profiles[0]?.id;
}
// Clean up accountPriorityOrder references
if (this.data.accountPriorityOrder) {
this.data.accountPriorityOrder = this.data.accountPriorityOrder.filter(
id => !removedIds.has(id.replace('oauth-', ''))
);
}
this.save();
console.warn(`[ClaudeProfileManager] Deduplicated profiles: ${before}${this.data.profiles.length} (removed ${removedIds.size} duplicates)`);
}
/**
* Check if the profile manager has been initialized
*/
@@ -354,7 +420,11 @@ export class ClaudeProfileManager {
}
/**
* Save or update a profile
* Save or update a profile.
*
* Deduplicates by email: if a profile with the same email already exists
* (different ID but same underlying account), the existing profile is updated
* instead of creating a duplicate entry.
*/
saveProfile(profile: ClaudeProfile): ClaudeProfile {
// Expand ~ in configDir path
@@ -362,16 +432,42 @@ export class ClaudeProfileManager {
profile.configDir = expandHomePath(profile.configDir);
}
const index = this.data.profiles.findIndex(p => p.id === profile.id);
// First check: exact ID match (update existing profile)
const indexById = this.data.profiles.findIndex(p => p.id === profile.id);
if (index >= 0) {
// Update existing
this.data.profiles[index] = profile;
} else {
// Add new
this.data.profiles.push(profile);
if (indexById >= 0) {
// Update existing by ID
this.data.profiles[indexById] = profile;
this.save();
return profile;
}
// Second check: same email already exists (prevent duplicate)
if (profile.email) {
const emailLower = profile.email.toLowerCase();
const indexByEmail = this.data.profiles.findIndex(
p => p.email?.toLowerCase() === emailLower
);
if (indexByEmail >= 0) {
const existing = this.data.profiles[indexByEmail];
debugLog('[ClaudeProfileManager] Reusing existing profile for email:', profile.email,
'(existing ID:', existing.id, ', incoming ID:', profile.id, ')');
// Update the existing profile with fresh data, keeping its ID and default status
this.data.profiles[indexByEmail] = {
...profile,
id: existing.id,
isDefault: existing.isDefault || profile.isDefault,
name: profile.name || existing.name,
};
this.save();
return this.data.profiles[indexByEmail];
}
}
// No match — add new profile
this.data.profiles.push(profile);
this.save();
return profile;
}
@@ -1737,4 +1737,120 @@ describe('usage-monitor', () => {
expect(hasHardcodedText(' ')).toBe(true);
});
});
describe('deduplicateProfilesByEmail (private, tested via instance as any)', () => {
function makeProfile(overrides: Partial<import('../../shared/types/agent').ProfileUsageSummary> & { profileId: string }): import('../../shared/types/agent').ProfileUsageSummary {
return {
profileId: overrides.profileId,
profileName: overrides.profileName ?? 'Test Profile',
profileEmail: overrides.profileEmail,
sessionPercent: overrides.sessionPercent ?? 0,
weeklyPercent: overrides.weeklyPercent ?? 0,
isAuthenticated: overrides.isAuthenticated ?? true,
isRateLimited: overrides.isRateLimited ?? false,
availabilityScore: overrides.availabilityScore ?? 100,
isActive: overrides.isActive ?? false,
needsReauthentication: overrides.needsReauthentication ?? false,
};
}
let monitor: UsageMonitor;
beforeEach(() => {
monitor = UsageMonitor.getInstance();
});
it('should return empty array for empty input', () => {
const result = (monitor as any).deduplicateProfilesByEmail([]);
expect(result).toEqual([]);
});
it('should return all profiles when there are no duplicate emails', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'alice@example.com' }),
makeProfile({ profileId: 'p2', profileEmail: 'bob@example.com' }),
makeProfile({ profileId: 'p3', profileEmail: 'carol@example.com' }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(3);
expect(result.map((p: any) => p.profileId)).toEqual(['p1', 'p2', 'p3']);
});
it('should keep the higher-usage profile when two share the same email', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 20, weeklyPercent: 30 }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 70, weeklyPercent: 50 }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].profileId).toBe('p2');
});
it('should keep the first profile when two have the same max usage', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 50, weeklyPercent: 40 }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 50, weeklyPercent: 40 }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].profileId).toBe('p1');
});
it('should merge isActive flag: active profile with lower usage survives as active', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 10, weeklyPercent: 10, isActive: true }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 80, weeklyPercent: 60, isActive: false }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
// p2 has higher usage, so it replaces p1, but isActive is merged from p1
expect(result[0].profileId).toBe('p2');
expect(result[0].isActive).toBe(true);
});
it('should merge needsReauthentication flag when replacing a profile', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'user@example.com', sessionPercent: 10, needsReauthentication: true }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 80, needsReauthentication: false }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].needsReauthentication).toBe(true);
});
it('should not deduplicate profiles with no email (uses profileId as key)', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: undefined }),
makeProfile({ profileId: 'p2', profileEmail: undefined }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(2);
});
it('should perform case-insensitive email matching', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'User@Example.COM', sessionPercent: 10 }),
makeProfile({ profileId: 'p2', profileEmail: 'user@example.com', sessionPercent: 90 }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
expect(result).toHaveLength(1);
expect(result[0].profileId).toBe('p2');
});
it('should handle mixed: some with emails, some without', () => {
const profiles = [
makeProfile({ profileId: 'p1', profileEmail: 'alice@example.com', sessionPercent: 20 }),
makeProfile({ profileId: 'p2', profileEmail: undefined }),
makeProfile({ profileId: 'p3', profileEmail: 'alice@example.com', sessionPercent: 60 }),
makeProfile({ profileId: 'p4', profileEmail: undefined }),
];
const result = (monitor as any).deduplicateProfilesByEmail(profiles);
// p1+p3 deduplicated (p3 wins), p2 and p4 are kept separately (no email)
expect(result).toHaveLength(3);
const ids = result.map((p: any) => p.profileId);
expect(ids).toContain('p3');
expect(ids).toContain('p2');
expect(ids).toContain('p4');
});
});
});
@@ -442,6 +442,10 @@ export class UsageMonitor extends EventEmitter {
// Include Z.AI provider accounts from providerAccounts
await this.appendZAIAccounts(allProfiles);
// Deduplicate by email (multiple profiles can share the same underlying account)
const deduped = this.deduplicateProfilesByEmail(allProfiles);
deduped.sort((a, b) => b.availabilityScore - a.availabilityScore);
// Return minimal data with auth status - don't return null!
return {
activeProfile: {
@@ -452,7 +456,7 @@ export class UsageMonitor extends EventEmitter {
fetchedAt: new Date(),
needsReauthentication: this.needsReauthProfiles.has(activeProfileId || '')
},
allProfiles,
allProfiles: deduped,
fetchedAt: new Date()
};
}
@@ -752,17 +756,51 @@ export class UsageMonitor extends EventEmitter {
// Include Z.AI provider accounts from providerAccounts
await this.appendZAIAccounts(allProfiles);
// Deduplicate by email (multiple profiles can share the same underlying account)
const deduplicatedProfiles = this.deduplicateProfilesByEmail(allProfiles);
// Sort by availability score (highest first = most available)
allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore);
deduplicatedProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore);
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
activeProfile: this.currentUsage!, // Non-null: _doGetAllProfilesUsage is only called when currentUsage is set
allProfiles,
allProfiles: deduplicatedProfiles,
fetchedAt: new Date()
};
}
/**
* Deduplicate profiles by email. Multiple ClaudeProfileManager profiles can share
* the same email (different configDir pointing to the same underlying OAuth account).
* Keeps one entry per email, preferring the one with higher usage or active status.
*/
private deduplicateProfilesByEmail(profiles: ProfileUsageSummary[]): ProfileUsageSummary[] {
const result: ProfileUsageSummary[] = [];
const profileKeys = new Map<string, number>(); // email-or-profileId -> index in result
for (const profile of profiles) {
const key = profile.profileEmail?.toLowerCase();
if (key && profileKeys.has(key)) {
const existingIdx = profileKeys.get(key)!;
const existing = result[existingIdx];
const existingMax = Math.max(existing.sessionPercent, existing.weeklyPercent);
const currentMax = Math.max(profile.sessionPercent, profile.weeklyPercent);
if (currentMax > existingMax || (currentMax === existingMax && profile.isActive)) {
const mergedIsActive = existing.isActive || profile.isActive;
const mergedNeedsReauth = existing.needsReauthentication || profile.needsReauthentication;
result[existingIdx] = { ...profile, isActive: mergedIsActive, needsReauthentication: mergedNeedsReauth };
}
} else {
profileKeys.set(key ?? profile.profileId, result.length);
result.push(profile);
}
}
if (result.length < profiles.length) {
this.debugLog(`[UsageMonitor] Deduplicated profiles by email: ${profiles.length}${result.length}`);
}
return result;
}
/**
* Fetch usage for an inactive profile using its own credentials
* This allows showing real usage data for non-active profiles
@@ -933,20 +933,6 @@ export function registerSettingsHandlers(
try {
const settings = readSettingsFile() ?? {};
const accounts: ProviderAccount[] = (settings.providerAccounts as ProviderAccount[] | undefined) ?? [];
// Prevent duplicate: same email + provider already registered
if (account.email) {
const duplicate = accounts.find(
(a) => a.provider === account.provider && a.email?.toLowerCase() === account.email!.toLowerCase()
);
if (duplicate) {
return {
success: false,
error: `DUPLICATE_EMAIL:${duplicate.name}`,
};
}
}
const now = Date.now();
const newAccount: ProviderAccount = {
...account,
@@ -91,6 +91,9 @@ export interface TerminalAPI {
submitOAuthCode: (terminalId: string, code: string) => Promise<IPCResult>;
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
onTerminalOnboardingComplete: (
callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void
) => () => void;
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
onTerminalProfileChanged: (callback: (event: TerminalProfileChangedEvent) => void) => () => void;
@@ -385,6 +388,21 @@ export const createTerminalAPI = (): TerminalAPI => ({
};
},
onTerminalOnboardingComplete: (
callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
info: { terminalId: string; profileId?: string; detectedAt: string }
): void => {
callback(info);
};
ipcRenderer.on(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler);
};
},
onTerminalPendingResume: (
callback: (id: string, sessionId?: string) => void
): (() => void) => {
@@ -400,11 +400,11 @@ export function GitHubSetupModal({
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="py-4 space-y-4 max-h-[60vh] overflow-y-auto">
<ProviderAccountsList />
{getProviderAccounts().length > 0 && (
<div className="flex items-center gap-2 rounded-lg bg-success/10 border border-success/30 p-3">
<div className="flex items-center gap-2 rounded-lg bg-success/10 border border-success/30 p-3 sticky bottom-0">
<CheckCircle2 className="h-4 w-4 text-success shrink-0" />
<p className="text-sm text-success">
{t('githubSetup.aiProviderReady')}
@@ -828,7 +828,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -959,7 +959,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -1099,7 +1099,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -1317,7 +1317,7 @@ export function UsageIndicator() {
<PopoverContent
side="bottom"
align="end"
className="text-xs w-72 p-0"
className="text-xs w-72 p-0 max-h-[80vh] overflow-y-auto"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -15,35 +15,39 @@ interface AccountsStepProps {
* Replaces the old AuthChoiceStep + OAuthStep two-step flow with a single
* step that reuses the ProviderAccountsList from settings. Users can add
* accounts from any supported provider (Anthropic, OpenAI, Google, etc.).
*
* Layout: The header and action buttons are pinned (always visible), while
* the provider list scrolls independently. This prevents the "Continue"
* button from being hidden below the fold on smaller screens.
*/
export function AccountsStep({ onNext, onBack, onSkip }: AccountsStepProps) {
const { t } = useTranslation('onboarding');
return (
<div className="flex h-full flex-col items-center px-8 py-6">
<div className="w-full max-w-2xl">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Users className="h-8 w-8 text-primary" />
<div className="w-full max-w-2xl flex flex-col min-h-0 h-full">
{/* Header — pinned at top */}
<div className="text-center mb-6 flex-shrink-0">
<div className="flex justify-center mb-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Users className="h-6 w-6 text-primary" />
</div>
</div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
<h1 className="text-2xl font-bold text-foreground tracking-tight">
{t('accounts.title')}
</h1>
<p className="mt-3 text-muted-foreground text-lg">
<p className="mt-2 text-muted-foreground text-base">
{t('accounts.description')}
</p>
</div>
{/* Provider accounts list - reused from settings */}
<div className="rounded-lg border border-border bg-card/50 p-4">
{/* Provider accounts list — scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto rounded-lg border border-border bg-card/50 p-4">
<ProviderAccountsList />
</div>
{/* Action Buttons */}
<div className="flex justify-between items-center mt-10 pt-6 border-t border-border">
{/* Action Buttons — pinned at bottom, always visible */}
<div className="flex justify-between items-center mt-4 pt-4 border-t border-border flex-shrink-0">
<Button
variant="ghost"
onClick={onBack}
@@ -110,20 +110,6 @@ export function AddAccountDialog({
}
}, [open, editAccount, provider, billingModelOverride]);
// Parse DUPLICATE_EMAIL error from backend and show user-friendly toast
const handleDuplicateEmailError = useCallback((error: string): boolean => {
if (error.startsWith('DUPLICATE_EMAIL:')) {
const existingName = error.slice('DUPLICATE_EMAIL:'.length);
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
description: t('providers.dialog.toast.duplicateEmail', { existingName }),
});
return true;
}
return false;
}, [toast, t]);
const isOAuthOnly = (provider === 'anthropic' || provider === 'openai') && authType === 'oauth';
const isCodexOAuth = provider === 'openai' && authType === 'oauth';
@@ -204,16 +190,10 @@ export function AddAccountDialog({
: t('providers.dialog.toast.added'),
description: name.trim(),
});
} else if (result.error && !handleDuplicateEmailError(result.error)) {
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
description: result.error,
});
}
};
autoSave();
}, [oauthStatus, isCodexOAuth, accountSaved, name, provider, oauthProfileId, isEditing, editAccount, oauthEmail, addProviderAccount, updateProviderAccount, handleDuplicateEmailError, toast, t, refreshUsageData]);
}, [oauthStatus, isCodexOAuth, accountSaved, name, provider, oauthProfileId, isEditing, editAccount, oauthEmail, addProviderAccount, updateProviderAccount, toast, t, refreshUsageData]);
const canSave = () => {
if (!name.trim()) return false;
@@ -284,14 +264,8 @@ export function AddAccountDialog({
description: name.trim(),
});
await refreshUsageData();
onOpenChange(false);
} else if (saveResult.error && !handleDuplicateEmailError(saveResult.error)) {
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
description: saveResult.error,
});
}
onOpenChange(false);
}, 800);
} else {
setOauthStatus('error');
@@ -344,7 +318,7 @@ export function AddAccountDialog({
setOauthStatus('error');
setOauthError(err instanceof Error ? err.message : 'Unexpected error');
}
}, [name, t, toast, isCodexOAuth, isEditing, editAccount, provider, addProviderAccount, updateProviderAccount, handleDuplicateEmailError, onOpenChange, refreshUsageData]);
}, [name, t, toast, isCodexOAuth, isEditing, editAccount, provider, addProviderAccount, updateProviderAccount, onOpenChange, refreshUsageData]);
const handleFallbackTerminal = useCallback(async () => {
if (!name.trim()) {
@@ -368,7 +342,7 @@ export function AddAccountDialog({
createdAt: new Date(),
});
if (!profileResult.success || !profileResult.data) {
toast({ variant: 'destructive', title: t('providers.dialog.toast.createProfileFailed') });
toast({ variant: 'destructive', title: 'Failed to create profile' });
return;
}
profileId = profileResult.data.id;
@@ -378,7 +352,7 @@ export function AddAccountDialog({
// Get terminal config for embedded AuthTerminal
const authResult = await window.electronAPI.authenticateClaudeProfile(profileId);
if (!authResult.success || !authResult.data) {
toast({ variant: 'destructive', title: authResult.error ?? t('providers.dialog.toast.authPrepareFailed') });
toast({ variant: 'destructive', title: authResult.error ?? 'Failed to prepare terminal' });
return;
}
@@ -388,7 +362,7 @@ export function AddAccountDialog({
} catch (err) {
toast({
variant: 'destructive',
title: err instanceof Error ? err.message : t('providers.dialog.toast.unexpectedError'),
title: err instanceof Error ? err.message : 'Unexpected error',
});
}
}, [name, oauthProfileId, t, toast]);
@@ -447,7 +421,7 @@ export function AddAccountDialog({
description: name.trim(),
});
onOpenChange(false);
} else if (result.error && !handleDuplicateEmailError(result.error)) {
} else {
toast({
variant: 'destructive',
title: t('providers.dialog.toast.error'),
@@ -104,6 +104,7 @@ export const terminalMock = {
onTerminalAuthCreated: () => () => {},
onTerminalClaudeBusy: () => () => {},
onTerminalClaudeExit: () => () => {},
onTerminalOnboardingComplete: () => () => {},
onTerminalPendingResume: () => () => {},
onTerminalProfileChanged: () => () => {},
onTerminalOAuthCodeNeeded: () => () => {},
+1
View File
@@ -109,6 +109,7 @@ export const IPC_CHANNELS = {
TERMINAL_OAUTH_CODE_SUBMIT: 'terminal:oauthCodeSubmit', // User submitted OAuth code to send to terminal
TERMINAL_CLAUDE_BUSY: 'terminal:claudeBusy', // Claude Code busy state (for visual indicator)
TERMINAL_CLAUDE_EXIT: 'terminal:claudeExit', // Claude Code exited (returned to shell)
TERMINAL_ONBOARDING_COMPLETE: 'terminal:onboardingComplete', // Claude onboarding complete (ready for input after login)
TERMINAL_PROFILE_CHANGED: 'terminal:profileChanged', // Profile changed, terminals need refresh (main -> renderer)
// Claude profile management (multi-account support)
@@ -71,12 +71,6 @@ export const ALL_AVAILABLE_MODELS: ModelOption[] = [
{ value: 'glm-4.7', label: 'GLM-4.7', provider: 'zai', description: 'Previous flagship', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'glm-4.6v', label: 'GLM-4.6V', provider: 'zai', description: 'Multimodal', capabilities: { thinking: false, tools: true, vision: true, contextWindow: 128000 } },
{ value: 'glm-4.5-flash', label: 'GLM-4.5 Flash', provider: 'zai', description: 'Fast', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
// Mascarade (LLM Orchestration Engine)
{ value: 'mascarade-router', label: 'Mascarade Router', provider: 'mascarade', description: 'Multi-LLM routing (best/cheapest/fastest)', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-writer', label: 'Mascarade Writer', provider: 'mascarade', description: 'Content generation agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-coder', label: 'Mascarade Coder', provider: 'mascarade', description: 'Code review & generation', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-analyst', label: 'Mascarade Analyst', provider: 'mascarade', description: 'Data analysis agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-planner', label: 'Mascarade Planner', provider: 'mascarade', description: 'Project decomposition', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
];
// Maps model shorthand to actual Claude model IDs
@@ -328,12 +322,6 @@ export const PROVIDER_PRESET_DEFINITIONS: Partial<Record<BuiltinProvider, Record
balanced: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
mascarade: {
auto: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
complex: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
balanced: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
};
/**
@@ -73,11 +73,4 @@ export const PROVIDER_REGISTRY: ProviderInfo[] = [
authMethods: ['api-key'], envVars: [],
configFields: ['baseUrl'],
},
{
id: 'mascarade', name: 'Mascarade', description: 'LLM Orchestration Engine — multi-provider routing with 19+ agents',
category: 'local',
authMethods: ['api-key'], envVars: ['MASCARADE_API_KEY'],
configFields: ['baseUrl'],
website: 'https://mascarade.saillant.cc',
},
];
@@ -674,9 +674,11 @@
"authFailed": "Authentication failed",
"connecting": "Connecting...",
"authenticate": "Authenticate: {{profileName}}",
"completeSetup": "Complete setup: {{profileName}}",
"authenticatedAs": "Authenticated as {{email}}",
"authenticated": "Authenticated!",
"authError": "Authentication Error",
"onboardingMessage": "Token received! Complete the Claude Code setup below - this window will close automatically when done.",
"successMessage": "Authentication successful! Closing..."
},
"profileCreated": {
@@ -810,11 +810,7 @@
"toast": {
"added": "Account added",
"updated": "Account updated",
"error": "Failed to save account",
"duplicateEmail": "This email is already registered as \"{{existingName}}\"",
"createProfileFailed": "Failed to create profile",
"authPrepareFailed": "Failed to prepare terminal",
"unexpectedError": "Unexpected error"
"error": "Failed to save account"
}
},
"toast": {
@@ -674,9 +674,11 @@
"authFailed": "Échec de l'authentification",
"connecting": "Connexion...",
"authenticate": "Authentifier : {{profileName}}",
"completeSetup": "Terminer la configuration : {{profileName}}",
"authenticatedAs": "Authentifié en tant que {{email}}",
"authenticated": "Authentifié !",
"authError": "Erreur d'authentification",
"onboardingMessage": "Jeton reçu ! Terminez la configuration de Claude Code ci-dessous - cette fenêtre se fermera automatiquement une fois terminé.",
"successMessage": "Authentification réussie ! Fermeture..."
},
"profileCreated": {
@@ -810,11 +810,7 @@
"toast": {
"added": "Compte ajouté",
"updated": "Compte mis à jour",
"error": "Échec de l'enregistrement du compte",
"duplicateEmail": "Cet e-mail est déjà enregistré sous \"{{existingName}}\"",
"createProfileFailed": "Échec de la création du profil",
"authPrepareFailed": "Échec de la préparation du terminal",
"unexpectedError": "Erreur inattendue"
"error": "Échec de l'enregistrement du compte"
}
},
"toast": {
+6
View File
@@ -298,6 +298,12 @@ export interface ElectronAPI {
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
/** Listen for Claude exit (user closed Claude within terminal, returned to shell) */
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
/** Listen for onboarding complete (Claude shows ready state after login/onboarding) */
onTerminalOnboardingComplete: (callback: (info: {
terminalId: string;
profileId?: string;
detectedAt: string;
}) => void) => () => void;
/** Listen for pending Claude resume notifications (for deferred resume on tab activation) */
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
/** Listen for profile change events - terminals need to be recreated with new profile env vars */
@@ -7,7 +7,7 @@ export type CredentialSource = 'oauth' | 'api-key' | 'env' | 'keychain';
export type BuiltinProvider =
| 'anthropic' | 'openai' | 'google' | 'amazon-bedrock' | 'azure'
| 'mistral' | 'groq' | 'xai' | 'openrouter' | 'zai'
| 'ollama' | 'openai-compatible' | 'mascarade';
| 'ollama' | 'openai-compatible';
export type BillingModel = 'subscription' | 'pay-per-use';
+946 -1120
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -45,6 +45,6 @@
"lucide-react": "^0.562.0"
},
"overrides": {
"@electron/rebuild": "4.0.3"
"@electron/rebuild": "4.0.2"
}
}