// Tommy inbox — pending approvals, sent history, intent breakdown.
// Wired to /tommy/pending-approvals, /tommy/messages, /tommy/stats.
function TommyStatPill({ label, value, sub }) {
return (
— AI SMS worker
Tommy
Drafts, approvals, and learned responses · model {config.data?.model || 'claude-sonnet'}
onNavigate({view:'system'})}>Config →
{[
{ id:'pending', label:`Pending · ${list.length}` },
{ id:'history', label:'Sent · recent' },
{ id:'training', label:'Training' },
{ id:'config', label:'Config' },
].map(t => (
setTab(t.id)}
style={{
background:'transparent', border:'none',
padding:'10px 14px', fontSize:13, fontWeight:500,
color: tab===t.id ? 'var(--ink)' : 'var(--muted)',
borderBottom: tab===t.id ? '2px solid var(--ink)' : '2px solid transparent',
marginBottom:-1,
cursor:'pointer',
}}
>{t.label}
))}
{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 && (
)}
— 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 || '—'}
onNavigate({view:'lead', id:a.contact_id})}>Open thread
dismiss(a)} title="Dismiss without sending or learning from this">Dismiss
reject(a)}>Reject
approve(a)} disabled={a.status === 'context_requested'}>Approve
);
})}
)}
{tab === 'history' && (
— Sent messages · recent
{msgs.length} shown
{msgs.length === 0 && !history.loading &&
No messages yet.
}
{msgs.length > 0 && (
Contact
Direction
Message
Sent
{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})}>
{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
{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' && (
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 && (
<>
setEditing(true)}>Edit
Delete
>
)}
{editing && (
<>
Cancel
{saving ? 'Saving…' : 'Save'}
>
)}
— 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 ? (
);
}
Object.assign(window, { TommyView });