diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.test.tsx new file mode 100644 index 000000000000..d106020823af --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { $activeGatewayProfile, $profileColors, $profileOrder, $profiles, setShowAllProfiles } from '@/store/profile' +import type { ProfileInfo } from '@/types/hermes' + +import { ProfileRail } from './profile-switcher' + +const getProfiles = vi.hoisted(() => vi.fn<() => Promise<{ profiles: ProfileInfo[] }>>(async () => ({ profiles: [] }))) + +vi.mock('@/hermes', () => ({ + createProfile: vi.fn(async () => undefined), + deleteProfile: vi.fn(async () => undefined), + getProfiles, + renameProfile: vi.fn(async () => undefined), + updateProfileSoul: vi.fn(async () => undefined), + setApiRequestProfile: vi.fn() +})) + +vi.mock('@/store/gateway', () => ({ + $gateway: { get: () => null, subscribe: vi.fn(() => () => undefined) }, + ensureGatewayForProfile: vi.fn(async () => undefined) +})) + +vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } })) + +function profile(name: string, overrides: Partial = {}): ProfileInfo { + return { + has_env: false, + is_default: false, + model: null, + name, + path: `/profiles/${name}`, + provider: null, + skill_count: 0, + ...overrides + } +} + +function profilesFixture(): ProfileInfo[] { + return [profile('default', { is_default: true }), profile('work'), profile('personal')] +} + +function renderRail() { + return render( + + + + + + ) +} + +beforeEach(() => { + const profiles = profilesFixture() + + getProfiles.mockResolvedValue({ profiles }) + $profiles.set(profiles) + $activeGatewayProfile.set('default') + $profileOrder.set([]) + $profileColors.set({}) + setShowAllProfiles(false) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() + $profiles.set([]) + $profileOrder.set([]) + $profileColors.set({}) + setShowAllProfiles(false) + $activeGatewayProfile.set('default') +}) + +describe('ProfileRail', () => { + it('shows the default profile as a visible option separate from all profiles', () => { + renderRail() + + const allProfiles = screen.getByRole('button', { name: 'All profiles' }) + const defaultProfile = screen.getByRole('button', { name: 'Switch to default' }) + + expect(allProfiles).not.toBe(defaultProfile) + expect(allProfiles.getAttribute('aria-pressed')).toBe('false') + expect(defaultProfile.getAttribute('aria-pressed')).toBe('true') + expect(defaultProfile.textContent).toContain('default') + }) + + it('keeps all profiles and concrete profile selection mutually exclusive', async () => { + renderRail() + + fireEvent.click(screen.getByRole('button', { name: 'All profiles' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'All profiles' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByRole('button', { name: 'Switch to default' }).getAttribute('aria-pressed')).toBe('false') + }) + + fireEvent.click(screen.getByRole('button', { name: 'Switch to default' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'All profiles' }).getAttribute('aria-pressed')).toBe('false') + expect(screen.getByRole('button', { name: 'Switch to default' }).getAttribute('aria-pressed')).toBe('true') + }) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 85b9dfaade83..427c8b7c587a 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -129,9 +129,14 @@ export function ProfileRail() { const isAll = scope === ALL_PROFILES const activeKey = normalizeProfileKey(gatewayProfile) const defaultProfile = profiles.find(profile => profile.is_default) - const onDefault = !isAll && activeKey === 'default' + const defaultKey = defaultProfile ? normalizeProfileKey(defaultProfile.name) : 'default' + const onDefault = !isAll && activeKey === defaultKey + + const named = sortByProfileOrder( + profiles.filter(profile => !profile.is_default), + order + ) - const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order) const multiProfile = profiles.length > 1 // distance constraint: a small drag reorders, a tap still selects the profile. @@ -196,19 +201,26 @@ export function ProfileRail() { return (
- {/* One button toggles default ↔ all: home face when scoped to a profile, - layers face when showing everything. Pinned left like Manage is right. - Hidden until a second profile exists. */} + {/* Multi-profile users need two distinct concepts here: "All profiles" is + an aggregate browsing mode; the default/root profile is a real profile + identity and must stay visible by name. */} {multiProfile && (defaultProfile ? ( - // On default → toggle to all. Anywhere else (all view or a named - // profile) → return to default. So leaving a profile never lands on all. - (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))} - /> + <> + setShowAllProfiles(true)} + /> + selectProfile(defaultProfile.name)} + tooltip={p.switchToProfile(defaultProfile.name)} + /> + ) : ( setShowAllProfiles(true)} /> ))} @@ -333,6 +345,36 @@ function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) { ) } +interface ProfileLabelPillProps { + active: boolean + glyph: string + label: string + tooltip: string + onSelect: () => void +} + +function ProfileLabelPill({ active, glyph, label, onSelect, tooltip }: ProfileLabelPillProps) { + return ( + + + + ) +} + interface ProfileSquareProps { active: boolean color: null | string @@ -481,7 +523,11 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on {p.rename} - + {t.common.delete}