From 97e72d975ba54ea5599a8fe925b493f73f0943e0 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:45:10 -0400 Subject: [PATCH] refactor(decnet_web/PersonaGeneration): extract PersonaCard + PersonaEditor --- .../PersonaGeneration/PersonaCard.test.tsx | 48 ++++ .../PersonaGeneration/PersonaCard.tsx | 90 +++++++ .../PersonaGeneration/PersonaEditor.tsx | 249 ++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 decnet_web/src/components/PersonaGeneration/PersonaCard.test.tsx create mode 100644 decnet_web/src/components/PersonaGeneration/PersonaCard.tsx create mode 100644 decnet_web/src/components/PersonaGeneration/PersonaEditor.tsx diff --git a/decnet_web/src/components/PersonaGeneration/PersonaCard.test.tsx b/decnet_web/src/components/PersonaGeneration/PersonaCard.test.tsx new file mode 100644 index 00000000..0fdd4667 --- /dev/null +++ b/decnet_web/src/components/PersonaGeneration/PersonaCard.test.tsx @@ -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(); + 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( + {}} onRemove={() => {}} + />, + ); + expect(screen.getByText('SUPPRESSED EM-DASH')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/PersonaGeneration/PersonaCard.tsx b/decnet_web/src/components/PersonaGeneration/PersonaCard.tsx new file mode 100644 index 00000000..5524a718 --- /dev/null +++ b/decnet_web/src/components/PersonaGeneration/PersonaCard.tsx @@ -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 = ({ persona: p, onEdit, onRemove }) => ( +
+
+
+ + {p.name} +
+ {p.email} +
+ +
+
+ ROLE + {p.role} +
+
+ TONE + + {p.tone === 'custom' && p.tone_custom + ? (p.tone_custom.length > 24 ? `${p.tone_custom.slice(0, 22)}…` : p.tone_custom) + : p.tone} + +
+
+ LANG + {(p.language ?? 'en').toUpperCase()} +
+
+ HOURS + {p.active_hours} +
+
+ REPLY + {p.reply_latency} +
+
+ +
+
+ MANNERISMS +
+
+ {p.mannerisms.length === 0 ? ( + + ) : ( + p.mannerisms.map((m, i) => ( + + {m.length > 24 ? `${m.slice(0, 22)}…` : m} + + )) + )} +
+
+ +
+ + {p.uses_llms_heavily ? ( + + LLM-HEAVY + + ) : ( + SUPPRESSED EM-DASH + )} + +
+ + +
+
+
+); + +export default PersonaCard; diff --git a/decnet_web/src/components/PersonaGeneration/PersonaEditor.tsx b/decnet_web/src/components/PersonaGeneration/PersonaEditor.tsx new file mode 100644 index 00000000..1321be13 --- /dev/null +++ b/decnet_web/src/components/PersonaGeneration/PersonaEditor.tsx @@ -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 = ({ + 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 ( + + + + + } + > +
+
+
+ + setDraft({ ...draft, name: e.target.value })} + placeholder="John Smith" + /> +
+
+ + setDraft({ ...draft, email: e.target.value })} + placeholder="john.smith@corp.com" + /> +
+
+ +
+ + setDraft({ ...draft, role: e.target.value })} + placeholder="Chief Operating Officer" + /> +
+ +
+
+ + + {draft.tone === 'custom' && ( + + setDraft({ ...draft, tone_custom: e.target.value || null }) + } + placeholder="e.g. terse, deadpan, sarcastic-but-polite" + /> + )} +
+
+ + setDraft({ ...draft, language: e.target.value || null })} + placeholder="en" + /> +
+
+ + +
+
+ + setDraft({ ...draft, active_hours: e.target.value })} + placeholder="09:00-18:00 (wraps OK)" + /> +
+
+ +
+ +
+ setMannerismDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addMannerism(); + } + }} + placeholder="opens with 'Hey' not 'Dear'" + /> + +
+ {draft.mannerisms.length > 0 && ( +
+ {draft.mannerisms.map((m, i) => ( + removeMannerism(i)} + title="click to remove" + > + {m} ✕ + + ))} +
+ )} +
+ +
+ +