diff --git a/src/components/AIViewer.tsx b/src/components/AIViewer.tsx index f7f3bd1..b9b02c0 100644 --- a/src/components/AIViewer.tsx +++ b/src/components/AIViewer.tsx @@ -306,72 +306,110 @@ function ChatPanel({ const [input, setInput] = useState(""); const [streamingContent, setStreamingContent] = useState(""); + // Optimistic UI: show user message immediately + const [pendingUserMessage, setPendingUserMessage] = useState( + null, + ); + // Track when we're waiting for AI (before tokens start streaming) + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); const messagesEndRef = useRef(null); const textareaRef = useRef(null); // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [conversation?.messages, streamingContent]); + }, [conversation?.messages, streamingContent, pendingUserMessage]); // Focus textarea on mount useEffect(() => { textareaRef.current?.focus(); }, [conversationId]); + // Clear pending message when conversation updates with our message + useEffect(() => { + if ( + pendingUserMessage && + conversation?.messages.some( + (m) => m.role === "user" && m.content === pendingUserMessage, + ) + ) { + setPendingUserMessage(null); + } + }, [conversation?.messages, pendingUserMessage]); + const handleSend = async () => { - if (!input.trim() || isGenerating) return; + if (!input.trim() || isGenerating || isWaitingForResponse) return; const userContent = input.trim(); setInput(""); - // Create conversation if needed - let activeConversationId = conversationId; - if (!activeConversationId) { - activeConversationId = await createConversation( + // Optimistically show user message immediately + setPendingUserMessage(userContent); + setIsWaitingForResponse(true); + + try { + // Create conversation if needed + let activeConversationId = conversationId; + if (!activeConversationId) { + activeConversationId = await createConversation( + providerInstanceId, + modelId, + ); + onConversationCreated(activeConversationId); + } + + // Add user message to DB + const userMessage = await addMessage({ + role: "user", + content: userContent, + }); + if (!userMessage) { + setPendingUserMessage(null); + setIsWaitingForResponse(false); + return; + } + + // Get all messages for context + const conv = await ( + await import("@/services/db") + ).default.llmConversations.get(activeConversationId); + if (!conv) { + setIsWaitingForResponse(false); + return; + } + + // Add placeholder assistant message + await addMessage({ role: "assistant", content: "" }); + + // Stream response + setStreamingContent(""); + + let fullContent = ""; + await sendMessage( providerInstanceId, modelId, + conv.messages, + (token) => { + // First token received - no longer "waiting" + setIsWaitingForResponse(false); + fullContent += token; + setStreamingContent(fullContent); + }, + async () => { + await updateLastMessage(fullContent); + setStreamingContent(""); + setIsWaitingForResponse(false); + }, + async (error) => { + await updateLastMessage(`Error: ${error}`); + setStreamingContent(""); + setIsWaitingForResponse(false); + }, ); - onConversationCreated(activeConversationId); + } catch (error) { + setPendingUserMessage(null); + setIsWaitingForResponse(false); } - - // Add user message - const userMessage = await addMessage({ - role: "user", - content: userContent, - }); - if (!userMessage) return; - - // Get all messages for context - const conv = await ( - await import("@/services/db") - ).default.llmConversations.get(activeConversationId); - if (!conv) return; - - // Add placeholder assistant message - await addMessage({ role: "assistant", content: "" }); - - // Stream response - setStreamingContent(""); - - let fullContent = ""; - await sendMessage( - providerInstanceId, - modelId, - conv.messages, - (token) => { - fullContent += token; - setStreamingContent(fullContent); - }, - async () => { - await updateLastMessage(fullContent); - setStreamingContent(""); - }, - async (error) => { - await updateLastMessage(error); - setStreamingContent(""); - }, - ); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -382,7 +420,9 @@ function ChatPanel({ }; const messages = conversation?.messages ?? []; - const displayMessages = + + // Build display messages with optimistic updates + let displayMessages = streamingContent && messages.length > 0 ? [ ...messages.slice(0, -1), @@ -390,12 +430,34 @@ function ChatPanel({ ] : messages; + // Add pending user message optimistically if not yet in conversation + if ( + pendingUserMessage && + !messages.some((m) => m.role === "user" && m.content === pendingUserMessage) + ) { + displayMessages = [ + ...displayMessages, + { + id: "pending", + role: "user" as const, + content: pendingUserMessage, + timestamp: Date.now(), + }, + ]; + } + + // Show thinking indicator when waiting for response + const showThinking = + isWaitingForResponse || (isGenerating && !streamingContent); + + const isBusy = isGenerating || isWaitingForResponse; + return (
{/* Messages */}
- {displayMessages.length === 0 && !isGenerating ? ( + {displayMessages.length === 0 && !showThinking ? (
Start a conversation
@@ -404,7 +466,7 @@ function ChatPanel({ {displayMessages.map((msg) => ( ))} - {isGenerating && !streamingContent && } + {showThinking && } )}
@@ -422,9 +484,9 @@ function ChatPanel({ placeholder="Type a message..." className="flex-1 min-h-[40px] max-h-[120px] resize-none" rows={1} - disabled={isGenerating} + disabled={isBusy} /> - {isGenerating ? ( + {isBusy ? (