refactor(decnet_web/PersonaGeneration): extract PersonaCard + PersonaEditor
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import PersonaCard from './PersonaCard';
|
||||
import { BLANK } from './helpers';
|
||||
import type { EmailPersona } from './types';
|
||||
|
||||
const persona: EmailPersona = {
|
||||
...BLANK,
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
role: 'COO',
|
||||
tone: 'custom',
|
||||
tone_custom: 'wry but polite',
|
||||
mannerisms: ['uses bullets', 'signs Best,'],
|
||||
uses_llms_heavily: true,
|
||||
};
|
||||
|
||||
describe('PersonaCard', () => {
|
||||
it('renders persona fields and fires edit/remove callbacks', () => {
|
||||
const onEdit = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
render(<PersonaCard persona={persona} onEdit={onEdit} onRemove={onRemove} />);
|
||||
expect(screen.getByText('Jane')).toBeInTheDocument();
|
||||
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('COO')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM-HEAVY')).toBeInTheDocument();
|
||||
expect(screen.getByText('wry but polite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Edit Jane'));
|
||||
fireEvent.click(screen.getByTitle('Remove Jane'));
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows em-dash-suppressed badge when not LLM-heavy and dash for empty mannerisms', () => {
|
||||
render(
|
||||
<PersonaCard
|
||||
persona={{ ...persona, uses_llms_heavily: false, mannerisms: [] }}
|
||||
onEdit={() => {}} onRemove={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('SUPPRESSED EM-DASH')).toBeInTheDocument();
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
90
decnet_web/src/components/PersonaGeneration/PersonaCard.tsx
Normal file
90
decnet_web/src/components/PersonaGeneration/PersonaCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Pencil, Trash2 } from '../../icons';
|
||||
import type { EmailPersona } from './types';
|
||||
|
||||
interface Props {
|
||||
persona: EmailPersona;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const PersonaCard: React.FC<Props> = ({ persona: p, onEdit, onRemove }) => (
|
||||
<div className="decky-card persona-card">
|
||||
<div className="decky-head">
|
||||
<div className="decky-name">
|
||||
<span className={`status-dot ${p.uses_llms_heavily ? 'mutating' : 'active'}`} />
|
||||
{p.name}
|
||||
</div>
|
||||
<span className="decky-ip">{p.email}</span>
|
||||
</div>
|
||||
|
||||
<div className="decky-meta">
|
||||
<div className="row">
|
||||
<span className="label">ROLE</span>
|
||||
<span>{p.role}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="label">TONE</span>
|
||||
<span
|
||||
className={`tone-chip tone-${p.tone}`}
|
||||
title={p.tone === 'custom' ? (p.tone_custom ?? '') : undefined}
|
||||
>
|
||||
{p.tone === 'custom' && p.tone_custom
|
||||
? (p.tone_custom.length > 24 ? `${p.tone_custom.slice(0, 22)}…` : p.tone_custom)
|
||||
: p.tone}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="label">LANG</span>
|
||||
<span className="dim">{(p.language ?? 'en').toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="label">HOURS</span>
|
||||
<span className="mono">{p.active_hours}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="label">REPLY</span>
|
||||
<span className="violet-accent">{p.reply_latency}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="type-label" style={{ marginBottom: 6, opacity: 0.5, fontSize: '0.62rem', letterSpacing: 1 }}>
|
||||
MANNERISMS
|
||||
</div>
|
||||
<div className="decky-services">
|
||||
{p.mannerisms.length === 0 ? (
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>—</span>
|
||||
) : (
|
||||
p.mannerisms.map((m, i) => (
|
||||
<span key={i} className="service-tag" title={m}>
|
||||
{m.length > 24 ? `${m.slice(0, 22)}…` : m}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="decky-footer">
|
||||
<span className="decky-hits">
|
||||
{p.uses_llms_heavily ? (
|
||||
<span className="alert-text" style={{ fontWeight: 700 }} title="Em-dash suppression lifted">
|
||||
LLM-HEAVY
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">SUPPRESSED EM-DASH</span>
|
||||
)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn small" onClick={onEdit} title={`Edit ${p.name}`}>
|
||||
<Pencil size={10} /> EDIT
|
||||
</button>
|
||||
<button className="btn alert small" onClick={onRemove} title={`Remove ${p.name}`}>
|
||||
<Trash2 size={10} /> REMOVE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PersonaCard;
|
||||
249
decnet_web/src/components/PersonaGeneration/PersonaEditor.tsx
Normal file
249
decnet_web/src/components/PersonaGeneration/PersonaEditor.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
import { Mail, Plus, Check, AlertTriangle } from '../../icons';
|
||||
import Modal from '../Modal/Modal';
|
||||
import { LATENCIES, TONES } from './helpers';
|
||||
import type { EmailPersona, ReplyLatency, Tone } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
editing: boolean;
|
||||
draft: EmailPersona;
|
||||
setDraft: (p: EmailPersona) => void;
|
||||
draftError: string | null;
|
||||
mannerismDraft: string;
|
||||
setMannerismDraft: (s: string) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const PersonaEditor: React.FC<Props> = ({
|
||||
open, editing, draft, setDraft, draftError,
|
||||
mannerismDraft, setMannerismDraft, onClose, onSave,
|
||||
}) => {
|
||||
const addMannerism = () => {
|
||||
const t = mannerismDraft.trim();
|
||||
if (!t) return;
|
||||
if (draft.mannerisms.includes(t)) {
|
||||
setMannerismDraft('');
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, mannerisms: [...draft.mannerisms, t] });
|
||||
setMannerismDraft('');
|
||||
};
|
||||
|
||||
const removeMannerism = (idx: number) => {
|
||||
setDraft({
|
||||
...draft,
|
||||
mannerisms: draft.mannerisms.filter((_, i) => i !== idx),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={editing ? 'EDIT PERSONA' : 'ADD PERSONA'}
|
||||
icon={Mail}
|
||||
accent="violet"
|
||||
width="wide"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn ghost" onClick={onClose}>CANCEL</button>
|
||||
<button className="btn violet" onClick={onSave}>
|
||||
<Check size={12} /> {editing ? 'UPDATE' : 'ADD'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="modal-body">
|
||||
<div className="grid-2">
|
||||
<div className="tweak-group">
|
||||
<label>NAME *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="John Smith"
|
||||
/>
|
||||
</div>
|
||||
<div className="tweak-group">
|
||||
<label>EMAIL *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={draft.email}
|
||||
onChange={(e) => setDraft({ ...draft, email: e.target.value })}
|
||||
placeholder="john.smith@corp.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tweak-group">
|
||||
<label>ROLE *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft.role}
|
||||
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
|
||||
placeholder="Chief Operating Officer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="tweak-group">
|
||||
<label>TONE</label>
|
||||
<select
|
||||
className="input"
|
||||
value={draft.tone}
|
||||
onChange={(e) => setDraft({ ...draft, tone: e.target.value as Tone })}
|
||||
>
|
||||
{TONES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
{draft.tone === 'custom' && (
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
maxLength={128}
|
||||
style={{ marginTop: 6 }}
|
||||
value={draft.tone_custom ?? ''}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, tone_custom: e.target.value || null })
|
||||
}
|
||||
placeholder="e.g. terse, deadpan, sarcastic-but-polite"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="tweak-group">
|
||||
<label>LANGUAGE</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
maxLength={8}
|
||||
value={draft.language ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, language: e.target.value || null })}
|
||||
placeholder="en"
|
||||
/>
|
||||
</div>
|
||||
<div className="tweak-group">
|
||||
<label>REPLY LATENCY</label>
|
||||
<select
|
||||
className="input"
|
||||
value={draft.reply_latency}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, reply_latency: e.target.value as ReplyLatency })
|
||||
}
|
||||
>
|
||||
{LATENCIES.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="tweak-group">
|
||||
<label>ACTIVE HOURS</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={draft.active_hours}
|
||||
onChange={(e) => setDraft({ ...draft, active_hours: e.target.value })}
|
||||
placeholder="09:00-18:00 (wraps OK)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tweak-group">
|
||||
<label>MANNERISMS ({draft.mannerisms.length}/12)</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
style={{ flex: 1 }}
|
||||
value={mannerismDraft}
|
||||
onChange={(e) => setMannerismDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addMannerism();
|
||||
}
|
||||
}}
|
||||
placeholder="opens with 'Hey' not 'Dear'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={addMannerism}
|
||||
disabled={!mannerismDraft.trim() || draft.mannerisms.length >= 12}
|
||||
>
|
||||
<Plus size={12} /> ADD
|
||||
</button>
|
||||
</div>
|
||||
{draft.mannerisms.length > 0 && (
|
||||
<div className="decky-services" style={{ marginTop: 8 }}>
|
||||
{draft.mannerisms.map((m, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="service-tag"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeMannerism(i)}
|
||||
title="click to remove"
|
||||
>
|
||||
{m} ✕
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tweak-group">
|
||||
<label>SIGNATURE (optional)</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.signature ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, signature: e.target.value || null })}
|
||||
placeholder="-- John COO, ACME Corp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', gap: 10, alignItems: 'center',
|
||||
padding: 14, border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="llm-heavy"
|
||||
type="checkbox"
|
||||
checked={draft.uses_llms_heavily}
|
||||
onChange={(e) => setDraft({ ...draft, uses_llms_heavily: e.target.checked })}
|
||||
style={{ accentColor: 'var(--violet)' }}
|
||||
/>
|
||||
<label htmlFor="llm-heavy" style={{ fontSize: '0.78rem', letterSpacing: 1 }}>
|
||||
<strong>USES LLMS HEAVILY</strong>
|
||||
<span className="dim" style={{ marginLeft: 8, letterSpacing: 0 }}>
|
||||
em-dash suppression lifted; output may contain natural em-dashes
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draftError && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--alert)',
|
||||
color: 'var(--alert)',
|
||||
padding: '8px 12px',
|
||||
fontSize: '0.75rem',
|
||||
letterSpacing: 1,
|
||||
display: 'inline-flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={12} /> {draftError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonaEditor;
|
||||
Reference in New Issue
Block a user