Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions apps/desktop/src/app/chat/sidebar/profile-switcher.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(
<I18nProvider configClient={null}>
<MemoryRouter>
<ProfileRail />
</MemoryRouter>
</I18nProvider>
)
}

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')
})
})
})
74 changes: 60 additions & 14 deletions apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -196,19 +201,26 @@ export function ProfileRail() {

return (
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
{/* 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.
<ProfilePill
active={isAll || onDefault}
glyph={isAll ? 'layers' : 'home'}
label={onDefault ? p.showAllProfiles : p.switchToProfile(defaultProfile.name)}
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
/>
<>
<ProfilePill
active={isAll}
glyph="layers"
label={p.allProfiles}
onSelect={() => setShowAllProfiles(true)}
/>
<ProfileLabelPill
active={onDefault}
glyph="home"
label={defaultProfile.name}
onSelect={() => selectProfile(defaultProfile.name)}
tooltip={p.switchToProfile(defaultProfile.name)}
/>
</>
) : (
<ProfilePill active={isAll} glyph="layers" label={p.allProfiles} onSelect={() => setShowAllProfiles(true)} />
))}
Expand Down Expand Up @@ -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 (
<Tip label={tooltip}>
<Button
aria-label={tooltip}
aria-pressed={active}
className={cn(
'h-6 max-w-20 gap-1 overflow-hidden bg-transparent px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
active && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={onSelect}
size="xs"
type="button"
variant="ghost"
>
<Codicon name={glyph} size="0.75rem" />
<span className="min-w-0 truncate">{label}</span>
</Button>
</Tip>
)
}

interface ProfileSquareProps {
active: boolean
color: null | string
Expand Down Expand Up @@ -481,7 +523,11 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
<Codicon name="edit" size="0.875rem" />
<span>{p.rename}</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>{t.common.delete}</span>
</ContextMenuItem>
Expand Down