// Primus IQ — Chat screen components
// HITL approval card: the agent proposed a report plan (template + outline);
// the user picks/edits and approves, edits, or rejects before generation starts.
const ReportPlanCard = ({ plan, onDecide }) => {
const args = plan.args || {};
const [templates, setTemplates] = React.useState([]);
const [template, setTemplate] = React.useState(args.template_id || 'primus_research');
React.useEffect(() => {
PrimusAPI.listReportTemplates()
.then(r => setTemplates((r && r.templates) || []))
.catch(() => setTemplates([
{ id: 'primus_research', name: 'Primus Research' },
{ id: 'default', name: 'Default Report' },
]));
}, []);
// If the proposed template_id isn't a real bundle, snap to primus_research once templates load.
React.useEffect(() => {
if (templates.length && !templates.some(t => t.id === template)) {
setTemplate(templates.some(t => t.id === 'primus_research') ? 'primus_research' : templates[0].id);
}
}, [templates]);
const [outlineText, setOutlineText] = React.useState((args.outline || []).join('\n'));
const buildOutline = () => outlineText.split('\n').map(s => s.trim()).filter(Boolean);
const approve = () => {
const outline = buildOutline();
const changed = template !== args.template_id ||
outline.join('|') !== (args.outline || []).join('|');
if (changed) {
// Edit: replace the tool-call args entirely (must include all fields).
// Spread the original args first so agent-set fields the card doesn't show
// (e.g. review_mode) are preserved, then override the two the user edits.
onDecide({
type: 'edit',
edited_action: {
name: plan.action,
args: { ...args, template_id: template, outline },
},
});
} else {
onDecide({ type: 'approve' });
}
};
return (
📄 Report plan — your approval needed
{args.title && (
{args.title}
)}
);
};
const ChatMsgBubble = ({ msg }) => {
const isHuman = msg.role === 'human';
const aiHtml = React.useMemo(() => {
if (isHuman) return null;
try {
return DOMPurify.sanitize(marked.parse(msg.content || ''));
} catch (_) {
return msg.content || '';
}
}, [isHuman, msg.content]);
return (
);
};
const ChatThinking = ({ message }) => (
P
{message || 'Thinking…'}
);
const ChatComposer = ({ onSend, disabled, onAttach, attachedFile, onRemoveAttach, attachUploading, attachError, onClearAttachError }) => {
const [val, setVal] = React.useState('');
const [focused, setFocused] = React.useState(false);
const fileRef = React.useRef(null);
const submit = () => {
const t = val.trim();
if (!t || disabled) return;
setVal('');
onSend(t);
};
const onKD = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } };
return (
{/* Hidden file input */}
{ const f = e.target.files?.[0]; if (f) onAttach?.(f); e.target.value = ''; }}
/>
{/* Attached file chip */}
{attachedFile && (
{attachUploading ? 'Uploading…' : attachedFile.name}
{!attachUploading && (
)}
)}
{/* Attach rejection (too big / unsupported) */}
{attachError && (
{attachError}
)}
Enter to send · Shift + Enter for new line
);
};
const ScreenNewChat = ({ setRoute, openAddSources, currentUser, maintenance = false,
sessions = [], activeId = null, setActiveId, onSessionsChanged, onSessionCreated,
initialMessage, initialProjectId, initialDoc, onInitialConsumed, projects = [], onOpenProject }) => {
const [messages, setMessages] = React.useState([]);
const [streaming, setStreaming] = React.useState(false);
const [progressMsg, setProgressMsg] = React.useState('');
const [streamingText, setStreamingText] = React.useState('');
const [attachedFile, setAttachedFile] = React.useState(null);
const [attachUploading, setAttachUploading] = React.useState(false);
const [attachError, setAttachError] = React.useState(null);
const [pendingApproval, setPendingApproval] = React.useState(null); // HITL report-plan card
const abortRef = React.useRef(null);
const bottomRef = React.useRef(null);
const scrollRef = React.useRef(null);
const stickRef = React.useRef(true); // follow streaming unless the user scrolled up
const justCreatedRef = React.useRef(false);
const initialSentRef = React.useRef(null); // tracks which initialMessage was auto-sent
// Stream watchdog — if the SSE goes silent this long (e.g. the upstream model is
// 503/timing out), stop waiting and surface a clear error instead of an endless
// spinner. Reset on every event; cleared on any terminal event.
const watchdogRef = React.useRef(null);
const WATCHDOG_MS = 90000;
const clearWatchdog = () => { if (watchdogRef.current) { clearTimeout(watchdogRef.current); watchdogRef.current = null; } };
const armWatchdog = () => {
clearWatchdog();
watchdogRef.current = setTimeout(() => {
watchdogRef.current = null;
if (abortRef.current) { try { abortRef.current(); } catch (_) {} abortRef.current = null; }
setStreaming(false); setStreamingText('');
setMessages(prev => [...prev, {
message_id: `err-${Date.now()}`, role: 'ai',
content: 'The model is taking longer than expected and may be temporarily unavailable. Please try again in a moment.',
created_at: new Date().toISOString(),
}]);
}, WATCHDOG_MS);
};
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
const firstName = currentUser?.full_name?.split(' ')?.[0] || 'there';
React.useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streaming]);
// Follow streaming tokens as they arrive — unless the user scrolled up to re-read
// mid-stream. "Following" is tracked by onScroll (real scrolls only), not measured
// here: by the time this runs the new token has already grown the content, which
// would make an in-effect nearBottom check read as "far from bottom" and get stuck.
React.useEffect(() => {
const el = scrollRef.current;
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
}, [streamingText]);
React.useEffect(() => {
if (justCreatedRef.current) { justCreatedRef.current = false; return; }
if (abortRef.current) { abortRef.current(); abortRef.current = null; }
clearWatchdog();
setStreaming(false);
if (!activeId) { setMessages([]); return; }
PrimusAPI.getChatMessages(activeId)
.then(data => setMessages(data || []))
.catch(() => setMessages([]));
}, [activeId]);
const handleAttach = async (file) => {
setAttachError(null);
setAttachedFile(file);
// If we already have a session, upload immediately.
// If not, we upload after the session is created on first send.
if (activeId) {
setAttachUploading(true);
try {
await PrimusAPI.attachChatDocument(activeId, file);
} catch (ex) {
setAttachedFile(null);
setAttachError(ex.message || 'Could not attach this file.');
} finally {
setAttachUploading(false);
}
}
};
const handleSend = async (text, docOverride) => {
// docOverride lets the project-composer launch carry a file straight into the
// first send (state set + immediate send would race the closure).
const fileToAttach = docOverride || attachedFile;
let sessionId = activeId;
if (!sessionId) {
try {
const sess = await PrimusAPI.createChatSession(initialProjectId ? { project_id: initialProjectId } : {});
sessionId = sess.session_id;
justCreatedRef.current = true;
setActiveId && setActiveId(sessionId);
// Show it in the sidebar NOW (top, carries project_id). Don't refetch here —
// a message-less session sorts to the bottom; the post-reply refresh reconciles
// it to the top with its real title once last_message_at is set.
onSessionCreated && onSessionCreated(sess);
} catch (ex) { console.error('Failed to create session:', ex); return; }
}
// Show the user's message + loader immediately, so feedback starts at "send" and
// covers the file-save phase too (not only after the AI starts).
const humanMsg = {
message_id: `tmp-${Date.now()}`, role: 'human',
content: text, created_at: new Date().toISOString(),
};
setMessages(prev => [...prev, humanMsg]);
setStreaming(true);
setProgressMsg(fileToAttach ? 'Saving your file…' : 'Routing your query…');
// If a file was attached but not yet uploaded, save it now (loader already showing).
// If it's rejected (too big / unsupported), surface the message and abort the send —
// summarizing a doc that didn't attach makes no sense.
if (fileToAttach && !attachUploading) {
try {
setAttachUploading(true);
await PrimusAPI.attachChatDocument(sessionId, fileToAttach);
setAttachedFile(null);
setProgressMsg('Routing your query…');
} catch (ex) {
setStreaming(false);
setMessages(prev => prev.filter(m => m.message_id !== humanMsg.message_id));
setAttachError(ex.message || 'Could not attach this file.');
setAttachedFile(null);
setAttachUploading(false);
return;
} finally {
setAttachUploading(false);
}
}
abortRef.current = PrimusAPI.streamChatMessage(sessionId, text, makeStreamHandler());
armWatchdog(); // catch a total hang (no events ever arrive)
};
// Build a fresh SSE event handler (own tokenBuffer) — reused by both send and
// resume so the streaming behaviour stays identical.
const makeStreamHandler = () => {
let tokenBuffer = '';
return (ev) => {
armWatchdog(); // any activity resets the silence timer
if (ev.type === 'progress') {
setProgressMsg(ev.message || 'Thinking…');
} else if (ev.type === 'token') {
tokenBuffer += ev.content || '';
setStreamingText(tokenBuffer);
} else if (ev.type === 'interrupt') {
// HITL: the agent proposed a report plan — pause and show the approval card.
clearWatchdog(); // intentional pause for user input, not a stall
setStreaming(false);
setStreamingText('');
tokenBuffer = '';
setPendingApproval({
action: ev.action,
args: ev.args || {}, // { title, brief, template_id, outline }
allowed: ev.allowed_decisions || ['approve', 'edit', 'reject'],
});
} else if (ev.type === 'message') {
const content = ev.content || '';
clearWatchdog();
setStreaming(false);
setStreamingText('');
tokenBuffer = '';
// Show immediately, then replace with authoritative DB state
if (content) setMessages(prev => [...prev, { message_id: `ai-${Date.now()}`, role: 'ai', content, created_at: new Date().toISOString() }]);
PrimusAPI.getChatMessages(sessionId)
.then(data => { if (data && data.length) setMessages(data); })
.catch(() => {});
onSessionsChanged && onSessionsChanged(); // reconcile title/order once the first reply lands
} else if (ev.type === 'done') {
const finalContent = tokenBuffer;
clearWatchdog();
setStreamingText('');
tokenBuffer = '';
setStreaming(false);
// Show immediately, then replace with authoritative DB state
if (finalContent) setMessages(prev => [...prev, { message_id: `ai-${Date.now()}`, role: 'ai', content: finalContent, created_at: new Date().toISOString() }]);
PrimusAPI.getChatMessages(sessionId)
.then(data => { if (data && data.length) setMessages(data); })
.catch(() => {});
onSessionsChanged && onSessionsChanged();
} else if (ev.type === 'error') {
clearWatchdog();
setStreaming(false);
setStreamingText('');
tokenBuffer = '';
const errMsg = ev.detail || ev.message || 'Please try again.';
setMessages(prev => [...prev, {
message_id: `err-${Date.now()}`, role: 'ai',
content: `Something went wrong: ${errMsg}`,
created_at: new Date().toISOString(),
}]);
}
};
};
// HITL: submit the user's decision on a proposed report plan and stream the rest.
const submitApproval = (decision) => {
setPendingApproval(null);
setStreaming(true);
setProgressMsg('Submitting your decision…');
// activeId is the component-scope session id; `sessionId` only exists as a
// local inside handleSend, so referencing it here threw a ReferenceError
// before the fetch ever fired (no /resume request reached the server).
abortRef.current = PrimusAPI.resumeChatMessage(activeId, decision, makeStreamHandler());
armWatchdog();
};
// Auto-send the first message when arriving from a project composer. handleSend
// creates the session with initialProjectId, so it's project-scoped end-to-end.
React.useEffect(() => {
if (initialMessage && initialSentRef.current !== initialMessage) {
initialSentRef.current = initialMessage;
handleSend(initialMessage, initialDoc); // carry an attached doc from the project composer
onInitialConsumed && onInitialConsumed();
}
}, [initialMessage]);
const activeSession = sessions.find(s => s.session_id === activeId) || null;
const sessionProject = activeSession?.project_id
? projects.find(p => p.id === activeSession.project_id)
: null;
if (activeId) {
return (
{sessionProject && (
<>
›
>
)}
{activeSession?.title || 'Chat'}
{ const el = e.currentTarget; stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120; }}
className="pi-thin-scroll" style={{ flex: 1, overflowY: 'auto', padding: '24px 0 0' }}>
{messages.map((m, i) =>
)}
{streaming && !streamingText &&
}
{streaming && streamingText && (
)}
{pendingApproval && (
)}
setAttachedFile(null)}
attachUploading={attachUploading}
attachError={attachError}
onClearAttachError={() => setAttachError(null)}
/>
);
}
return (
{greeting},{' '}
{firstName}
setAttachedFile(null)}
attachUploading={attachUploading}
attachError={attachError}
onClearAttachError={() => setAttachError(null)}
/>
{maintenance && (
Chat is coming soon — temporarily unavailable.
)}
{[
{ l: 'Create research', icon: 'book' },
{ l: 'Design a framework', icon: 'apps' },
{ l: 'Explore Primus expertise', icon: 'search' },
{ l: 'Help with consulting', icon: 'folder' },
].map(c => (
))}
);
};
const composerIconBtn = {
width: 32, height: 32, borderRadius: 8,
background: 'transparent', border: '1px solid var(--line-2)',
cursor: 'pointer', display: 'grid', placeItems: 'center',
transition: 'all 0.15s',
};
const composerChip = {
height: 32, padding: '0 10px', borderRadius: 8,
background: 'transparent', border: '1px solid var(--line-2)',
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 13, color: 'var(--ink-2)', fontFamily: 'inherit', fontWeight: 500,
};
const chipBtn = {
padding: '8px 14px', borderRadius: 999,
background: 'var(--bg-card)', border: '1px solid var(--line)',
fontSize: 13, color: 'var(--ink-2)', fontFamily: 'inherit', fontWeight: 500,
cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6,
transition: 'all 0.15s',
};
Object.assign(window, {
ChatMsgBubble,
ChatThinking,
ChatComposer,
ScreenNewChat,
composerIconBtn,
composerChip,
chipBtn,
});