← back to Norma
components/email-analyzer/EmailAnalyzer.tsx
1216 lines
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Sparkles,
RotateCcw,
BarChart3,
Save,
Pencil,
FileText,
MessageSquare,
Sliders,
Gauge,
Lightbulb,
BookOpen,
History,
FolderClock,
CheckCircle2,
Loader2,
Search,
Inbox,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import AnalyzerChat, { type ChatMessage } from './AnalyzerChat';
import VersionTimeline, { type VersionEntry } from './VersionTimeline';
import ReferencePanel from './ReferencePanel';
import type { Reference } from './useReferences';
import OriginalEmailCard, { AddOriginalSection } from './OriginalEmailCard';
import GeneratedVersionCard from './GeneratedVersionCard';
import CompareModal from './CompareModal';
import ProsConsPanel, { type ProsConsData } from './ProsConsPanel';
import { fetchProsCons } from './fetchProsCons';
import ScorePanel from './ScorePanel';
import RewriteControls from './RewriteControls';
import SuggestionsPanel, { extractKeywords } from './SuggestionsPanel';
import SavedAnalyses from './SavedAnalyses';
import EmailSearchPanel from './EmailSearchPanel';
import SdccEmailsPanel from './SdccEmailsPanel';
import SubjectLineMakerPanel from './SubjectLineMakerPanel';
import LayoutGrid, { type LayoutState, type PanelRenderer } from './LayoutGrid';
import PanelShell from './PanelShell';
import {
DEFAULT_LAYOUT_STATE,
LAYOUT_PREF_KEY,
PANEL_LABELS,
type PanelId,
} from './panelLayoutDefaults';
import { readLocal, rehydrate, savePreference } from '@/lib/user-preferences';
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function generateSessionId(): string {
return crypto.randomUUID ? crypto.randomUUID() : generateId();
}
function countWords(html: string): number {
const plain = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
return plain.split(/\s+/).filter(Boolean).length;
}
function countParagraphs(html: string): number {
const pCount = (html.match(/<p[\s>]/gi) || []).length;
if (pCount > 0) return pCount;
return html.split(/\n\n+/).filter((s) => s.trim()).length;
}
function textToHtml(text: string): string {
return text
.split(/\n\n+/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('\n');
}
interface AnalysisStats {
wordCount: number;
paragraphCount: number;
sentenceCount: number;
tone: string;
topics: string[];
}
/* ─── Persistence keys ───────────────────────────────────────────────────── */
const SESSION_CACHE_KEY = 'norma-email-analyzer-session';
const LOAD_SESSION_KEY = 'norma-analyzer-load-session';
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function EmailAnalyzer() {
const { addToast } = useToast();
// Core state
const [pastedEmail, setPastedEmail] = useState('');
const [originalVersions, setOriginalVersions] = useState<
{ id: string; content: string; label: string; timestamp: Date }[]
>([]);
const [showAddOriginal, setShowAddOriginal] = useState(false);
const [newOriginalText, setNewOriginalText] = useState('');
const [currentVersion, setCurrentVersion] = useState('');
const [sessionId, setSessionId] = useState('');
const [versions, setVersions] = useState<VersionEntry[]>([]);
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisStats, setAnalysisStats] = useState<AnalysisStats | null>(null);
const [showCompare, setShowCompare] = useState(false);
const [showOriginal, setShowOriginal] = useState(true);
const [prosConsAnalysis, setProsConsAnalysis] = useState<ProsConsData | null>(null);
const [analyzingProsCons, setAnalyzingProsCons] = useState(false);
const getContextStringRef = useRef<(() => string) | null>(null);
const [compareA, setCompareA] = useState<VersionEntry | null>(null);
const [compareB, setCompareB] = useState<VersionEntry | null>(null);
const [compareProsCons, setCompareProsCons] = useState<ProsConsData | null>(null);
const [comparePCLoading, setComparePCLoading] = useState(false);
// Parallel-analysis state
const [suggestionsRefreshKey, setSuggestionsRefreshKey] = useState(0);
// Layout config — dockable grid of panels.
// On every page load we force every panel to start collapsed. The user's
// column count, panel order, and x/y positions are preserved — only the
// expanded/collapsed state is reset so the tool always fits on one screen.
const [layout, setLayout] = useState<LayoutState>(() => {
const base = readLocal<LayoutState>(LAYOUT_PREF_KEY) ?? DEFAULT_LAYOUT_STATE;
return { ...base, panels: base.panels.map((p) => ({ ...p, collapsed: true })) };
});
// Save-state tracking for the header pill
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [, rerender] = useState(0);
// Rehydrate layout from DB on mount (localStorage wins until DB responds)
useEffect(() => {
let cancelled = false;
void (async () => {
const local = readLocal<LayoutState>(LAYOUT_PREF_KEY);
const rehydrated = await rehydrate<LayoutState>(LAYOUT_PREF_KEY, local);
if (!cancelled && rehydrated && rehydrated !== local) {
// Force all collapsed on DB rehydrate too — same rationale as the mount initializer.
setLayout({ ...rehydrated, panels: rehydrated.panels.map((p) => ({ ...p, collapsed: true })) });
}
})();
return () => { cancelled = true; };
}, []);
// Persist layout on any change (write-through: localStorage sync + DB debounced)
useEffect(() => {
savePreference(LAYOUT_PREF_KEY, layout);
}, [layout]);
// Tick once a minute so the "Saved 12s ago" pill refreshes without touching layout
useEffect(() => {
if (!lastSavedAt) return;
const t = setInterval(() => rerender((n) => n + 1), 30_000);
return () => clearInterval(t);
}, [lastSavedAt]);
const pasteRef = useRef<HTMLTextAreaElement>(null);
// Restore session from localStorage on mount (or load a saved session if one was requested)
useEffect(() => {
const pendingLoad = (() => {
try {
return sessionStorage.getItem(LOAD_SESSION_KEY);
} catch {
return null;
}
})();
if (pendingLoad) {
try { sessionStorage.removeItem(LOAD_SESSION_KEY); } catch { /* noop */ }
void hydrateFromSavedSession(pendingLoad);
return;
}
try {
const cached = localStorage.getItem(SESSION_CACHE_KEY);
if (!cached) {
setSessionId(generateSessionId());
return;
}
const data = JSON.parse(cached);
if (data.pastedEmail) setPastedEmail(data.pastedEmail);
if (data.currentVersion) setCurrentVersion(data.currentVersion);
if (data.sessionId) setSessionId(data.sessionId);
if (data.versions) {
setVersions(
data.versions.map((v: VersionEntry) => ({
...v,
timestamp: new Date(v.timestamp),
})),
);
}
if (data.activeVersionId) setActiveVersionId(data.activeVersionId);
if (data.chatMessages) {
setChatMessages(
data.chatMessages.map((m: ChatMessage) => ({
...m,
timestamp: new Date(m.timestamp),
})),
);
}
if (data.analysisStats) setAnalysisStats(data.analysisStats);
} catch {
setSessionId(generateSessionId());
}
}, []);
// Persist session on change
useEffect(() => {
if (!sessionId) return;
try {
localStorage.setItem(
SESSION_CACHE_KEY,
JSON.stringify({
pastedEmail,
currentVersion,
sessionId,
versions,
activeVersionId,
chatMessages,
analysisStats,
}),
);
} catch {
/* noop */
}
}, [pastedEmail, currentVersion, sessionId, versions, activeVersionId, chatMessages, analysisStats]);
/* ── Hydrate state from a previously saved session (db-backed) ─────────── */
async function hydrateFromSavedSession(sid: string): Promise<void> {
try {
const res = await fetch(
`/api/email-analyzer/drafts?session_id=${encodeURIComponent(sid)}`,
{ credentials: 'include' },
);
if (!res.ok) throw new Error('Failed to load saved analysis');
const data = await res.json();
const rows: Array<{
id: number;
version_number: number;
label: string | null;
tone: string | null;
word_count: number | null;
paragraph_count: number | null;
current_version: string;
original_email: string;
chat_history: unknown;
created_at: string;
}> = Array.isArray(data.drafts) ? data.drafts : [];
if (rows.length === 0) {
setSessionId(generateSessionId());
addToast('Saved analysis had no versions', 'error');
return;
}
const sorted = [...rows].sort((a, b) => a.version_number - b.version_number);
const latest = sorted[sorted.length - 1];
const rebuilt: VersionEntry[] = sorted.map((r) => ({
id: generateId(),
versionNumber: r.version_number,
content: r.current_version,
label: r.label || `Version ${r.version_number}`,
tone: r.tone || 'professional',
wordCount: r.word_count || countWords(r.current_version),
paragraphCount: r.paragraph_count || countParagraphs(r.current_version),
timestamp: new Date(r.created_at),
}));
let chat: ChatMessage[] = [];
const rawChat = latest.chat_history;
if (Array.isArray(rawChat)) {
chat = rawChat.map((m: { role: string; content: string }) => ({
id: generateId(),
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content,
timestamp: new Date(latest.created_at),
}));
} else if (typeof rawChat === 'string') {
try {
const parsed = JSON.parse(rawChat);
if (Array.isArray(parsed)) {
chat = parsed.map((m: { role: string; content: string }) => ({
id: generateId(),
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content,
timestamp: new Date(latest.created_at),
}));
}
} catch { /* ignore */ }
}
setPastedEmail(latest.original_email);
setCurrentVersion(latest.current_version);
setSessionId(sid);
setVersions(rebuilt);
setActiveVersionId(rebuilt[rebuilt.length - 1].id);
setChatMessages(chat);
setOriginalVersions([
{
id: generateId(),
content: latest.original_email,
label: 'Original v0.1',
timestamp: new Date(latest.created_at),
},
]);
const plain = latest.original_email.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const wc = plain.split(/\s+/).filter(Boolean).length;
const sc = plain.split(/[.!?]+/).filter((s) => s.trim()).length;
const pc = latest.original_email.split(/\n\n+/).filter((s) => s.trim()).length;
setAnalysisStats({
wordCount: wc,
paragraphCount: pc,
sentenceCount: sc,
tone: latest.tone || 'professional',
topics: [],
});
addToast(`Loaded ${rebuilt.length} version${rebuilt.length === 1 ? '' : 's'}`, 'success');
} catch (err) {
setSessionId(generateSessionId());
addToast((err as Error).message || 'Failed to load saved analysis', 'error');
}
}
/* ── Analyze email stats ───────────────────────────────────────────────── */
async function handleAnalyze(): Promise<void> {
if (!pastedEmail.trim()) {
addToast('Paste an email first', 'error');
return;
}
setIsAnalyzing(true);
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: `Analyze this email and return ONLY valid JSON (no markdown, no code fences):
{
"tone": "one word tone description (e.g. formal, friendly, urgent, empathetic, professional, casual)",
"topics": ["list", "of", "3-5", "key", "topics"],
"sentiment": "positive, negative, or neutral"
}
Email:
${pastedEmail.substring(0, 2000)}`,
maxOutputTokens: 200,
temperature: 0.3,
}),
});
if (!res.ok) throw new Error('Analysis failed');
const data = await res.json();
let text = data.text || '';
text = text.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();
const plain = pastedEmail.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const wc = plain.split(/\s+/).filter(Boolean).length;
const sc = plain.split(/[.!?]+/).filter((s) => s.trim()).length;
const pc = pastedEmail.split(/\n\n+/).filter((s) => s.trim()).length;
let parsed = { tone: 'professional', topics: [] as string[], sentiment: 'neutral' };
try {
parsed = JSON.parse(text);
} catch {
/* use defaults */
}
const stats: AnalysisStats = {
wordCount: wc,
paragraphCount: pc,
sentenceCount: sc,
tone: parsed.tone || 'professional',
topics: parsed.topics || [],
};
setAnalysisStats(stats);
const htmlVersion = pastedEmail.includes('<p') ? pastedEmail : textToHtml(pastedEmail);
setCurrentVersion(htmlVersion);
setOriginalVersions([
{ id: generateId(), content: pastedEmail, label: 'Original v0.1', timestamp: new Date() },
]);
const v1: VersionEntry = {
id: generateId(),
versionNumber: 1,
content: htmlVersion,
label: 'Original',
tone: stats.tone,
wordCount: wc,
paragraphCount: pc,
timestamp: new Date(),
};
setVersions([v1]);
setActiveVersionId(v1.id);
saveVersionToDb(v1, htmlVersion);
addToast('Email analyzed', 'success');
} catch (err) {
addToast((err as Error).message || 'Analysis failed', 'error');
} finally {
setIsAnalyzing(false);
}
}
/* ── Save version to DB ────────────────────────────────────────────────── */
const saveVersionToDb = useCallback(
async (version: VersionEntry, emailContent: string): Promise<void> => {
setIsSaving(true);
try {
const res = await fetch('/api/email-analyzer/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
originalEmail: pastedEmail,
currentVersion: emailContent,
versionNumber: version.versionNumber,
label: version.label,
tone: version.tone,
paragraphCount: version.paragraphCount,
wordCount: version.wordCount,
chatHistory: chatMessages.map((m) => ({ role: m.role, content: m.content })),
sessionId,
isFavorite: version.isFavorite || false,
score: version.score ?? null,
}),
});
if (res.ok) setLastSavedAt(Date.now());
} catch {
// Silent fail for DB save -- session data is in localStorage
} finally {
setIsSaving(false);
}
},
[pastedEmail, chatMessages, sessionId],
);
/* ── AI Rewrite via chat ───────────────────────────────────────────────── */
async function handleRewrite(instruction: string): Promise<void> {
if (!currentVersion) return;
setIsProcessing(true);
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: instruction,
timestamp: new Date(),
};
setChatMessages((prev) => [...prev, userMsg]);
try {
const res = await fetch('/api/email-analyzer/rewrite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
originalEmail: pastedEmail,
currentVersion,
instruction,
chatHistory: chatMessages.map((m) => ({
role: m.role,
content: m.content,
})),
referenceContext: getContextStringRef.current?.() || '',
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({ error: 'Rewrite failed' }));
throw new Error(errData.error || 'Rewrite failed');
}
const data = await res.json();
const {
rewrittenEmail,
summary,
tone: newTone,
paragraphCount: newPc,
wordCount: newWc,
} = data;
const aiMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: summary || 'Done! Check the updated version on the left.',
timestamp: new Date(),
};
setChatMessages((prev) => [...prev, aiMsg]);
setCurrentVersion(rewrittenEmail);
const newVersion: VersionEntry = {
id: generateId(),
versionNumber: versions.length + 1,
content: rewrittenEmail,
label: summary || instruction.substring(0, 50),
tone: newTone || 'professional',
wordCount: newWc || countWords(rewrittenEmail),
paragraphCount: newPc || countParagraphs(rewrittenEmail),
timestamp: new Date(),
};
setVersions((prev) => [...prev, newVersion]);
setActiveVersionId(newVersion.id);
saveVersionToDb(newVersion, rewrittenEmail);
setShowOriginal(false);
// Fire-and-forget pros/cons comparison
runInlineProsCons(rewrittenEmail, currentVersion);
addToast('Version saved', 'success');
} catch (err) {
const errMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: `Sorry, there was an error: ${(err as Error).message}`,
timestamp: new Date(),
};
setChatMessages((prev) => [...prev, errMsg]);
addToast((err as Error).message, 'error');
} finally {
setIsProcessing(false);
}
}
/* ── Quick action handlers ─────────────────────────────────────────────── */
function handleQuickAction(action: { type: string; value: string }): void {
let instruction = '';
switch (action.type) {
case 'tone':
instruction = `Rewrite this email with a ${action.value} tone`;
break;
case 'length':
if (action.value === 'shorter') {
instruction = 'Make this email shorter and more concise';
} else if (action.value === 'longer') {
instruction = 'Expand this email with more detail and context';
} else {
instruction = 'Rewrite this email keeping the same approximate length';
}
break;
case 'add-paragraph':
instruction = `Add a paragraph about: ${action.value}`;
break;
default:
return;
}
handleRewrite(instruction);
}
/* ── Add another original email ────────────────────────────────────────── */
function handleAddOriginal(): void {
if (!newOriginalText.trim()) return;
const num = originalVersions.length + 1;
const label = `Original v0.${num}`;
const newOrig = {
id: generateId(),
content: newOriginalText.trim(),
label,
timestamp: new Date(),
};
setOriginalVersions((prev) => [...prev, newOrig]);
const htmlVersion = newOriginalText.includes('<p')
? newOriginalText
: textToHtml(newOriginalText);
const newVersion: VersionEntry = {
id: generateId(),
versionNumber: versions.length + 1,
content: htmlVersion,
label,
tone: 'original',
wordCount: countWords(newOriginalText),
paragraphCount: countParagraphs(newOriginalText),
timestamp: new Date(),
};
setVersions((prev) => [...prev, newVersion]);
setCurrentVersion(htmlVersion);
setActiveVersionId(newVersion.id);
setPastedEmail(newOriginalText.trim());
setNewOriginalText('');
setShowAddOriginal(false);
saveVersionToDb(newVersion, htmlVersion);
addToast(`${label} saved`, 'success');
}
/* ── Shared pros/cons fetch (inline panel) ─────────────────────────────── */
async function runInlineProsCons(newHtml: string, previousHtml: string): Promise<void> {
if (!previousHtml || !newHtml) {
setProsConsAnalysis(null);
return;
}
setAnalyzingProsCons(true);
try {
const result = await fetchProsCons(pastedEmail, newHtml, previousHtml);
setProsConsAnalysis(result);
} catch {
setProsConsAnalysis(null);
} finally {
setAnalyzingProsCons(false);
}
}
/* ── Version selection ─────────────────────────────────────────────────── */
function handleSelectVersion(version: VersionEntry): void {
setCurrentVersion(version.content);
setActiveVersionId(version.id);
const idx = versions.findIndex((v) => v.id === version.id);
const prevVersion = idx > 0 ? versions[idx - 1] : null;
if (prevVersion) {
runInlineProsCons(version.content, prevVersion.content);
} else {
runInlineProsCons(version.content, pastedEmail);
}
}
/* ── Compare modal ─────────────────────────────────────────────────────── */
function handleCompare(a: VersionEntry, b: VersionEntry): void {
setCompareA(a);
setCompareB(b);
setShowCompare(true);
setCompareProsCons(null);
setComparePCLoading(true);
fetchProsCons(pastedEmail, b.content, a.content, {
newLabel: `version B (v${b.versionNumber})`,
previousLabel: `version A (v${a.versionNumber})`,
})
.then((result) => setCompareProsCons(result))
.catch(() => setCompareProsCons(null))
.finally(() => setComparePCLoading(false));
}
/* ── Export to Compose ─────────────────────────────────────────────────── */
function handleExportCompose(content: string): void {
const plainText = content
.replace(/<[^>]+>/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
sessionStorage.setItem(
'norma-analyzer-export',
JSON.stringify({ body: plainText, html: content }),
);
addToast('Email exported -- open Compose in Email Center to use it', 'success');
}
/* ── Favorite toggle ───────────────────────────────────────────────────── */
function handleToggleFavorite(versionId: string): void {
setVersions((prev) => {
const next = prev.map((v) =>
v.id === versionId ? { ...v, isFavorite: !v.isFavorite } : v,
);
// Persist immediately so the star survives reload.
const updated = next.find((v) => v.id === versionId);
if (updated && currentVersion) {
void saveVersionToDb(updated, updated.content);
}
return next;
});
}
/* ── Save a version as a reference (project-scoped or universal) ──────── */
async function handleAddVersionToReferences(
version: VersionEntry,
scope: 'project' | 'universal',
): Promise<void> {
const plainText = version.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const title = `${version.label} (v${version.versionNumber})`;
if (scope === 'project') {
if (!sessionId) {
addToast('No active session — cannot save project reference', 'error');
return;
}
try {
const res = await fetch('/api/email-analyzer/references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
ref_type: 'article',
title,
content: plainText,
key_points: [],
include_in_rewrite: true,
}),
});
if (!res.ok) throw new Error('save failed');
addToast(`Added "${title}" to project references`, 'success');
// tell ReferencePanel to reload
try {
window.dispatchEvent(
new CustomEvent('norma-analyzer-refs-changed', { detail: { sessionId } }),
);
} catch {
/* noop */
}
} catch {
addToast('Failed to save project reference', 'error');
}
return;
}
// Universal scope: save to the universal references store (shared across sessions).
// Round 2 will add a dedicated /api/email-analyzer/universal-references endpoint;
// for now, best-effort POST + graceful fallback so the button is discoverable.
try {
const res = await fetch('/api/email-analyzer/universal-references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
content: plainText,
tone: version.tone,
score: version.score ?? null,
source_session_id: sessionId,
}),
});
if (!res.ok) throw new Error('save failed');
addToast(`Added "${title}" to Universal References`, 'success');
} catch {
addToast('Universal References endpoint not ready yet — saved locally', 'info');
}
}
/* ── New Analysis ──────────────────────────────────────────────────────── */
function handleNewAnalysis(): void {
setPastedEmail('');
setCurrentVersion('');
setVersions([]);
setActiveVersionId(null);
setChatMessages([]);
setAnalysisStats(null);
setSessionId(generateSessionId());
setShowCompare(false);
setCompareA(null);
setCompareB(null);
try {
localStorage.removeItem(SESSION_CACHE_KEY);
} catch {
/* noop */
}
pasteRef.current?.focus();
addToast('Started new analysis', 'info');
}
/* ── Save current version explicitly ───────────────────────────────────── */
function handleSaveVersion(): void {
if (!currentVersion) return;
const existing = versions.find((v) => v.id === activeVersionId);
if (existing) {
saveVersionToDb(existing, currentVersion);
addToast('Version saved to database', 'success');
}
}
const hasVersions = versions.length > 0;
return (
<div className="p-4 sm:p-6 max-w-7xl mx-auto">
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between mb-5 gap-3 flex-wrap">
<div className="flex items-center gap-2.5">
<div
style={{
width: 36,
height: 36,
borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(79,70,229,0.15), rgba(99,102,241,0.15))',
border: '1px solid rgba(79,70,229,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
aria-hidden="true"
>
<Sparkles size={18} style={{ color: '#6366f1' }} />
</div>
<div>
<h1
className="text-lg font-semibold"
style={{ color: 'var(--color-text)' }}
>
Email Analyzer
</h1>
<p
className="text-sm"
style={{ color: 'var(--color-text-muted)' }}
>
Paste, analyze, and rewrite emails with AI
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasVersions && (
<>
<SaveStatusPill
isSaving={isSaving}
lastSavedAt={lastSavedAt}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={handleSaveVersion}
title="Create a named checkpoint of the current version"
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<Save size={14} /> Save checkpoint
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={handleNewAnalysis}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<RotateCcw size={14} /> New Analysis
</button>
</>
)}
</div>
</div>
{/* ── Compare Modal ────────────────────────────────────────────────── */}
{showCompare && compareA && compareB && (
<CompareModal
compareA={compareA}
compareB={compareB}
prosConsData={compareProsCons}
prosConsLoading={comparePCLoading}
onClose={() => setShowCompare(false)}
onApplySuggestion={(suggestion, label) => {
const instruction = `[Improvement for ${label}] ${suggestion}`;
void handleRewrite(instruction);
}}
/>
)}
{/* ── Dockable Panel Grid ─────────────────────────────────────────── */}
{(() => {
// Helper to build a PanelShell-wrapped renderer with an icon, title, and body node.
const wrap = (
id: PanelId,
icon: React.ReactNode,
body: React.ReactNode,
bodyPadding = 14,
badge?: React.ReactNode,
): PanelRenderer => (collapsed, setCollapsed) => (
<PanelShell
id={id}
title={PANEL_LABELS[id]}
icon={icon}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
bodyPadding={bodyPadding}
badge={badge}
>
{body}
</PanelShell>
);
const renderers: Partial<Record<PanelId, PanelRenderer>> = {
original: wrap(
'original',
<Pencil size={14} />,
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<OriginalEmailCard
pastedEmail={pastedEmail}
onPastedEmailChange={setPastedEmail}
showOriginal={showOriginal}
onToggleOriginal={() => setShowOriginal((p) => !p)}
hasVersions={hasVersions}
isAnalyzing={isAnalyzing}
onAnalyze={handleAnalyze}
pasteRef={pasteRef}
/>
{hasVersions && (
<AddOriginalSection
show={showAddOriginal}
onShow={() => setShowAddOriginal(true)}
onHide={() => { setShowAddOriginal(false); setNewOriginalText(''); }}
originalCount={originalVersions.length}
newOriginalText={newOriginalText}
onNewOriginalTextChange={setNewOriginalText}
onSave={handleAddOriginal}
/>
)}
</div>,
),
stats: analysisStats
? wrap(
'stats',
<BarChart3 size={14} />,
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<StatBadge label="Words" value={String(analysisStats.wordCount)} />
<StatBadge label="Paragraphs" value={String(analysisStats.paragraphCount)} />
<StatBadge label="Sentences" value={String(analysisStats.sentenceCount)} />
<StatBadge label="Tone" value={analysisStats.tone} />
{analysisStats.topics.slice(0, 3).map((topic) => (
<span key={topic} className="badge" style={{ fontSize: 12, padding: '3px 10px', backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)', textTransform: 'none' }}>
{topic}
</span>
))}
</div>,
)
: undefined,
generated: currentVersion
? wrap(
'generated',
<FileText size={14} />,
<GeneratedVersionCard
currentVersion={currentVersion}
activeVersionId={activeVersionId}
versions={versions}
/>,
0,
)
: undefined,
proscons:
(prosConsAnalysis || analyzingProsCons) && versions.length > 1
? wrap(
'proscons',
<BarChart3 size={14} style={{ color: 'var(--color-lane-b)' }} />,
<ProsConsPanel data={prosConsAnalysis} loading={analyzingProsCons} />,
0,
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>vs previous</span>,
)
: undefined,
chat: wrap(
'chat',
<MessageSquare size={14} />,
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 260 }}>
<AnalyzerChat
messages={chatMessages}
onSendMessage={handleRewrite}
onQuickAction={handleQuickAction}
isProcessing={isProcessing}
hasEmail={hasVersions}
/>
</div>,
),
score: wrap(
'score',
<Gauge size={14} />,
<ScorePanel
email={currentVersion || pastedEmail}
autoRunKey={activeVersionId || undefined}
onApplySuggestion={(suggestion, label) => {
const instruction = `[Improvement for ${label}] ${suggestion}`;
void handleRewrite(instruction);
}}
/>,
0,
),
controls: wrap(
'controls',
<Sliders size={14} />,
<RewriteControls
onGo={(instruction) => { void handleRewrite(instruction); }}
isProcessing={isProcessing}
hasEmail={!!currentVersion}
/>,
0,
),
suggestions:
sessionId && (currentVersion || pastedEmail)
? wrap(
'suggestions',
<Lightbulb size={14} />,
<SuggestionsPanel
key={suggestionsRefreshKey}
sessionId={sessionId}
keywords={extractKeywords(currentVersion || pastedEmail)}
onReferenceAdded={() => setSuggestionsRefreshKey((k) => k + 1)}
/>,
0,
)
: undefined,
references: sessionId
? wrap(
'references',
<BookOpen size={14} />,
<ReferencePanel
sessionId={sessionId}
onReferencesChange={(_refs: Reference[], getCtx: () => string) => {
getContextStringRef.current = getCtx;
}}
/>,
0,
)
: undefined,
versions: wrap(
'versions',
<History size={14} />,
<VersionTimeline
versions={[
...(pastedEmail ? [{
id: 'orig-main',
versionNumber: 0,
content: pastedEmail,
label: 'Original (pasted)',
tone: 'original',
wordCount: countWords(pastedEmail),
paragraphCount: countParagraphs(pastedEmail),
timestamp: originalVersions[0]?.timestamp || new Date(0),
}] : []),
...originalVersions
.filter((o) => o.content !== pastedEmail)
.map((o, i) => ({
id: `orig-${o.id}`,
versionNumber: 0,
content: o.content,
label: o.label || `Original v0.${i + 2}`,
tone: 'original',
wordCount: countWords(o.content),
paragraphCount: countParagraphs(o.content),
timestamp: o.timestamp,
})),
...versions,
]}
activeVersionId={activeVersionId}
onSelectVersion={handleSelectVersion}
onCompare={handleCompare}
onExportCompose={handleExportCompose}
onToggleFavorite={handleToggleFavorite}
onAddToReferences={(v, scope) => { void handleAddVersionToReferences(v, scope); }}
/>,
),
saved: wrap(
'saved',
<FolderClock size={14} />,
<SavedAnalyses
onOpen={(sid) => {
try {
sessionStorage.setItem(LOAD_SESSION_KEY, sid);
window.location.reload();
} catch {
addToast('Unable to load that analysis', 'error');
}
}}
/>,
0,
),
search: wrap(
'search',
<Search size={14} />,
<EmailSearchPanel
onOpenSession={(sid) => {
try {
sessionStorage.setItem(LOAD_SESSION_KEY, sid);
window.location.reload();
} catch {
addToast('Unable to load that session', 'error');
}
}}
/>,
0,
),
sdccEmails: wrap(
'sdccEmails',
<Inbox size={14} />,
<SdccEmailsPanel
onLoadIntoAnalyzer={({ subject, body }) => {
setPastedEmail(body);
setCurrentVersion(body);
addToast(`Loaded "${subject.slice(0, 40)}" — ready to analyze`, 'success');
}}
/>,
0,
),
subjects: wrap(
'subjects',
<Sparkles size={14} />,
<SubjectLineMakerPanel
sessionId={sessionId}
versionId={activeVersionId}
emailBody={currentVersion || pastedEmail}
/>,
0,
),
};
return (
<LayoutGrid
layout={layout}
onLayoutChange={setLayout}
panelRenderers={renderers as Record<string, PanelRenderer>}
onReset={() => setLayout(DEFAULT_LAYOUT_STATE)}
rowHeight={40}
/>
);
})()}
{/* ── Responsive styles ────────────────────────────────────────────── */}
<style>{`
@media (max-width: 900px) {
.email-analyzer-panels {
flex-direction: column !important;
}
.email-analyzer-panels > div {
flex: 1 1 auto !important;
}
}
.email-preview-content p {
margin: 0 0 0.75em;
}
.email-preview-content p:last-child {
margin-bottom: 0;
}
.email-preview-content a {
color: var(--color-primary);
text-decoration: underline;
}
.email-preview-content ul, .email-preview-content ol {
padding-left: 1.5rem;
margin: 0 0 0.75em;
}
.email-preview-content strong {
font-weight: 600;
}
`}</style>
</div>
);
}
/* ─── SaveStatusPill ─────────────────────────────────────────────────────── */
function formatRelativeAgo(ts: number): string {
const secs = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (secs < 10) return 'just now';
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return new Date(ts).toLocaleDateString();
}
function SaveStatusPill({ isSaving, lastSavedAt }: { isSaving: boolean; lastSavedAt: number | null }) {
const label = isSaving
? 'Saving…'
: lastSavedAt
? `Saved ${formatRelativeAgo(lastSavedAt)}`
: 'Not saved';
const color = isSaving
? 'var(--color-text-secondary)'
: lastSavedAt
? 'var(--color-success)'
: 'var(--color-text-muted)';
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '3px 10px',
borderRadius: 9999,
fontSize: 12,
color,
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}
title={lastSavedAt ? new Date(lastSavedAt).toLocaleString() : undefined}
>
{isSaving ? <Loader2 size={12} className="spinner" /> : <CheckCircle2 size={12} />}
{label}
</span>
);
}
/* ─── StatBadge helper ───────────────────────────────────────────────────── */
function StatBadge({ label, value }: { label: string; value: string }) {
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '3px 10px',
borderRadius: 9999,
fontSize: 12,
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}
>
<span style={{ color: 'var(--color-text-muted)', fontWeight: 500 }}>
{label}:
</span>
<span
style={{
color: 'var(--color-text)',
fontWeight: 600,
textTransform: 'capitalize',
}}
>
{value}
</span>
</div>
);
}