diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 99f1c03..45010ec 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -5,13 +5,15 @@ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { PipelineState } from "../api/client"; import { api, ChatWebSocket } from "../api/client"; import { useChatHistory } from "../hooks/useChatHistory"; -import type { Message, ProviderConfig, ToolCall } from "../types"; +import type { Message, ProviderConfig } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; +import { ChatInput } from "./ChatInput"; import { LozengeFlyProvider } from "./LozengeFlyContext"; +import { MessageItem } from "./MessageItem"; import { StagePanel } from "./StagePanel"; -const { useCallback, useEffect, useRef, useState } = React; +const { useCallback, useEffect, useMemo, useRef, useState } = React; /** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */ function ThinkingBlock({ text }: { text: string }) { @@ -63,6 +65,37 @@ function ThinkingBlock({ text }: { text: string }) { ); } +/** Streaming message renderer — stable component to avoid recreation on each render. */ +function StreamingMessage({ content }: { content: string }) { + return ( + { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {content} + + ); +} + const NARROW_BREAKPOINT = 900; function formatToolActivity(toolName: string): string { @@ -100,6 +133,16 @@ function formatToolActivity(toolName: string): string { } } +const estimateTokens = (text: string): number => Math.ceil(text.length / 4); + +const getContextWindowSize = (modelName: string): number => { + if (modelName.startsWith("claude-")) return 200000; + if (modelName.includes("llama3")) return 8192; + if (modelName.includes("qwen2.5")) return 32768; + if (modelName.includes("deepseek")) return 16384; + return 8192; +}; + interface ChatProps { projectPath: string; onCloseProject: () => void; @@ -107,7 +150,6 @@ interface ChatProps { export function Chat({ projectPath, onCloseProject }: ChatProps) { const { messages, setMessages, clearMessages } = useChatHistory(projectPath); - const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [model, setModel] = useState("llama3.1"); const [enableTools, setEnableTools] = useState(true); @@ -155,30 +197,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const wsRef = useRef(null); const messagesEndRef = useRef(null); - const inputRef = useRef(null); const scrollContainerRef = useRef(null); const shouldAutoScrollRef = useRef(true); const lastScrollTopRef = useRef(0); const userScrolledUpRef = useRef(false); const pendingMessageRef = useRef(""); - const estimateTokens = (text: string): number => Math.ceil(text.length / 4); - - const getContextWindowSize = (modelName: string): number => { - if (modelName.startsWith("claude-")) return 200000; - if (modelName.includes("llama3")) return 8192; - if (modelName.includes("qwen2.5")) return 32768; - if (modelName.includes("deepseek")) return 16384; - return 8192; - }; - - const calculateContextUsage = (): { - used: number; - total: number; - percentage: number; - } => { + const contextUsage = useMemo(() => { let totalTokens = 0; - totalTokens += 200; for (const msg of messages) { @@ -200,9 +226,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { total: contextWindow, percentage, }; - }; - - const contextUsage = calculateContextUsage(); + }, [messages, streamingContent, model]); useEffect(() => { api @@ -371,10 +395,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } }, [autoScrollKey, scrollToBottom]); - useEffect(() => { - inputRef.current?.focus(); - }, []); - // Auto-send queued message when loading ends useEffect(() => { if (pendingAutoSend) { @@ -415,21 +435,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } }; - const sendMessage = async (messageOverride?: string) => { - const messageToSend = messageOverride ?? input; - if (!messageToSend.trim()) return; + const sendMessage = async (messageText: string) => { + if (!messageText.trim()) return; // Agent is busy — queue the message instead of dropping it if (loading) { const newItem = { id: String(queueIdCounterRef.current++), - text: messageToSend, + text: messageText, }; queuedMessagesRef.current = [...queuedMessagesRef.current, newItem]; setQueuedMessages([...queuedMessagesRef.current]); - if (!messageOverride || messageOverride === input) { - setInput(""); - } return; } @@ -437,19 +453,16 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { if (!isClaudeCode && model.startsWith("claude-")) { const hasKey = await api.getAnthropicApiKeyExists(); if (!hasKey) { - pendingMessageRef.current = messageToSend; + pendingMessageRef.current = messageText; setShowApiKeyDialog(true); return; } } - const userMsg: Message = { role: "user", content: messageToSend }; + const userMsg: Message = { role: "user", content: messageText }; const newHistory = [...messages, userMsg]; setMessages(newHistory); - if (!messageOverride || messageOverride === input) { - setInput(""); - } setLoading(true); setStreamingContent(""); setStreamingThinking(""); @@ -536,6 +549,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } }; + const handleRemoveQueuedMessage = useCallback((id: string) => { + queuedMessagesRef.current = queuedMessagesRef.current.filter( + (item) => item.id !== id, + ); + setQueuedMessages([...queuedMessagesRef.current]); + }, []); + return (
)} {messages.map((msg: Message, idx: number) => ( -
-
- {msg.role === "user" ? ( - msg.content - ) : msg.role === "tool" ? ( -
- - - - Tool Output - {msg.tool_call_id && ` (${msg.tool_call_id})`} - - -
-													{msg.content}
-												
-
- ) : ( -
- { - const match = /language-(\w+)/.exec( - className || "", - ); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {msg.content} - -
- )} - - {msg.tool_calls && ( -
- {msg.tool_calls.map((tc: ToolCall, i: number) => { - let argsSummary = ""; - try { - const args = JSON.parse(tc.function.arguments); - const firstKey = Object.keys(args)[0]; - if (firstKey && args[firstKey]) { - argsSummary = String(args[firstKey]); - if (argsSummary.length > 50) { - argsSummary = `${argsSummary.substring(0, 47)}...`; - } - } - } catch (_e) { - // ignore - } - - return ( -
- - - {tc.function.name} - {argsSummary && `(${argsSummary})`} - -
- ); - })} -
- )} -
-
+ msg={msg} + /> ))} {loading && streamingThinking && ( @@ -847,34 +719,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }} >
- { - const match = /language-(\w+)/.exec( - className || "", - ); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {streamingContent} - +
@@ -947,176 +792,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { )} {/* Chat input pinned at bottom of left column */} -
-
- {/* Queued message indicators */} - {queuedMessages.map(({ id, text }) => ( -
- - Queued - - - {text} - - - -
- ))} - {/* Input row */} -
-