import * as React from "react"; import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { PipelineState } from "../api/client"; import { api, ChatWebSocket } from "../api/client"; import type { Message, ProviderConfig, ToolCall } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; import { LozengeFlyProvider } from "./LozengeFlyContext"; import { StagePanel } from "./StagePanel"; const { useCallback, useEffect, useRef, useState } = React; const NARROW_BREAKPOINT = 900; interface ChatProps { projectPath: string; onCloseProject: () => void; } export function Chat({ projectPath, onCloseProject }: ChatProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [model, setModel] = useState("llama3.1"); const [enableTools, setEnableTools] = useState(true); const [availableModels, setAvailableModels] = useState([]); const [claudeModels, setClaudeModels] = useState([]); const [streamingContent, setStreamingContent] = useState(""); const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(""); const [hasAnthropicKey, setHasAnthropicKey] = useState(false); const [pipeline, setPipeline] = useState({ upcoming: [], current: [], qa: [], merge: [], }); const [claudeSessionId, setClaudeSessionId] = useState(null); const [isNarrowScreen, setIsNarrowScreen] = useState( window.innerWidth < NARROW_BREAKPOINT, ); 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; } => { let totalTokens = 0; totalTokens += 200; for (const msg of messages) { totalTokens += estimateTokens(msg.content); if (msg.tool_calls) { totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); } } if (streamingContent) { totalTokens += estimateTokens(streamingContent); } const contextWindow = getContextWindowSize(model); const percentage = Math.round((totalTokens / contextWindow) * 100); return { used: totalTokens, total: contextWindow, percentage, }; }; const contextUsage = calculateContextUsage(); useEffect(() => { api .getOllamaModels() .then(async (models) => { if (models.length > 0) { const sortedModels = models.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); setAvailableModels(sortedModels); try { const savedModel = await api.getModelPreference(); if (savedModel) { setModel(savedModel); } else if (sortedModels.length > 0) { setModel(sortedModels[0]); } } catch (e) { console.error(e); } } }) .catch((err) => console.error(err)); api .getAnthropicApiKeyExists() .then((exists) => { setHasAnthropicKey(exists); if (!exists) return; return api.getAnthropicModels().then((models) => { if (models.length > 0) { const sortedModels = models.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); setClaudeModels(sortedModels); } else { setClaudeModels([]); } }); }) .catch((err) => { console.error(err); setHasAnthropicKey(false); setClaudeModels([]); }); }, []); useEffect(() => { const ws = new ChatWebSocket(); wsRef.current = ws; ws.connect({ onToken: (content) => { setStreamingContent((prev: string) => prev + content); }, onUpdate: (history) => { setMessages(history); setStreamingContent(""); const last = history[history.length - 1]; if (last?.role === "assistant" && !last.tool_calls) { setLoading(false); } }, onSessionId: (sessionId) => { setClaudeSessionId(sessionId); }, onError: (message) => { console.error("WebSocket error:", message); setLoading(false); }, onPipelineState: (state) => { setPipeline(state); }, }); return () => { ws.close(); wsRef.current = null; }; }, []); const scrollToBottom = useCallback(() => { const element = scrollContainerRef.current; if (element) { element.scrollTop = element.scrollHeight; lastScrollTopRef.current = element.scrollHeight; } }, []); const handleScroll = () => { const element = scrollContainerRef.current; if (!element) return; const currentScrollTop = element.scrollTop; const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 5; if (currentScrollTop < lastScrollTopRef.current) { userScrolledUpRef.current = true; shouldAutoScrollRef.current = false; } if (isAtBottom) { userScrolledUpRef.current = false; shouldAutoScrollRef.current = true; } lastScrollTopRef.current = currentScrollTop; }; const autoScrollKey = messages.length + streamingContent.length; useEffect(() => { if ( autoScrollKey >= 0 && shouldAutoScrollRef.current && !userScrolledUpRef.current ) { scrollToBottom(); } }, [autoScrollKey, scrollToBottom]); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { const handleResize = () => setIsNarrowScreen(window.innerWidth < NARROW_BREAKPOINT); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); const cancelGeneration = async () => { try { wsRef.current?.cancel(); await api.cancelChat(); if (streamingContent) { setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: streamingContent }, ]); setStreamingContent(""); } setLoading(false); } catch (e) { console.error("Failed to cancel chat:", e); } }; const sendMessage = async (messageOverride?: string) => { const messageToSend = messageOverride ?? input; if (!messageToSend.trim() || loading) return; const isClaudeCode = model === "claude-code-pty"; if (!isClaudeCode && model.startsWith("claude-")) { const hasKey = await api.getAnthropicApiKeyExists(); if (!hasKey) { pendingMessageRef.current = messageToSend; setShowApiKeyDialog(true); return; } } const userMsg: Message = { role: "user", content: messageToSend }; const newHistory = [...messages, userMsg]; setMessages(newHistory); if (!messageOverride || messageOverride === input) { setInput(""); } setLoading(true); setStreamingContent(""); try { const provider = isClaudeCode ? "claude-code" : model.startsWith("claude-") ? "anthropic" : "ollama"; const config: ProviderConfig = { provider, model, base_url: "http://localhost:11434", enable_tools: enableTools, ...(isClaudeCode && claudeSessionId ? { session_id: claudeSessionId } : {}), }; wsRef.current?.sendChat(newHistory, config); } catch (e) { console.error("Chat error:", e); const errorMessage = String(e); if (!errorMessage.includes("Chat cancelled by user")) { setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: `**Error:** ${e}` }, ]); } setLoading(false); } }; const handleSaveApiKey = async () => { if (!apiKeyInput.trim()) return; try { await api.setAnthropicApiKey(apiKeyInput); setShowApiKeyDialog(false); setApiKeyInput(""); const pendingMessage = pendingMessageRef.current; pendingMessageRef.current = ""; if (pendingMessage.trim()) { sendMessage(pendingMessage); } } catch (e) { console.error("Failed to save API key:", e); alert(`Failed to save API key: ${e}`); } }; const clearSession = async () => { const confirmed = window.confirm( "Are you sure? This will clear all messages and reset the conversation context.", ); if (confirmed) { try { await api.cancelChat(); wsRef.current?.cancel(); } catch (e) { console.error("Failed to cancel chat:", e); } setMessages([]); setStreamingContent(""); setLoading(false); setClaudeSessionId(null); } }; return (
{ setModel(newModel); api.setModelPreference(newModel).catch(console.error); }} enableTools={enableTools} onToggleTools={setEnableTools} /> {/* Two-column content area */}
{/* Left column: chat messages + input pinned at bottom */}
{/* Scrollable messages area */}
{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})`}
); })}
)}
))} {loading && streamingContent && (
{ const match = /language-(\w+)/.exec(className || ""); const isInline = !className; return !isInline && match ? ( {String(children).replace(/\n$/, "")} ) : ( {children} ); }, }} > {streamingContent}
)} {loading && !streamingContent && (
Thinking...
)}
{/* Chat input pinned at bottom of left column */}