import { useCallback, useEffect, useRef, useState } from 'react';
import {
Zap,
X,
Plus,
History,
MessageSquare,
Send,
Trash2,
} from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import type { ChatMessage } from '../../lib/chat-store';
import { streamChat } from '../../lib/chat-api';
import { useNotebookStore } from '../../lib/notebook-store';
import '../../styles/chat-widget.css';
// ── Lightweight markdown ────────────────────────────────
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function renderMarkdown(text: string): string {
let html = escapeHtml(text);
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
return `
${code.trim()}
`;
});
html = html.replace(/\*\*(.+?)\*\*/g, '$1');
html = html.replace(/(?$1');
html = html.replace(/`([^`]+?)`/g, '$1');
html = html.replace(
/\[([^\]]+?)\]\((https?:\/\/[^)]+?)\)/g,
'$1',
);
html = html.replace(/\n/g, '
');
return html;
}
// ── Components ──────────────────────────────────────────
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
return (
{msg.role === 'assistant' ? (
<>
{isStreaming && }
>
) : (
msg.text
)}
);
}
function HistoryView({
onBack,
}: {
onBack: () => void;
}) {
const conversations = useChatStore((s) => s.conversations);
const activeId = useChatStore((s) => s.activeConversationId);
const setActive = useChatStore((s) => s.setActiveConversation);
const deleteConv = useChatStore((s) => s.deleteConversation);
const [pendingDeleteId, setPendingDeleteId] = useState(null);
const pendingTimer = useRef | null>(null);
const handleDelete = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (pendingDeleteId === id) {
// Second click — confirm
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(null);
deleteConv(id);
} else {
// First click — arm
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(id);
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
}
},
[pendingDeleteId, deleteConv],
);
useEffect(() => {
return () => {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
};
}, []);
if (conversations.length === 0) {
return (
);
}
return (
{conversations.map((c) => (
))}
);
}
// ── Main Widget ─────────────────────────────────────────
export default function ChatWidget() {
const panelOpen = useChatStore((s) => s.panelOpen);
const streaming = useChatStore((s) => s.streaming);
const togglePanel = useChatStore((s) => s.togglePanel);
const closePanel = useChatStore((s) => s.closePanel);
const createConversation = useChatStore((s) => s.createConversation);
const addUserMessage = useChatStore((s) => s.addUserMessage);
const addAssistantMessage = useChatStore((s) => s.addAssistantMessage);
const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant);
const setStreaming = useChatStore((s) => s.setStreaming);
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
const activeConversationId = useChatStore((s) => s.activeConversationId);
// Notebook context from the notebook editor (if on a notebook page)
const notebookId = useNotebookStore((s) => s.notebookId);
const notebook = useNotebookStore((s) => s.notebook);
const [input, setInput] = useState('');
const [statusText, setStatusText] = useState('');
const [showHistory, setShowHistory] = useState(false);
const [reasoningText, setReasoningText] = useState('');
const [reasoningTime, setReasoningTime] = useState(0);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const abortRef = useRef(null);
const activeConv = getActiveConversation();
const messages = activeConv?.messages ?? [];
// Auto-scroll on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length, streaming]);
// Focus input when panel opens
useEffect(() => {
if (panelOpen && !showHistory) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [panelOpen, showHistory]);
// Keyboard: Escape to close
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && panelOpen) {
closePanel();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [panelOpen, closePanel]);
const handleSend = useCallback(async () => {
const question = input.trim();
if (!question || streaming) return;
// Ensure there's an active conversation
if (!activeConversationId) {
createConversation();
}
setInput('');
addUserMessage(question);
addAssistantMessage('');
setStreaming(true);
setStatusText('');
setReasoningText('');
setReasoningTime(0);
const abortController = new AbortController();
abortRef.current = abortController;
const reasoningStart = Date.now();
try {
const notebookCtx = notebookId && notebook
? {
notebook_id: notebookId,
title: notebook.metadata.title,
engine: notebook.metadata.engine,
}
: null;
for await (const evt of streamChat({
question,
notebook: notebookCtx,
signal: abortController.signal,
})) {
switch (evt.event) {
case 'status':
setStatusText((evt.data as { text: string }).text);
break;
case 'token':
appendToLastAssistant((evt.data as { text: string }).text);
setStatusText('');
break;
case 'reasoning':
setReasoningText((prev) => prev + (evt.data as { text: string }).text);
setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000));
break;
case 'error':
appendToLastAssistant(
`\n\n*Error: ${(evt.data as { text: string }).text}*`,
);
break;
case 'done':
break;
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// User cancelled — that's fine
} else {
appendToLastAssistant(
`\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`,
);
}
} finally {
setStreaming(false);
setStatusText('');
abortRef.current = null;
}
}, [
input, streaming, activeConversationId, notebookId, notebook,
createConversation, addUserMessage, addAssistantMessage,
appendToLastAssistant, setStreaming,
]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const handleNewConversation = useCallback(() => {
// If streaming, abort first
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setStreaming(false);
}
createConversation();
setShowHistory(false);
setReasoningText('');
setReasoningTime(0);
setTimeout(() => inputRef.current?.focus(), 100);
}, [createConversation, setStreaming]);
return (
<>
{/* Floating action button */}
{/* Chat panel */}
{panelOpen && (
{/* Header */}
{showHistory
? 'Conversations'
: activeConv?.title ?? 'Circuit Assistant'}
{/* Body */}
{showHistory ? (
setShowHistory(false)} />
) : (
<>
{messages.length === 0 && !streaming && (
Ask about circuits, netlists, or simulation results
)}
{messages.map((msg, i) => (
))}
{reasoningText && streaming && (
Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}
{reasoningText}
)}
{statusText && (
{statusText}
)}
{/* Input */}
setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={streaming}
/>
>
)}
)}
>
);
}