← back to Norma
components/email-analyzer/AnalyzerChat.tsx
610 lines
'use client';
import { useState, useRef, useEffect } from 'react';
import {
Send,
Loader2,
User,
Bot,
Plus,
Sliders,
RotateCcw,
} from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
interface AnalyzerChatProps {
messages: ChatMessage[];
onSendMessage: (message: string) => void;
onQuickAction: (action: { type: string; value: string }) => void;
isProcessing: boolean;
hasEmail: boolean;
}
/* ─── Slider dials ───────────────────────────────────────────────────────── */
/* Each dial is 1-5, default 3 ("no change on this axis"). The rewrite */
/* instruction only includes dials the user moved off the center position. */
interface DialDef {
id: keyof DialState;
label: string;
left: string;
right: string;
/** labels[0..4] correspond to values 1..5 */
labels: [string, string, string, string, string];
/** instruction fragment for each value, empty string for no-op */
instructions: [string, string, string, string, string];
}
interface DialState {
length: number;
sentiment: number;
tone: number;
urgency: number;
donation: number;
}
const DIAL_DEFAULTS: DialState = {
length: 3,
sentiment: 3,
tone: 3,
urgency: 3,
donation: 3,
};
const DIALS: DialDef[] = [
{
id: 'length',
label: 'Length',
left: 'Shorter',
right: 'Longer',
labels: ['Much shorter', 'Shorter', 'Same', 'Longer', 'Much longer'],
instructions: [
'make the email significantly shorter and more concise, cutting anything non-essential',
'make the email somewhat shorter and tighter',
'',
'expand the email with more detail and supporting context',
'significantly expand the email with rich detail, context, and supporting points',
],
},
{
id: 'sentiment',
label: 'Sentiment',
left: 'Critical',
right: 'Positive',
labels: ['Very critical', 'Skeptical', 'Neutral', 'Warm', 'Very positive'],
instructions: [
'shift the sentiment to be strongly critical and direct about the problem',
'shift the sentiment to be more skeptical and measured',
'',
'shift the sentiment to be warmer and more optimistic',
'shift the sentiment to be strongly positive, hopeful, and uplifting',
],
},
{
id: 'tone',
label: 'Tone',
left: 'Formal',
right: 'Casual',
labels: ['Very formal', 'Formal', 'Neutral', 'Casual', 'Very casual'],
instructions: [
'use a very formal, professional tone suitable for executive or legal audiences',
'use a somewhat more formal, polished tone',
'',
'use a more casual, conversational tone',
'use a very casual, warm, conversational tone as if speaking to a friend',
],
},
{
id: 'urgency',
label: 'Urgency',
left: 'Calm',
right: 'Urgent',
labels: ['Very calm', 'Calm', 'Neutral', 'Urgent', 'Very urgent'],
instructions: [
'make the email feel very calm and measured, no time pressure',
'reduce the urgency, make it feel less rushed',
'',
'increase the urgency and add a clear call-to-action',
'make the email feel highly urgent with a strong deadline-driven call-to-action',
],
},
{
id: 'donation',
label: 'Donation Ask',
left: 'None',
right: 'Strong ask',
labels: ['Remove ask', 'Soft mention', 'No change', 'Direct ask', 'Strong ask'],
instructions: [
'remove any donation or fundraising ask entirely',
'soften any donation ask to a subtle, indirect mention',
'',
'add a clear, direct donation ask with a suggested amount and a call-to-action link',
'add a strong, urgent donation ask with multiple suggested amounts, a deadline, and a prominent call-to-action',
],
},
];
function buildInstructionFromDials(state: DialState): string {
const parts: string[] = [];
for (const dial of DIALS) {
const v = state[dial.id];
if (v === 3) continue;
const frag = dial.instructions[v - 1];
if (frag) parts.push(frag);
}
if (parts.length === 0) return '';
return `Please rewrite the email with the following adjustments:\n- ${parts.join('\n- ')}`;
}
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function AnalyzerChat({
messages,
onSendMessage,
onQuickAction,
isProcessing,
hasEmail,
}: AnalyzerChatProps) {
const [input, setInput] = useState('');
const [addTopicInput, setAddTopicInput] = useState('');
const [showAddTopic, setShowAddTopic] = useState(false);
const [dials, setDials] = useState<DialState>(DIAL_DEFAULTS);
const chatEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dialsDirty = (Object.keys(DIAL_DEFAULTS) as Array<keyof DialState>).some(
(k) => dials[k] !== DIAL_DEFAULTS[k],
);
function updateDial(id: keyof DialState, value: number): void {
setDials((prev) => ({ ...prev, [id]: value }));
}
function resetDials(): void {
setDials(DIAL_DEFAULTS);
}
function applyDials(): void {
const instruction = buildInstructionFromDials(dials);
if (!instruction || isProcessing) return;
onSendMessage(instruction);
resetDials();
}
// Auto-scroll to bottom on new messages
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-focus input after AI responds
useEffect(() => {
if (!isProcessing && hasEmail) {
inputRef.current?.focus();
}
}, [isProcessing, hasEmail]);
function handleSend() {
const trimmed = input.trim();
if (!trimmed || isProcessing) return;
onSendMessage(trimmed);
setInput('');
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}
function handleAddTopic() {
const trimmed = addTopicInput.trim();
if (!trimmed) return;
onQuickAction({ type: 'add-paragraph', value: trimmed });
setAddTopicInput('');
setShowAddTopic(false);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* ── Chat Messages ──────────────────────────────────────────────── */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 0',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
{messages.length === 0 && (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '32px 16px',
gap: 8,
}}
>
<Bot
size={28}
style={{ color: 'var(--color-text-muted)' }}
/>
<p
style={{
fontSize: 14,
color: 'var(--color-text-muted)',
textAlign: 'center',
lineHeight: 1.5,
}}
>
{hasEmail
? 'Tell me how to improve this email. Try: "Make it more formal" or "Add a call to action"'
: 'Paste an email on the left to get started'}
</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
gap: 8,
padding: '0 4px',
}}
>
{/* Avatar */}
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
backgroundColor:
msg.role === 'user'
? 'var(--color-primary-dim)'
: 'var(--color-surface-el)',
border: `1px solid ${msg.role === 'user' ? 'rgba(10,124,89,0.25)' : 'var(--color-border)'}`,
}}
>
{msg.role === 'user' ? (
<User size={14} style={{ color: 'var(--color-primary)' }} />
) : (
<Bot size={14} style={{ color: 'var(--color-text-muted)' }} />
)}
</div>
{/* Bubble */}
<div
style={{
maxWidth: '85%',
padding: '8px 12px',
borderRadius: 10,
fontSize: 13,
lineHeight: 1.5,
color: 'var(--color-text)',
backgroundColor:
msg.role === 'user'
? 'var(--color-primary-dim)'
: 'var(--color-surface-el)',
border: `1px solid ${msg.role === 'user' ? 'rgba(10,124,89,0.2)' : 'var(--color-border)'}`,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.content}
</div>
</div>
))}
{isProcessing && (
<div style={{ display: 'flex', gap: 8, padding: '0 4px' }}>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<Bot size={14} style={{ color: 'var(--color-text-muted)' }} />
</div>
<div
style={{
padding: '8px 12px',
borderRadius: 10,
fontSize: 13,
color: 'var(--color-text-muted)',
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Loader2 size={14} className="spinner" />
Rewriting...
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* ── Quick Actions ──────────────────────────────────────────────── */}
{hasEmail && (
<div
style={{
padding: '8px 0',
borderTop: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{/* Rewrite dials (sliders) */}
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
}}
>
<Sliders size={12} style={{ color: 'var(--color-text-muted)' }} />
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
Rewrite Dials
</div>
{dialsDirty && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={resetDials}
disabled={isProcessing}
style={{
fontSize: 10,
padding: '2px 8px',
minHeight: 22,
gap: 3,
marginLeft: 'auto',
}}
title="Reset all dials to center"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{DIALS.map((dial) => {
const value = dials[dial.id];
const isDefault = value === 3;
const currentLabel = dial.labels[value - 1];
return (
<div key={dial.id}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: 2,
}}
>
<label
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{dial.label}
</label>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: isDefault
? 'var(--color-text-muted)'
: 'var(--color-primary)',
}}
>
{currentLabel}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 9,
color: 'var(--color-text-muted)',
minWidth: 48,
textAlign: 'right',
}}
>
{dial.left}
</span>
<input
type="range"
min={1}
max={5}
step={1}
value={value}
disabled={isProcessing}
onChange={(e) => updateDial(dial.id, Number(e.target.value))}
style={{
flex: 1,
accentColor: isDefault
? 'var(--color-text-muted)'
: 'var(--color-primary)',
}}
/>
<span
style={{
fontSize: 9,
color: 'var(--color-text-muted)',
minWidth: 56,
}}
>
{dial.right}
</span>
</div>
</div>
);
})}
</div>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dialsDirty || isProcessing}
onClick={applyDials}
style={{
marginTop: 8,
fontSize: 12,
padding: '6px 12px',
width: '100%',
justifyContent: 'center',
}}
>
{isProcessing ? (
<>
<Loader2 size={12} className="spinner" /> Rewriting...
</>
) : (
<>Apply Dial Changes</>
)}
</button>
</div>
{/* Add paragraph about ... */}
<div>
{showAddTopic ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
className="input"
style={{ fontSize: 13, minHeight: 34, padding: '6px 10px', flex: 1 }}
placeholder="e.g. upcoming webinar on March 25th"
value={addTopicInput}
onChange={(e) => setAddTopicInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); handleAddTopic(); }
if (e.key === 'Escape') setShowAddTopic(false);
}}
autoFocus
/>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ minHeight: 34, padding: '4px 10px', fontSize: 12 }}
disabled={isProcessing || !addTopicInput.trim()}
onClick={handleAddTopic}
>
Add
</button>
</div>
) : (
<button
type="button"
disabled={isProcessing}
onClick={() => setShowAddTopic(true)}
className="btn btn-ghost btn-sm"
style={{
fontSize: 12,
padding: '4px 10px',
minHeight: 30,
gap: 4,
borderColor: 'var(--color-border)',
}}
>
<Plus size={12} />
Add paragraph about...
</button>
)}
</div>
</div>
)}
{/* ── Chat Input ─────────────────────────────────────────────────── */}
<div
style={{
padding: '8px 0 0',
borderTop: '1px solid var(--color-border)',
display: 'flex',
gap: 6,
alignItems: 'flex-end',
}}
>
<textarea
ref={inputRef}
className="input"
style={{
flex: 1,
fontSize: 13,
minHeight: 40,
maxHeight: 120,
resize: 'none',
padding: '8px 12px',
}}
placeholder={hasEmail ? 'Tell me how to change the email... (Ctrl+Enter to send)' : 'Paste an email first...'}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={!hasEmail || isProcessing}
rows={1}
/>
<button
type="button"
className="btn btn-primary btn-sm"
style={{
minHeight: 40,
width: 40,
padding: 0,
flexShrink: 0,
}}
disabled={!input.trim() || isProcessing || !hasEmail}
onClick={handleSend}
title="Send (Ctrl+Enter)"
>
{isProcessing ? (
<Loader2 size={16} className="spinner" />
) : (
<Send size={16} />
)}
</button>
</div>
</div>
);
}