diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index c7b2f5179..3960fdd8e 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -149,6 +149,22 @@ const SettingsHome = () => { ), onClick: () => navigateToSettings('mascot'), }, + { + id: 'persona', + title: t('settings.persona.menuTitle'), + description: t('settings.persona.menuDesc'), + icon: ( + + + + ), + onClick: () => navigateToSettings('persona'), + }, ], }, // Features tile (Screen Awareness / Messaging Channels / Notifications / diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index aef243722..4fcc9dbc2 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -156,6 +156,14 @@ describe('SettingsHome', () => { expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications'); }); + it('navigates to persona settings when Persona is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Persona').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('persona'); + }); + it('navigates to /notifications inbox when Alerts is clicked', async () => { const user = userEvent.setup(); renderSettingsHome(); diff --git a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx index d6fce1fc2..61cb07f50 100644 --- a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx +++ b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx @@ -27,4 +27,9 @@ describe('useSettingsNavigation breadcrumbs', () => { renderWithProviders(, { initialEntries: ['/settings/developer-options'] }); expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); }); + + test('persona returns Settings (top-level)', () => { + renderWithProviders(, { initialEntries: ['/settings/persona'] }); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); + }); }); diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 8f0d3e656..e38049b53 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -32,6 +32,7 @@ export type SettingsRoute = | 'notifications' | 'notification-routing' | 'mascot' + | 'persona' | 'appearance' | 'intelligence' | 'webhooks-triggers' @@ -115,6 +116,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/notifications')) return 'notifications'; if (path.includes('/settings/devices')) return 'devices'; if (path.includes('/settings/mascot')) return 'mascot'; + if (path.includes('/settings/persona')) return 'persona'; if (path.includes('/settings/appearance')) return 'appearance'; if (path.includes('/settings/mcp-server')) return 'mcp-server'; return 'home'; @@ -241,6 +243,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'mascot': return [settingsCrumb]; + // Persona panel sits at the top level of Settings. + case 'persona': + return [settingsCrumb]; + // Appearance (theme) panel sits at the top level of Settings. case 'appearance': return [settingsCrumb]; diff --git a/app/src/components/settings/panels/PersonaPanel.test.tsx b/app/src/components/settings/panels/PersonaPanel.test.tsx new file mode 100644 index 000000000..2a278eba3 --- /dev/null +++ b/app/src/components/settings/panels/PersonaPanel.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import PersonaPanel from './PersonaPanel'; + +const { + mockNavigateBack, + mockNavigateToSettings, + readPersonaFileMock, + writePersonaFileMock, + resetPersonaFileMock, +} = vi.hoisted(() => ({ + mockNavigateBack: vi.fn(), + mockNavigateToSettings: vi.fn(), + readPersonaFileMock: vi.fn(), + writePersonaFileMock: vi.fn(), + resetPersonaFileMock: vi.fn(), +})); + +vi.mock('../../../services/api/personaFilesApi', () => ({ + PERSONA_FILE_SOUL: 'SOUL.md', + readPersonaFile: (...args: unknown[]) => readPersonaFileMock(...args), + writePersonaFile: (...args: unknown[]) => writePersonaFileMock(...args), + resetPersonaFile: (...args: unknown[]) => resetPersonaFileMock(...args), +})); + +vi.mock('../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: mockNavigateBack, + navigateToSettings: mockNavigateToSettings, + breadcrumbs: [{ label: 'Settings' }], + }), +})); + +const soulFile = (overrides: Record = {}) => ({ + filename: 'SOUL.md', + contents: 'You are helpful.', + is_default: true, + ...overrides, +}); + +describe('PersonaPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + readPersonaFileMock.mockResolvedValue(soulFile()); + writePersonaFileMock.mockImplementation((_name: string, contents: string) => + Promise.resolve(soulFile({ contents, is_default: false })) + ); + resetPersonaFileMock.mockResolvedValue( + soulFile({ contents: 'default soul', is_default: true }) + ); + }); + + it('loads SOUL.md contents into the editor on mount', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('persona-soul-editor')).toHaveValue('You are helpful.'); + }); + expect(readPersonaFileMock).toHaveBeenCalledWith('SOUL.md'); + }); + + it('persists the display name to the store on save', async () => { + const { store } = renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); + + fireEvent.change(screen.getByTestId('persona-display-name-input'), { + target: { value: 'Nova' }, + }); + fireEvent.change(screen.getByTestId('persona-description-input'), { + target: { value: 'Calm and concise.' }, + }); + fireEvent.click(screen.getByTestId('persona-identity-save')); + + expect(store.getState().persona.displayName).toBe('Nova'); + expect(store.getState().persona.description).toBe('Calm and concise.'); + }); + + it('keeps the identity save button disabled until a field changes', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); + expect(screen.getByTestId('persona-identity-save')).toBeDisabled(); + }); + + it('writes edited SOUL.md contents over RPC', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); + + fireEvent.change(screen.getByTestId('persona-soul-editor'), { + target: { value: 'You are calm and concise.' }, + }); + fireEvent.click(screen.getByTestId('persona-soul-save')); + + await waitFor(() => { + expect(writePersonaFileMock).toHaveBeenCalledWith('SOUL.md', 'You are calm and concise.'); + }); + }); + + it('surfaces a save error when the write RPC fails', async () => { + writePersonaFileMock.mockRejectedValue(new Error('disk full')); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); + + fireEvent.change(screen.getByTestId('persona-soul-editor'), { target: { value: 'edited' } }); + fireEvent.click(screen.getByTestId('persona-soul-save')); + + await waitFor(() => { + expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('disk full'); + }); + }); + + it('surfaces a reset error when the reset RPC fails', async () => { + readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false })); + resetPersonaFileMock.mockRejectedValue(new Error('reset boom')); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom')); + + fireEvent.click(screen.getByTestId('persona-soul-reset')); + + await waitFor(() => { + expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('reset boom'); + }); + }); + + it('resets SOUL.md to the bundled default', async () => { + // Start from a non-default file so the Reset button is enabled. + readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false })); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom'); + }); + + fireEvent.click(screen.getByTestId('persona-soul-reset')); + + await waitFor(() => { + expect(resetPersonaFileMock).toHaveBeenCalledWith('SOUL.md'); + expect(screen.getByTestId('persona-soul-editor')).toHaveValue('default soul'); + }); + }); + + it('disables Reset while the file is already the bundled default', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); + expect(screen.getByTestId('persona-soul-reset')).toBeDisabled(); + expect(screen.getByTestId('persona-soul-default-badge')).toBeInTheDocument(); + }); + + it('surfaces a load error', async () => { + readPersonaFileMock.mockRejectedValue(new Error('boom')); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('persona-soul-error')).toHaveTextContent('boom'); + }); + }); + + it('navigates to mascot settings for avatar & voice', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('persona-open-mascot')); + expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot'); + }); +}); diff --git a/app/src/components/settings/panels/PersonaPanel.tsx b/app/src/components/settings/panels/PersonaPanel.tsx new file mode 100644 index 000000000..096b8dd93 --- /dev/null +++ b/app/src/components/settings/panels/PersonaPanel.tsx @@ -0,0 +1,281 @@ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + PERSONA_FILE_SOUL, + readPersonaFile, + resetPersonaFile, + writePersonaFile, +} from '../../../services/api/personaFilesApi'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { + MAX_PERSONA_DESCRIPTION_LEN, + MAX_PERSONA_DISPLAY_NAME_LEN, + selectPersonaDescription, + selectPersonaDisplayName, + setPersonaDescription, + setPersonaDisplayName, +} from '../../../store/personaSlice'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const log = debug('persona:panel'); + +const PersonaPanel = () => { + const { t } = useT(); + const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); + const dispatch = useAppDispatch(); + + const storedDisplayName = useAppSelector(selectPersonaDisplayName); + const storedDescription = useAppSelector(selectPersonaDescription); + + const [nameDraft, setNameDraft] = useState(storedDisplayName); + const [descriptionDraft, setDescriptionDraft] = useState(storedDescription); + + // Re-sync drafts when the store is reset externally (e.g. resetUserScopedState + // during an identity flip) so Save can't write stale values into a clean store. + useEffect(() => { + setNameDraft(storedDisplayName); + }, [storedDisplayName]); + useEffect(() => { + setDescriptionDraft(storedDescription); + }, [storedDescription]); + + // SOUL.md editor state. The file is loaded over RPC on mount; `isDefault` + // tracks whether the current on-disk copy is the bundled prompt so the UI can + // disable Reset when there is nothing to restore. + const [soulDraft, setSoulDraft] = useState(''); + const [soulSaved, setSoulSaved] = useState(''); + const [soulIsDefault, setSoulIsDefault] = useState(true); + const [soulLoading, setSoulLoading] = useState(true); + const [soulError, setSoulError] = useState(null); + const [soulBusy, setSoulBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + log('[ui-flow] soul.load:start file=%s', PERSONA_FILE_SOUL); + readPersonaFile(PERSONA_FILE_SOUL) + .then(file => { + if (cancelled) return; + setSoulDraft(file.contents); + setSoulSaved(file.contents); + setSoulIsDefault(file.is_default); + setSoulError(null); + log('[ui-flow] soul.load:ok is_default=%s', file.is_default); + }) + .catch((err: unknown) => { + if (cancelled) return; + log('[ui-flow] soul.load:error %s', err instanceof Error ? err.message : err); + setSoulError(err instanceof Error ? err.message : 'Could not load SOUL.md'); + }) + .finally(() => { + if (!cancelled) setSoulLoading(false); + }); + return () => { + cancelled = true; + }; + // Load once on mount — `t` is intentionally excluded so a locale change + // does not re-fetch and overwrite unsaved edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const nameDirty = nameDraft.trim() !== storedDisplayName; + const descriptionDirty = descriptionDraft.trim() !== storedDescription; + const identityDirty = nameDirty || descriptionDirty; + + const onSaveIdentity = () => { + if (nameDirty) dispatch(setPersonaDisplayName(nameDraft)); + if (descriptionDirty) dispatch(setPersonaDescription(descriptionDraft)); + }; + + const soulDirty = soulDraft !== soulSaved; + + const onSaveSoul = async () => { + setSoulBusy(true); + setSoulError(null); + log('[ui-flow] soul.save:start bytes=%d', soulDraft.length); + try { + const file = await writePersonaFile(PERSONA_FILE_SOUL, soulDraft); + setSoulDraft(file.contents); + setSoulSaved(file.contents); + setSoulIsDefault(file.is_default); + log('[ui-flow] soul.save:ok'); + } catch (err) { + log('[ui-flow] soul.save:error %s', err instanceof Error ? err.message : err); + setSoulError(err instanceof Error ? err.message : t('settings.persona.soul.saveError')); + } finally { + setSoulBusy(false); + } + }; + + const onResetSoul = async () => { + setSoulBusy(true); + setSoulError(null); + log('[ui-flow] soul.reset:start'); + try { + const file = await resetPersonaFile(PERSONA_FILE_SOUL); + setSoulDraft(file.contents); + setSoulSaved(file.contents); + setSoulIsDefault(file.is_default); + log('[ui-flow] soul.reset:ok'); + } catch (err) { + log('[ui-flow] soul.reset:error %s', err instanceof Error ? err.message : err); + setSoulError(err instanceof Error ? err.message : t('settings.persona.soul.resetError')); + } finally { + setSoulBusy(false); + } + }; + + return ( +
+ + +
+ {/* ── Identity ─────────────────────────────────────────────── */} +
+

+ {t('settings.persona.identityHeading')} +

+
+ +