Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1475fe5151 | |||
| 32fc3522e8 | |||
| 33ed7d33fe |
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user