import type { KeyboardEvent } from 'react'; import { useRef, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; import type { ModelOption } from '@/state/useModels'; import type { useClaude } from './useClaude'; import { MessageList } from 'apps/Chat'; import { InputArea } from './InputArea'; export type Attachment = | { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean } | { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean }; type EmbeddableChatProps = { chat: ReturnType; provider?: 'claude' | 'opencode' | 'pi-mono'; availableModels?: ModelOption[]; onProviderChange?: (provider: 'claude' | 'opencode' | 'pi-mono') => void; onBeforeSend?: (text: string) => boolean | Promise; commandFeedback?: string | null; defaultInput?: string; promptPrefix?: string; className?: string; cwd?: { root?: string; path: string }; autoSend?: boolean; }; export const EmbeddableChat = ({ chat, provider = 'claude', availableModels = [], onProviderChange, onBeforeSend, commandFeedback = null, defaultInput = '', promptPrefix, className, cwd, autoSend = false, }: EmbeddableChatProps) => { const { messages, streamingText, isConnected, isGenerating, sessionId, model, selectedModel, setSelectedModel, sendPrompt, stopGeneration, } = chat; const client = useClient(); const [input, setInput] = useState(defaultInput); const [attachments, setAttachments] = useState([]); const [showJumpToBottom, setShowJumpToBottom] = useState(false); const textareaRef = useRef(null); const scrollViewportRef = useRef(null); const bottomRef = useRef(null); const userScrolledRef = useRef(false); const handleAttachWebpage = async (url: string) => { const idx = attachments.length; setAttachments((prev) => [ ...prev, { type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true }, ]); try { const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', { url, sessionId: sessionId ?? undefined, provider, }); setAttachments((prev) => prev.map((a, i) => i === idx ? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false } : a, ), ); } catch { setAttachments((prev) => prev.filter((_, i) => i !== idx)); toast.error('Failed to scrape webpage'); } }; const handleAttachImage = async (file: File) => { const idx = attachments.length; setAttachments((prev) => [ ...prev, { type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true }, ]); try { const formData = new FormData(); formData.append('file', file); if (sessionId) formData.append('sessionId', sessionId); formData.append('provider', provider); const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData); setAttachments((prev) => prev.map((a, i) => i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a, ), ); } catch { setAttachments((prev) => prev.filter((_, i) => i !== idx)); toast.error('Failed to upload image'); } }; const handleRemoveAttachment = (index: number) => { setAttachments((prev) => prev.filter((_, i) => i !== index)); }; const handleSend = async () => { const text = input.trim(); if (!text || isGenerating) return; if (onBeforeSend) { const handled = await onBeforeSend(text); if (handled) { setInput(''); if (textareaRef.current) textareaRef.current.style.height = 'auto'; return; } } // Prepend metadata/attachment content to the prompt let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text; const ids: string[] = []; const images: { filename: string; dataUrl: string }[] = []; for (const a of attachments) { if (a.loading) continue; if (a.type === 'webpage' && a.content) { prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`; } else if (a.type === 'image' && a.dataUrl) { prompt = `[Attached image: ${a.filename}]\n\n${prompt}`; images.push({ filename: a.filename, dataUrl: a.dataUrl }); } ids.push(a.attachmentId); } // On first message (no sessionId), include attachmentIds so server can relocate tmp files const cwdForFirst = !sessionId ? cwd : undefined; sendPrompt( prompt, !sessionId && ids.length > 0 ? ids : undefined, images.length > 0 ? images : undefined, cwdForFirst, ); setAttachments([]); setInput(''); userScrolledRef.current = false; if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }; const handleKeyDown = (ev: KeyboardEvent) => { if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); handleSend(); } }; // Auto-resize textarea useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; }, [input]); // Auto-scroll to bottom on new messages useEffect(() => { if (!userScrolledRef.current) { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [messages, streamingText]); // Detect user scrolling up useEffect(() => { const viewport = scrollViewportRef.current; if (!viewport) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = viewport; const atBottom = scrollHeight - scrollTop - clientHeight < 60; userScrolledRef.current = !atBottom; setShowJumpToBottom(!atBottom); }; viewport.addEventListener('scroll', handleScroll); return () => viewport.removeEventListener('scroll', handleScroll); }, []); const jumpToBottom = () => { userScrolledRef.current = false; bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }; // Focus textarea on mount useEffect(() => { textareaRef.current?.focus(); }, []); // Auto-send first message when autoSend is enabled const autoSentRef = useRef(false); useEffect(() => { if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) { autoSentRef.current = true; handleSend(); } }, [autoSend, isConnected, messages.length, input]); return (
sendPrompt(text)} scrollViewportRef={scrollViewportRef} bottomRef={bottomRef} />
); };