// ==========================================================================
// Chat stream atoms: user message, AI message, skill router pills, file drops
// ==========================================================================
function UserMsg({ children }) {
return (
);
}
function AIMsg({ children, streaming, small }) {
return (
{children}
{streaming && }
);
}
// ==== Skill router pill ====
// stages: "pending" | "thinking" | "done"
//
// Two skills are visually distinct ("moat"): consulting-brand, consulting-data.
// They are rendered with a filled background because they represent Inku's
// defensibility — retrieval across the customer's brand corpus and their
// performance data. Every other skill is a prompt-shaped generator.
// See api.js chat() MOAT comment block for where this is wired on the backend.
const SKILL_LABELS = {
'consulting-brand': 'Consulting brand',
'consulting-data': 'Consulting data',
'document-intelligence': 'Document intelligence',
'customer-research': 'Customer research',
'marketing-psychology': 'Marketing psychology',
'positioning': 'Positioning',
'messaging-architecture': 'Messaging architecture',
'ad-creative': 'Ad creative',
'launch-strategy': 'Launch strategy',
'social-content': 'Social content',
'variant-generation': 'Variant generation',
'performance-hypothesis': 'Performance hypothesis',
'fatigue-detection': 'Fatigue detection',
'brand-context': 'Brand context',
};
const MOAT_SKILLS = new Set(['consulting-brand', 'consulting-data']);
function SkillPill({ skill, stage }) {
const id = skill.id || skill.label;
const label = SKILL_LABELS[id] || skill.label || id;
const isMoat = MOAT_SKILLS.has(id);
// Moat pills lean warmer (accent-warm) when active, and always show filled.
const bg =
stage === 'thinking' ? (isMoat ? 'var(--accent-warm)' : 'var(--moss-glow)')
: stage === 'done' ? (isMoat ? 'var(--moss-deep)' : 'var(--bg-2)')
: (isMoat ? 'var(--card-solid)' : 'transparent');
const color =
stage === 'thinking' ? (isMoat ? '#fff' : 'var(--moss-deep)')
: stage === 'done' ? (isMoat ? 'var(--bg)' : 'var(--ink)')
: (isMoat ? 'var(--accent-warm-2)' : 'var(--ink-mute)');
const border = isMoat
? `1px solid ${stage === 'done' ? 'var(--moss-deep)' : 'var(--accent-warm)'}`
: '1px solid var(--line)';
return (
{isMoat
? ◆
: stage === 'thinking' ? : stage === 'done' ? : }
{isMoat && (stage === 'thinking') && }
{isMoat && (stage === 'done') && }
{label}
);
}
function SkillRouter({ skills, activeIndex }) {
// activeIndex = number of completed skills. skills[activeIndex] is "thinking".
return (
routing
{skills.map((s, i) => (
{i < skills.length - 1 && →}
))}
);
}
// ==== File drop zone (used in composer + flow 1 empty state) ====
function DropZone({ onDrop, children }) {
const [over, setOver] = React.useState(false);
return (
{ e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={e => { e.preventDefault(); setOver(false); onDrop && onDrop(); }}
onClick={() => onDrop && onDrop()}
style={{
border: over ? '1.5px dashed var(--moss-deep)' : '1.5px dashed var(--line)',
background: over ? 'var(--moss-glow)' : 'transparent',
borderRadius: 14,
padding: 28,
textAlign: 'center',
cursor: 'pointer',
transition: 'all .2s',
}}>
{children}
);
}
// ==== Markdown rendering ====
// Assistant text comes back as markdown (bold, lists, tables, code). marked
// + DOMPurify are loaded globally via CDN in index.html. We sanitize with
// DOMPurify, then DOMParser turns the HTML string into inert DOM nodes
// (script tags are not executed by 'text/html' parsing), which we move
// into our ref. No innerHTML.
function Markdown({ text }) {
const ref = React.useRef(null);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
while (node.firstChild) node.removeChild(node.firstChild);
if (!text) return;
if (typeof window.marked === 'undefined' || typeof window.DOMPurify === 'undefined') {
node.textContent = text;
return;
}
const html = window.marked.parse(text, { breaks: true, gfm: true, mangle: false, headerIds: false });
const safe = window.DOMPurify.sanitize(html);
const parsed = new DOMParser().parseFromString(safe, 'text/html');
const frag = document.createDocumentFragment();
while (parsed.body.firstChild) frag.appendChild(parsed.body.firstChild);
node.appendChild(frag);
}, [text]);
return ;
}
Object.assign(window, { UserMsg, AIMsg, SkillPill, SkillRouter, DropZone, Markdown });