// Tommy inbox — pending approvals, sent history, intent breakdown. // Wired to /tommy/pending-approvals, /tommy/messages, /tommy/stats. function TommyStatPill({ label, value, sub }) { return (
{label}
{value}
{sub}
); } function TommyView({ onNavigate }) { const [tab, setTab] = React.useState('pending'); const [topicFilter, setTopicFilter] = React.useState(''); const [trainingSearch, setTrainingSearch] = React.useState(''); const [debouncedTrainingSearch, setDebouncedTrainingSearch] = React.useState(''); React.useEffect(() => { const t = setTimeout(() => setDebouncedTrainingSearch(trainingSearch.trim()), 250); return () => clearTimeout(t); }, [trainingSearch]); const stats = useAsync(() => api.get('/api/v2/tommy/stats'), []); const approvals = useAsync(() => api.get('/api/v2/tommy/pending-approvals'), []); const history = useAsync(() => api.get('/api/v2/tommy/messages?limit=100'), [tab === 'history']); const config = useAsync(() => api.get('/api/v2/system/tommy-config'), []); const training = useAsync(() => { if (tab !== 'training') return Promise.resolve({ items: [], total: 0, topics: [] }); const qs = new URLSearchParams({ limit: '100' }); if (topicFilter) qs.set('topic', topicFilter); if (debouncedTrainingSearch) qs.set('search', debouncedTrainingSearch); return api.get('/api/v2/tommy/learned-responses?' + qs.toString()); }, [tab, topicFilter, debouncedTrainingSearch]); React.useEffect(() => { function onEv() { approvals.reload(); stats.reload(); } window.addEventListener('raw:event', onEv); return () => window.removeEventListener('raw:event', onEv); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const s = stats.data || {}; const list = approvals.data?.approvals || []; const msgs = history.data?.messages || history.data?.items || []; async function approve(a) { const msg = prompt('Send as:', a.tommy_draft) ?? a.tommy_draft; if (!msg) return; try { await api.post('/api/v2/tommy/approve', { approval_id: a.id, message: msg }); approvals.reload(); stats.reload(); } catch (e) { if (isQuietHoursError(e) && confirm(quietHoursPrompt(e, 'approve and send'))) { try { await api.post('/api/v2/tommy/approve', { approval_id: a.id, message: msg, override_quiet_hours: true, }); approvals.reload(); stats.reload(); return; } catch (e2) { alert(friendlyApiError(e2, 'Approve failed')); return; } } alert(friendlyApiError(e, 'Approve failed')); } } async function reject(a) { try { await api.post('/api/v2/tommy/reject', { approval_id: a.id }); approvals.reload(); stats.reload(); } catch (e) { alert(friendlyApiError(e, 'Reject failed')); } } async function dismiss(a) { try { await api.post('/api/v2/tommy/dismiss', { approval_id: a.id }); approvals.reload(); stats.reload(); } catch (e) { alert(friendlyApiError(e, 'Dismiss failed')); } } return (
— AI SMS worker

Tommy

Drafts, approvals, and learned responses · model {config.data?.model || 'claude-sonnet'}
{[ { id:'pending', label:`Pending · ${list.length}` }, { id:'history', label:'Sent · recent' }, { id:'training', label:'Training' }, { id:'config', label:'Config' }, ].map(t => ( ))}
{tab === 'pending' && (
Pending approvals
auto-expires in 24h
{list.length === 0 && !approvals.loading &&
All caught up. Tommy is auto-sending.
} {list.map(a => { const lastInbound = a.inbound_message || ''; return (
{initialsOf(a.contact_name)}
{a.contact_name}
{a.contact_phone || '—'}
{(a.stage || 'new lead').toLowerCase()} {a.is_member && member} {a.hot && hot}
{lastInbound && (
— Inbound
{lastInbound}
)}
— Tommy draft
{a.tommy_draft || (a.status === 'context_requested' ? 'Redrafting with context…' : 'Draft pending…')}
status · {a.status}
waiting · {fmtAgoFrom(a.waiting_since)}
reason · {a.hold_reason || 'needs review'}
source · {a.source || '—'}
); })}
)} {tab === 'history' && (
Sent messages · recent
{msgs.length} shown
{msgs.length === 0 && !history.loading &&
No messages yet.
} {msgs.length > 0 && ( {msgs.map((m, i) => { const isOut = (m.direction || '').toLowerCase() === 'outbound' || m.direction === 'auto' || m.direction === 'approved'; const typeColor = isOut ? 'var(--green)' : 'var(--blue)'; const typeBg = isOut ? 'var(--green-soft)' : 'var(--blue-soft)'; return ( m.contact_id && onNavigate({view:'lead', id:m.contact_id})}> ); })}
Contact Direction Message Sent
{m.contact_name || m.contact_id || '—'} {isOut ? 'outbound' : 'inbound'} {m.message} {fmtAgoFrom(m.sent_at)}
)}
)} {tab === 'training' && (
Training library
{training.data?.total ?? 0} examples · few-shot memory for Tommy
setTrainingSearch(e.target.value)} style={{ flex:1, minWidth:220, height:32, padding:'0 12px', border:'1px solid var(--line)', borderRadius:'var(--radius-sm)', background:'var(--bone-2)', fontSize:13, outline:'none', }} />
{(training.data?.topics || []).map(t => ( ))}
{training.loading &&
Loading training library…
} {!training.loading && (training.data?.items || []).length === 0 && (
No learned responses yet. When staff coach a Tommy draft or send their own reply, it's stored here as a few-shot example.
)} {(training.data?.items || []).map(item => ( training.reload()} /> ))}
)} {tab === 'config' && (
Tommy configuration
MODEL · {config.data?.model || '—'}
MEMORY SOURCE · {config.data?.memory_source || '—'}
TONE · {config.data?.tone || '—'}
ROUTING · {config.data?.contact_routing || '—'}
APPROVAL TRIGGERS:
    {(config.data?.approval_triggers || []).map((t, i) =>
  • {t}
  • )}
)}
); } function TrainingRow({ item, onReload }) { const [editing, setEditing] = React.useState(false); const [saving, setSaving] = React.useState(false); const [staffResponse, setStaffResponse] = React.useState(item.staff_response || ''); const [topic, setTopic] = React.useState(item.topic || ''); React.useEffect(() => { setStaffResponse(item.staff_response || ''); setTopic(item.topic || ''); }, [item.id]); async function save() { if (!staffResponse.trim()) { alert('Staff response cannot be empty.'); return; } setSaving(true); try { await api.patch(`/api/v2/tommy/learned-responses/${item.id}`, { staff_response: staffResponse.trim(), topic: topic.trim() || null, }); setEditing(false); await onReload(); } catch (e) { alert(e.message || 'Save failed'); } finally { setSaving(false); } } async function remove() { if (!confirm('Remove this example from Tommy\'s training library?')) return; try { await api.del(`/api/v2/tommy/learned-responses/${item.id}`); await onReload(); } catch (e) { alert(e.message || 'Delete failed'); } } function cancel() { setStaffResponse(item.staff_response || ''); setTopic(item.topic || ''); setEditing(false); } return (
{editing ? ( setTopic(e.target.value)} style={{ height:22, padding:'0 10px', border:'1px solid var(--line)', borderRadius:11, fontFamily:'var(--mono)', fontSize:11, letterSpacing:'0.04em', background:'#fff', outline:'none', minWidth:120, }} /> ) : ( item.topic ? ( {item.topic} ) : ( untagged ) )} {item.was_edited && ( coached )} {fmtAgoFrom(item.created_at)}
{!editing && ( <> )} {editing && ( <> )}
— Inbound
{item.inbound_message}
{item.tommy_draft && ( <>
— Tommy's original draft
{item.tommy_draft}
)}
— Staff reply {item.was_edited ? '· edited by staff' : '· used as-is'}
{editing ? (