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
92 changes: 78 additions & 14 deletions app/src/components/settings/panels/PersonaPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ const soulFile = (overrides: Record<string, unknown> = {}) => ({
...overrides,
});

/** Wait for the SOUL section to finish loading (the mode toggle is always shown). */
const awaitLoaded = () =>
waitFor(() => expect(screen.getByTestId('persona-soul-mode-guided')).toBeInTheDocument());

/** Switch to the Advanced (raw markdown) editor. */
const openAdvanced = () => fireEvent.click(screen.getByTestId('persona-soul-mode-advanced'));

describe('PersonaPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -52,17 +59,65 @@ describe('PersonaPanel', () => {
);
});

it('loads SOUL.md contents into the editor on mount', async () => {
it('defaults to the guided builder and hides raw markdown', async () => {
renderWithProviders(<PersonaPanel />);
await awaitLoaded();
expect(screen.getByTestId('persona-guided-personality')).toBeInTheDocument();
expect(screen.queryByTestId('persona-soul-editor')).not.toBeInTheDocument();
expect(readPersonaFileMock).toHaveBeenCalledWith('SOUL.md');
});

it('reveals the raw SOUL.md editor in Advanced mode', async () => {
renderWithProviders(<PersonaPanel />);
await awaitLoaded();
openAdvanced();
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('You are helpful.');
});

it('splices a guided field edit into SOUL.md and saves it over RPC', async () => {
readPersonaFileMock.mockResolvedValue(
soulFile({ contents: '## Personality\n\nOld.\n', is_default: false })
);
renderWithProviders(<PersonaPanel />);
await awaitLoaded();

await waitFor(() =>
expect(screen.getByTestId('persona-guided-personality')).toHaveValue('Old.')
);
fireEvent.change(screen.getByTestId('persona-guided-personality'), {
target: { value: 'Warm and direct.' },
});
fireEvent.click(screen.getByTestId('persona-soul-save'));

await waitFor(() => {
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('You are helpful.');
expect(writePersonaFileMock).toHaveBeenCalledWith(
'SOUL.md',
'## Personality\n\nWarm and direct.\n'
);
});
});

it('applies a role template and saves the seeded persona over RPC', async () => {
readPersonaFileMock.mockResolvedValue(
soulFile({ contents: '## Personality\n\nOld.\n', is_default: false })
);
renderWithProviders(<PersonaPanel />);
await awaitLoaded();

fireEvent.click(screen.getByTestId('persona-template-doctor'));
fireEvent.click(screen.getByTestId('persona-soul-save'));

await waitFor(() => {
const lastCall = writePersonaFileMock.mock.calls.at(-1);
expect(lastCall?.[0]).toBe('SOUL.md');
expect(lastCall?.[1]).toContain('Careful and precise');
expect(lastCall?.[1]).toContain('## Voice');
});
expect(readPersonaFileMock).toHaveBeenCalledWith('SOUL.md');
});

it('persists the display name to the store on save', async () => {
const { store } = renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
await awaitLoaded();

fireEvent.change(screen.getByTestId('persona-display-name-input'), {
target: { value: 'Nova' },
Expand All @@ -78,13 +133,14 @@ describe('PersonaPanel', () => {

it('keeps the identity save button disabled until a field changes', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
await awaitLoaded();
expect(screen.getByTestId('persona-identity-save')).toBeDisabled();
});

it('writes edited SOUL.md contents over RPC', async () => {
it('writes edited SOUL.md contents over RPC from the raw editor', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
await awaitLoaded();
openAdvanced();

fireEvent.change(screen.getByTestId('persona-soul-editor'), {
target: { value: 'You are calm and concise.' },
Expand All @@ -99,7 +155,8 @@ describe('PersonaPanel', () => {
it('surfaces a save error when the write RPC fails', async () => {
writePersonaFileMock.mockRejectedValue(new Error('disk full'));
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
await awaitLoaded();
openAdvanced();

fireEvent.change(screen.getByTestId('persona-soul-editor'), { target: { value: 'edited' } });
fireEvent.click(screen.getByTestId('persona-soul-save'));
Expand All @@ -113,7 +170,7 @@ describe('PersonaPanel', () => {
readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false }));
resetPersonaFileMock.mockRejectedValue(new Error('reset boom'));
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom'));
await awaitLoaded();

fireEvent.click(screen.getByTestId('persona-soul-reset'));

Expand All @@ -126,9 +183,9 @@ describe('PersonaPanel', () => {
// Start from a non-default file so the Reset button is enabled.
readPersonaFileMock.mockResolvedValue(soulFile({ contents: 'custom', is_default: false }));
renderWithProviders(<PersonaPanel />);
await waitFor(() => {
expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom');
});
await awaitLoaded();
openAdvanced();
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toHaveValue('custom'));

fireEvent.click(screen.getByTestId('persona-soul-reset'));

Expand All @@ -140,7 +197,7 @@ describe('PersonaPanel', () => {

it('disables Reset while the file is already the bundled default', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
await awaitLoaded();
expect(screen.getByTestId('persona-soul-reset')).toBeDisabled();
expect(screen.getByTestId('persona-soul-default-badge')).toBeInTheDocument();
});
Expand All @@ -155,8 +212,15 @@ describe('PersonaPanel', () => {

it('navigates to the Face tab for avatar & voice', async () => {
renderWithProviders(<PersonaPanel />);
await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument());
await awaitLoaded();
fireEvent.click(screen.getByTestId('persona-open-mascot'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('personality#face');
});

it('links guided users to Agent access for permissions', async () => {
renderWithProviders(<PersonaPanel />);
await awaitLoaded();
fireEvent.click(screen.getByTestId('persona-guided-agent-access'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('agent-access');
});
});
53 changes: 43 additions & 10 deletions app/src/components/settings/panels/PersonaPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import Button from '../../ui/Button';
import { SettingsRow, SettingsSection, SettingsTextArea, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import SettingsPanel from '../layout/SettingsPanel';
import PersonaGuidedFields from './persona/PersonaGuidedFields';

type SoulMode = 'guided' | 'advanced';

const log = debug('persona:panel');

Expand Down Expand Up @@ -59,6 +62,9 @@ const PersonaPanel = ({ embedded = false }: PersonaPanelProps) => {
const [soulLoading, setSoulLoading] = useState(true);
const [soulError, setSoulError] = useState<string | null>(null);
const [soulBusy, setSoulBusy] = useState(false);
// Guided (structured fields) is the default so users never touch raw markdown;
// Advanced exposes the full SOUL.md text editor for power users.
const [soulMode, setSoulMode] = useState<SoulMode>('guided');

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -195,17 +201,44 @@ const PersonaPanel = ({ embedded = false }: PersonaPanelProps) => {
</div>
) : (
<>
<div className="px-4 py-3">
<SettingsTextArea
aria-label={t('settings.persona.soul.editorLabel')}
data-testid="persona-soul-editor"
value={soulDraft}
rows={12}
spellCheck={false}
className="font-mono text-xs leading-relaxed"
onChange={e => setSoulDraft(e.target.value)}
/>
<div
role="group"
aria-label={t('settings.persona.builder.modeLabel')}
className="flex items-center gap-1 px-4 pt-3">
<Button
type="button"
aria-pressed={soulMode === 'guided'}
data-testid="persona-soul-mode-guided"
variant={soulMode === 'guided' ? 'primary' : 'secondary'}
size="xs"
onClick={() => setSoulMode('guided')}>
{t('settings.persona.builder.modeGuided')}
</Button>
<Button
type="button"
aria-pressed={soulMode === 'advanced'}
data-testid="persona-soul-mode-advanced"
variant={soulMode === 'advanced' ? 'primary' : 'secondary'}
size="xs"
onClick={() => setSoulMode('advanced')}>
{t('settings.persona.builder.modeAdvanced')}
</Button>
</div>
{soulMode === 'guided' ? (
<PersonaGuidedFields value={soulDraft} onChange={setSoulDraft} disabled={soulBusy} />
) : (
<div className="px-4 py-3">
<SettingsTextArea
aria-label={t('settings.persona.soul.editorLabel')}
data-testid="persona-soul-editor"
value={soulDraft}
rows={12}
spellCheck={false}
className="font-mono text-xs leading-relaxed"
onChange={e => setSoulDraft(e.target.value)}
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<Button
type="button"
Expand Down
101 changes: 101 additions & 0 deletions app/src/components/settings/panels/persona/PersonaGuidedFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useT } from '../../../../lib/i18n/I18nContext';
import { SettingsRow, SettingsTextArea } from '../../controls';
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation';
import { applyPersonaField, parsePersonaFields, type PersonaFieldKey } from './personaSections';
import PersonaTemplatePicker from './PersonaTemplatePicker';

interface PersonaGuidedFieldsProps {
/** The raw SOUL.md text — the single source of truth this view edits. */
value: string;
/** Emit the updated SOUL.md text after a managed section is spliced. */
onChange: (nextSoul: string) => void;
disabled?: boolean;
}

interface FieldDef {
key: PersonaFieldKey;
labelKey: string;
placeholderKey: string;
testId: string;
}

const FIELDS: readonly FieldDef[] = [
{
key: 'personality',
labelKey: 'settings.persona.builder.personalityLabel',
placeholderKey: 'settings.persona.builder.personalityPlaceholder',
testId: 'persona-guided-personality',
},
{
key: 'voice',
labelKey: 'settings.persona.builder.voiceLabel',
placeholderKey: 'settings.persona.builder.voicePlaceholder',
testId: 'persona-guided-voice',
},
{
key: 'about',
labelKey: 'settings.persona.builder.aboutLabel',
placeholderKey: 'settings.persona.builder.aboutPlaceholder',
testId: 'persona-guided-about',
},
] as const;

/**
* Structured persona editor (issue #4253, PR1). Presents a few friendly fields
* that map to named `SOUL.md` sections so non-technical users never touch raw
* markdown. The raw text stays the source of truth: each edit is spliced back
* into `value` via {@link applyPersonaField} and emitted through `onChange`.
*/
const PersonaGuidedFields = ({ value, onChange, disabled = false }: PersonaGuidedFieldsProps) => {
const { t } = useT();
const { navigateToSettings } = useSettingsNavigation();
const fields = parsePersonaFields(value);

return (
<div className="px-4 py-3 space-y-4">
<p className="text-xs text-content-muted leading-relaxed">
{t('settings.persona.builder.intro')}
</p>

<PersonaTemplatePicker value={value} onChange={onChange} disabled={disabled} />

{FIELDS.map(field => (
<SettingsRow
key={field.key}
htmlFor={field.testId}
label={t(field.labelKey)}
stacked
control={
<SettingsTextArea
id={field.testId}
data-testid={field.testId}
aria-label={t(field.labelKey)}
value={fields[field.key]}
rows={3}
disabled={disabled}
placeholder={t(field.placeholderKey)}
onChange={e => onChange(applyPersonaField(value, field.key, e.target.value))}
/>
}
/>
))}

<p className="text-xs text-content-muted leading-relaxed">
{t('settings.persona.builder.preservedNote')}
</p>

<p className="text-xs text-content-muted leading-relaxed">
{t('settings.persona.builder.securityNote')}{' '}
<button
type="button"
data-testid="persona-guided-agent-access"
className="text-primary-700 hover:underline dark:text-primary-300"
onClick={() => navigateToSettings('agent-access')}>
{t('settings.persona.builder.securityLink')}
</button>
</p>
</div>
);
};

export default PersonaGuidedFields;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useT } from '../../../../lib/i18n/I18nContext';
import { applyTemplate, PERSONA_TEMPLATES } from './personaTemplates';

interface PersonaTemplatePickerProps {
/** Current raw SOUL.md text a template is spliced into. */
value: string;
/** Emit the updated SOUL.md text after a template is applied. */
onChange: (nextSoul: string) => void;
disabled?: boolean;
}

/**
* Role starting-points for the guided persona builder (issue #4253, PR2).
*
* Applying a template fills the Personality and Communication-style fields for a
* common role (doctor, researcher, executive, teacher, student, family) and
* leaves the rest of SOUL.md — including the user-specific "About you" — intact.
* Nothing is persisted until the user saves, so this is a safe starting point.
*/
const PersonaTemplatePicker = ({
value,
onChange,
disabled = false,
}: PersonaTemplatePickerProps) => {
const { t } = useT();

return (
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-content">
{t('settings.persona.templates.heading')}
</p>
<p className="text-xs text-content-muted leading-relaxed">
{t('settings.persona.templates.desc')}
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{PERSONA_TEMPLATES.map(template => (
<button
key={template.id}
type="button"
disabled={disabled}
data-testid={`persona-template-${template.id}`}
onClick={() => onChange(applyTemplate(value, template))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the persona capability catalog

This adds a new user-facing Persona template action, but the repo instructions require updating src/openhuman/about_app/ for user-facing features, and catalog_data.rs still describes Persona Pack only as display name/SOUL editing/mascot settings. Any about/help/capability surface backed by that catalog will omit the template workflow, so please extend the Persona Pack catalog entry/tests alongside this UI addition.

Useful? React with 👍 / 👎.

className="flex flex-col items-start gap-0.5 rounded-lg border border-line-strong bg-surface px-3 py-2 text-left transition-colors hover:border-primary-400 hover:bg-surface-hover disabled:opacity-50">
<span className="text-sm font-medium text-content">{t(template.labelKey)}</span>
<span className="text-[11px] text-content-muted leading-snug">
{t(template.descriptionKey)}
</span>
</button>
))}
</div>
</div>
);
};

export default PersonaTemplatePicker;
Loading
Loading