[eric] split: pull MCP result cards out of ToolCallBubble

This commit is contained in:
ciregenz
2026-05-23 05:20:52 -07:00
parent 0731e2b293
commit 7802c0abf8
8 changed files with 572 additions and 0 deletions
@@ -0,0 +1,99 @@
import React from 'react';
import Box from '@mui/material/Box';
import EventIcon from '@mui/icons-material/Event';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCardColors } from './cardColors';
import { formatTimestamp } from './mcpCardHelpers';
export const CalendarCard: React.FC<{ data: Record<string, any>; hideHeader?: boolean }> = ({ data, hideHeader }) => {
const c = useClaudeTokens();
const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_DIM, TC_SUCCESS } = useCardColors();
const items: any[] = data.items || (Array.isArray(data) ? data : []);
const single = !items.length ? data : null;
if (single && (single.summary || single.start)) {
const start = single.start?.dateTime || single.start?.date || single.start || '';
const end = single.end?.dateTime || single.end?.date || single.end || '';
return (
<Box sx={{
...(hideHeader
? { overflow: 'hidden' }
: { bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, overflow: 'hidden' }),
}}>
{!hideHeader && (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.75,
px: 1.5, py: 0.85, borderBottom: `1px solid ${TC_BORDER}`,
}}>
<EventIcon sx={{ fontSize: 14, color: TC_SUCCESS, opacity: 0.8 }} />
<span style={{ color: TC_HEADING, fontSize: '0.78rem', fontWeight: 600, fontFamily: c.font.sans }}>
{single.summary || '(no title)'}
</span>
</Box>
)}
<Box sx={{ px: 1.5, py: 1, display: 'flex', flexDirection: 'column', gap: 0.3 }}>
{start && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Start</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{formatTimestamp(start)}</span>
</Box>
)}
{end && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>End</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{formatTimestamp(end)}</span>
</Box>
)}
{single.location && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Where</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{single.location}</span>
</Box>
)}
{single.description && (
<Box sx={{ mt: 0.3, pt: 0.5, borderTop: `1px solid ${TC_BORDER}` }}>
<pre style={{
margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: c.font.sans, fontSize: '0.68rem', lineHeight: 1.5,
color: TC_BODY,
}}>
{single.description.slice(0, 300)}
{single.description.length > 300 ? '…' : ''}
</pre>
</Box>
)}
</Box>
</Box>
);
}
if (items.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, p: 1.5, pt: 1 }}>
{items.slice(0, 6).map((item: any, i: number) => (
<Box key={i} sx={{
bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`,
borderRadius: 1.5, px: 1.25, py: 0.75,
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 1,
transition: 'background-color 0.15s',
'&:hover': { bgcolor: TC_HOVER },
}}>
<span style={{ color: TC_HEADING, fontSize: '0.72rem', fontWeight: 500, fontFamily: c.font.sans }}>
{item.summary || '(no title)'}
</span>
<span style={{ color: TC_DIM, fontSize: '0.6rem', flexShrink: 0, fontFamily: c.font.mono }}>
{formatTimestamp(item.start?.dateTime || item.start?.date || item.start)}
</span>
</Box>
))}
{items.length > 6 && (
<span style={{ color: TC_DIM, fontSize: '0.64rem', fontStyle: 'italic', textAlign: 'center', display: 'block' }}>
+{items.length - 6} more
</span>
)}
</Box>
);
}
return null;
};
@@ -0,0 +1,56 @@
import React from 'react';
import Box from '@mui/material/Box';
import FolderIcon from '@mui/icons-material/Folder';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCardColors } from './cardColors';
export const DriveCard: React.FC<{ data: Record<string, any> }> = ({ data }) => {
const c = useClaudeTokens();
const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_DIM, TC_WARNING } = useCardColors();
const files: any[] = data.files || (Array.isArray(data) ? data : []);
const single = !files.length && data.name ? data : null;
if (single) {
return (
<Box sx={{
bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`,
borderRadius: 1.5, mx: 1.5, my: 1, px: 1.25, py: 0.85, display: 'flex', alignItems: 'center', gap: 0.75,
}}>
<FolderIcon sx={{ fontSize: 16, color: TC_WARNING, opacity: 0.7 }} />
<Box>
<span style={{ color: TC_HEADING, fontSize: '0.73rem', fontWeight: 500, display: 'block', fontFamily: c.font.sans }}>
{single.name}
</span>
{single.mimeType && (
<span style={{ color: TC_DIM, fontSize: '0.6rem', fontFamily: c.font.mono }}>{single.mimeType}</span>
)}
</Box>
</Box>
);
}
if (files.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1.5, pt: 1 }}>
{files.slice(0, 8).map((f: any, i: number) => (
<Box key={i} sx={{
bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`,
borderRadius: 1.5, px: 1.25, py: 0.6, display: 'flex', alignItems: 'center', gap: 0.75,
transition: 'background-color 0.15s',
'&:hover': { bgcolor: TC_HOVER },
}}>
<FolderIcon sx={{ fontSize: 13, color: TC_WARNING, opacity: 0.5 }} />
<span style={{ color: TC_HEADING, fontSize: '0.7rem', fontFamily: c.font.sans }}>{f.name || f.id}</span>
{f.mimeType && (
<span style={{ color: TC_DIM, fontSize: '0.58rem', flexShrink: 0, fontFamily: c.font.mono }}>
{f.mimeType.split('/').pop()}
</span>
)}
</Box>
))}
</Box>
);
}
return null;
};
@@ -0,0 +1,51 @@
import React from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCardColors } from './cardColors';
export const GenericMcpCard: React.FC<{ data: Record<string, any> }> = ({ data }) => {
const c = useClaudeTokens();
const { TC_DIM, TC_BODY } = useCardColors();
const entries = Object.entries(data).filter(([, v]) => v != null);
if (entries.length === 0)
return <span style={{ color: TC_DIM, fontStyle: 'italic', fontSize: '0.7rem', padding: '8px 12px', display: 'block' }}>(empty response)</span>;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.3, px: 1.5, py: 1 }}>
{entries.slice(0, 20).map(([key, val], i) => {
const isLong = typeof val === 'string' && val.length > 100;
const isObj = typeof val === 'object';
return (
<Box key={i} sx={{ fontSize: '0.7rem', display: 'flex', gap: 0.75, lineHeight: 1.5 }}>
<span style={{ color: TC_DIM, minWidth: 72, flexShrink: 0, fontWeight: 500, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.03em', paddingTop: 1 }}>
{key}
</span>
{isObj ? (
<pre style={{
margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
color: TC_BODY, fontFamily: c.font.mono, fontSize: '0.68rem',
}}>
{JSON.stringify(val, null, 2).slice(0, 500)}
</pre>
) : isLong ? (
<pre style={{
margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
color: TC_BODY, fontFamily: c.font.sans, fontSize: '0.68rem',
}}>
{String(val).slice(0, 500)}{String(val).length > 500 ? '…' : ''}
</pre>
) : (
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{String(val)}</span>
)}
</Box>
);
})}
{entries.length > 20 && (
<span style={{ color: TC_DIM, fontSize: '0.62rem', fontStyle: 'italic' }}>
+{entries.length - 20} more fields
</span>
)}
</Box>
);
};
@@ -0,0 +1,196 @@
import React from 'react';
import Box from '@mui/material/Box';
import EmailIcon from '@mui/icons-material/Email';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import SendIcon from '@mui/icons-material/Send';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCardColors } from './cardColors';
import { extractEmailFields } from './mcpCardHelpers';
export const GmailCard: React.FC<{ data: Record<string, any>; action: string; hideSubjectHeader?: boolean }> = ({ data, action, hideSubjectHeader }) => {
const c = useClaudeTokens();
const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_MUTED, TC_DIM, TC_ACCENT, TC_SUCCESS, TC_WARNING } = useCardColors();
const email = extractEmailFields(data);
const labels = data.labelIds || data.labels || [];
const attachments = data.attachments || [];
const isSend = action.includes('send');
const isSearch = action.includes('search') || action.includes('list');
const messages: any[] = data.messages || (isSearch && data.results ? data.results : []);
if (messages.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, p: 1.5, pt: 1 }}>
{messages.slice(0, 5).map((msg: any, i: number) => {
const m = extractEmailFields(msg);
return (
<Box key={i} sx={{
bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5,
px: 1.25, py: 1, display: 'flex', flexDirection: 'column', gap: 0.4,
transition: 'background-color 0.15s',
'&:hover': { bgcolor: TC_HOVER },
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 1 }}>
<span style={{ color: TC_HEADING, fontSize: '0.74rem', fontWeight: 600, fontFamily: c.font.sans }}>
{m.subject}
</span>
{m.date && (
<span style={{ color: TC_DIM, fontSize: '0.6rem', flexShrink: 0, fontFamily: c.font.mono }}>
{m.date}
</span>
)}
</Box>
{m.from && (
<span style={{ color: TC_MUTED, fontSize: '0.68rem', fontFamily: c.font.sans }}>
{m.from}
</span>
)}
{(m.snippet || m.bodyPreview) && (
<span style={{ color: TC_BODY, fontSize: '0.68rem', lineHeight: 1.45, fontFamily: c.font.sans }}>
{(m.snippet || m.bodyPreview).slice(0, 120)}
{(m.snippet || m.bodyPreview).length > 120 ? '…' : ''}
</span>
)}
</Box>
);
})}
{messages.length > 5 && (
<span style={{ color: TC_DIM, fontSize: '0.66rem', fontStyle: 'italic', textAlign: 'center', display: 'block' }}>
+{messages.length - 5} more
</span>
)}
</Box>
);
}
return (
<Box sx={{
...(hideSubjectHeader
? { overflow: 'hidden' }
: { bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, overflow: 'hidden' }),
}}>
{!hideSubjectHeader && (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 0.75,
px: 1.5, py: 0.85,
borderBottom: `1px solid ${TC_BORDER}`,
}}>
{isSend ? (
<SendIcon sx={{ fontSize: 14, color: TC_SUCCESS, opacity: 0.8 }} />
) : (
<EmailIcon sx={{ fontSize: 14, color: TC_ACCENT, opacity: 0.8 }} />
)}
<span style={{ color: TC_HEADING, fontSize: '0.78rem', fontWeight: 600, flex: 1, fontFamily: c.font.sans }}>
{email.subject}
</span>
</Box>
)}
<Box sx={{ px: 1.5, py: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{(email.from || email.to || email.date) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.3 }}>
{email.from && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>From</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.from}</span>
</Box>
)}
{email.to && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>To</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.to}</span>
</Box>
)}
{email.date && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Date</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.date}</span>
</Box>
)}
</Box>
)}
{labels.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.4, flexWrap: 'wrap', mt: 0.15 }}>
{labels.map((l: string, i: number) => (
<Box key={i} sx={{
display: 'inline-flex', alignItems: 'center',
bgcolor: `${TC_ACCENT}18`, borderRadius: 0.75,
px: 0.6, py: 0.1,
}}>
<span style={{ fontSize: '0.56rem', color: TC_ACCENT, fontFamily: c.font.mono, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{l}</span>
</Box>
))}
</Box>
)}
{(email.snippet || email.bodyPreview) && (
<Box sx={{
mt: 0.25, pt: 0.5, borderTop: `1px solid ${TC_BORDER}`,
color: TC_BODY,
fontFamily: c.font.sans,
fontSize: '0.7rem',
lineHeight: 1.6,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } },
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: TC_HEADING, fontFamily: c.font.sans,
mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '0.82rem' }, '& h2': { fontSize: '0.78rem' },
'& h3': { fontSize: '0.74rem' }, '& h4, & h5, & h6': { fontSize: '0.7rem' },
'& strong': { color: TC_HEADING, fontWeight: 600 },
'& em': { fontStyle: 'italic' },
'& a': { color: TC_ACCENT, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } },
'& ul, & ol': { pl: 2, mb: 0.75, mt: 0 },
'& li': { mb: 0.2 },
'& blockquote': {
m: 0, mb: 0.75, pl: 1, ml: 0,
borderLeft: `2px solid ${TC_BORDER}`,
color: TC_MUTED, fontStyle: 'italic',
},
'& code': {
bgcolor: `${TC_BORDER}`, px: 0.4, py: 0.15,
borderRadius: 0.5, fontSize: '0.65rem', fontFamily: c.font.mono,
},
'& pre': {
bgcolor: `${TC_BORDER}`, borderRadius: 1, p: 1,
overflow: 'auto', fontSize: '0.65rem', fontFamily: c.font.mono,
m: 0, mb: 0.75,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& hr': { border: 'none', borderTop: `1px solid ${TC_BORDER}`, my: 0.75 },
'& img': { maxWidth: '100%', borderRadius: 1 },
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ a: ({ children, ...props }) => <a {...props}>{children}</a> }}
>
{email.bodyPreview || email.snippet}
</ReactMarkdown>
</Box>
)}
{attachments.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.4, flexWrap: 'wrap', mt: 0.2 }}>
{attachments.map((a: any, i: number) => (
<Box key={i} sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
bgcolor: `${TC_WARNING}15`, borderRadius: 0.75,
px: 0.6, py: 0.1,
}}>
<AttachFileIcon sx={{ fontSize: 9, color: TC_WARNING, opacity: 0.7 }} />
<span style={{ fontSize: '0.58rem', color: TC_WARNING, fontFamily: c.font.mono }}>
{a.filename || a.name || 'attachment'}
</span>
</Box>
))}
</Box>
)}
</Box>
</Box>
);
};
@@ -0,0 +1,36 @@
import React from 'react';
export const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 14 }) => {
if (service === 'gmail') {
return (
<svg width={size} height={size} viewBox="0 0 24 10" fill="none" style={{ flexShrink: 0 , marginBottom: '6px'}}>
<path d="M2 6.5V18a2 2 0 002 2h1V8l-3-1.5z" fill="#4285F4"/>
<path d="M22 6.5V18a2 2 0 01-2 2h-1V8l3-1.5z" fill="#34A853"/>
<path d="M5 8v12h2V10.2L12 14l5-3.8V20h2V8l-7 5.25L5 8z" fill="#EA4335"/>
<path d="M4 4a2 2 0 00-2 2.5L5 8V4H4z" fill="#4285F4"/>
<path d="M20 4a2 2 0 012 2.5L19 8V4h1z" fill="#FBBC04"/>
<path d="M19 4H5v4l7 5.25L19 8V4z" fill="#EA4335"/>
</svg>
);
}
if (service === 'calendar') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ flexShrink: 0 }}>
<rect x="3" y="3" width="18" height="18" rx="2" fill="#fff" stroke="#4285F4" strokeWidth="1.5"/>
<rect x="3" y="3" width="18" height="6" rx="2" fill="#4285F4"/>
<text x="12" y="17.5" textAnchor="middle" fontSize="9" fontWeight="700" fill="#4285F4" fontFamily="sans-serif">31</text>
</svg>
);
}
if (service === 'drive' || service === 'sheets') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ flexShrink: 0 }}>
<path d="M8 2l7 12H1L8 2z" fill="#FBBC04"/>
<path d="M15 2l7 12h-7L8 2h7z" fill="#34A853"/>
<path d="M1 14h14l-3.5 6H4.5L1 14z" fill="#4285F4"/>
<path d="M15 14h7l-3.5 6h-7L15 14z" fill="#EA4335"/>
</svg>
);
}
return null;
};
@@ -0,0 +1,57 @@
import React from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ParsedMcpResult } from './toolResultParsing';
import { useTermColors } from './toolColorize';
import { useCardColors } from './cardColors';
import { GmailCard } from './GmailCard';
import { CalendarCard } from './CalendarCard';
import { DriveCard } from './DriveCard';
import { GenericMcpCard } from './GenericMcpCard';
export const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> = ({ parsed, compact }) => {
const c = useClaudeTokens();
const tc = useTermColors();
const { TC_BODY } = useCardColors();
const { service, action, data, rawText } = parsed;
if (data.error || data.is_error) {
return (
<Box sx={{ p: 1 }}>
<span style={{ color: tc.STDERR_COLOR, fontSize: '0.73rem' }}>
{data.error || data.message || JSON.stringify(data, null, 2)}
</span>
</Box>
);
}
if (service === 'gmail') return <GmailCard data={data} action={action} hideSubjectHeader={compact} />;
if (service === 'calendar') return <CalendarCard data={data} hideHeader={compact} />;
if (service === 'drive' || service === 'sheets') return <DriveCard data={data} />;
// Plain-text MCP results: render rawText capped at 6000 chars (model still sees full payload).
const hasData = data && Object.keys(data).length > 0;
if (!hasData && rawText && rawText.trim()) {
const DISPLAY_CAP = 6000;
const preview = rawText.length > DISPLAY_CAP
? rawText.slice(0, DISPLAY_CAP) + `\n… (${rawText.length - DISPLAY_CAP} more chars; model received full output)`
: rawText;
return (
<Box sx={{ px: 1.5, py: 1 }}>
<span style={{
color: TC_BODY,
fontSize: '0.72rem',
fontFamily: c.font.sans,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
display: 'block',
lineHeight: 1.55,
}}>
{preview}
</span>
</Box>
);
}
return <GenericMcpCard data={data} />;
};
@@ -0,0 +1,45 @@
import { useThemeMode } from '@/shared/styles/ThemeContext';
export interface CardColors {
TC_BG: string;
TC_BORDER: string;
TC_HOVER: string;
TC_HEADING: string;
TC_BODY: string;
TC_MUTED: string;
TC_DIM: string;
TC_ACCENT: string;
TC_SUCCESS: string;
TC_WARNING: string;
}
const darkCardColors: CardColors = {
TC_BG: 'rgba(255,255,255,0.03)',
TC_BORDER: 'rgba(255,255,255,0.06)',
TC_HOVER: 'rgba(255,255,255,0.05)',
TC_HEADING: '#C2C0B6',
TC_BODY: '#9C9A92',
TC_MUTED: '#85837C',
TC_DIM: 'rgba(156,154,146,0.5)',
TC_ACCENT: '#c4633a',
TC_SUCCESS: '#7AB948',
TC_WARNING: '#D1A041',
};
const lightCardColors: CardColors = {
TC_BG: 'rgba(0,0,0,0.03)',
TC_BORDER: 'rgba(0,0,0,0.08)',
TC_HOVER: 'rgba(0,0,0,0.05)',
TC_HEADING: '#3D3D3A',
TC_BODY: '#555550',
TC_MUTED: '#73726C',
TC_DIM: 'rgba(115,114,108,0.5)',
TC_ACCENT: '#ae5630',
TC_SUCCESS: '#265B19',
TC_WARNING: '#805C1F',
};
export function useCardColors(): CardColors {
const { mode } = useThemeMode();
return mode === 'dark' ? darkCardColors : lightCardColors;
}
@@ -0,0 +1,32 @@
import { getGmailHeader } from './mcpToolName';
export function formatTimestamp(ts: string | number | undefined): string {
if (!ts) return '';
try {
const d = typeof ts === 'number' ? new Date(ts) : new Date(ts);
if (isNaN(d.getTime())) return String(ts);
return d.toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
});
} catch { return String(ts); }
}
export function stripHtml(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
export function extractEmailFields(msg: any) {
const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)';
const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || '';
const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || '';
const rawDate = msg.date || msg.internalDate || msg.receivedAt || getGmailHeader(msg, 'Date') || '';
const date = formatTimestamp(rawDate);
const snippet = msg.snippet || '';
const body = msg.body || msg.text || msg.textBody || '';
const htmlBody = msg.htmlBody || msg.html || '';
const bodyPreview = body || (htmlBody ? stripHtml(htmlBody) : '');
return { subject, from, to, date, snippet, bodyPreview };
}