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 { api, ChatWebSocket } from "../api/client"; import type { ReviewStory, UpcomingStory } from "../api/workflow"; import { workflowApi } from "../api/workflow"; import type { Message, ProviderConfig, ToolCall } from "../types"; import { AgentPanel } from "./AgentPanel"; import { ChatHeader } from "./ChatHeader"; import { GatePanel } from "./GatePanel"; import { ReviewPanel } from "./ReviewPanel"; import { TodoPanel } from "./TodoPanel"; import { UpcomingPanel } from "./UpcomingPanel"; const { useCallback, useEffect, useRef, useState } = React; interface ChatProps { projectPath: string; onCloseProject: () => void; } interface GateState { canAccept: boolean; reasons: string[]; warning: string | null; summary: { total: number; passed: number; failed: number; }; missingCategories: string[]; coverageReport: { currentPercent: number; thresholdPercent: number; baselinePercent: number | null; } | null; } 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 [gateState, setGateState] = useState(null); const [gateError, setGateError] = useState(null); const [isGateLoading, setIsGateLoading] = useState(false); const [reviewQueue, setReviewQueue] = useState([]); const [reviewError, setReviewError] = useState(null); const [isReviewLoading, setIsReviewLoading] = useState(false); const [proceedingStoryId, setProceedingStoryId] = useState( null, ); const [proceedError, setProceedError] = useState(null); const [proceedSuccess, setProceedSuccess] = useState(null); const [lastReviewRefresh, setLastReviewRefresh] = useState(null); const [lastGateRefresh, setLastGateRefresh] = useState(null); const [isCollectingCoverage, setIsCollectingCoverage] = useState(false); const [coverageError, setCoverageError] = useState(null); const [storyTodos, setStoryTodos] = useState< { storyId: string; storyName: string | null; items: string[]; error: string | null; }[] >([]); const [todoError, setTodoError] = useState(null); const [isTodoLoading, setIsTodoLoading] = useState(false); const [lastTodoRefresh, setLastTodoRefresh] = useState(null); const [upcomingStories, setUpcomingStories] = useState([]); const [upcomingError, setUpcomingError] = useState(null); const [isUpcomingLoading, setIsUpcomingLoading] = useState(false); const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState( null, ); const [claudeSessionId, setClaudeSessionId] = useState(null); const storyId = "26_establish_tdd_workflow_and_gates"; const gateStatusColor = isGateLoading ? "#aaa" : gateState?.canAccept ? "#7ee787" : "#ff7b72"; const gateStatusLabel = isGateLoading ? "Checking..." : gateState?.canAccept ? "Ready to accept" : "Blocked"; 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); }) .catch((err) => { console.error(err); setHasAnthropicKey(false); }); 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); setClaudeModels([]); }); }, []); useEffect(() => { let active = true; setIsGateLoading(true); setGateError(null); workflowApi .getAcceptance({ story_id: storyId }) .then((response) => { if (!active) return; setGateState({ canAccept: response.can_accept, reasons: response.reasons, warning: response.warning ?? null, summary: response.summary, missingCategories: response.missing_categories, coverageReport: response.coverage_report ? { currentPercent: response.coverage_report.current_percent, thresholdPercent: response.coverage_report.threshold_percent, baselinePercent: response.coverage_report.baseline_percent ?? null, } : null, }); setLastGateRefresh(new Date()); }) .catch((error) => { if (!active) return; const message = error instanceof Error ? error.message : "Failed to load workflow gates."; setGateError(message); setGateState(null); }) .finally(() => { if (active) { setIsGateLoading(false); } }); return () => { active = false; }; }, [storyId]); useEffect(() => { let active = true; setIsReviewLoading(true); setReviewError(null); workflowApi .getReviewQueueAll() .then((response) => { if (!active) return; setReviewQueue(response.stories); setLastReviewRefresh(new Date()); }) .catch((error) => { if (!active) return; const message = error instanceof Error ? error.message : "Failed to load review queue."; setReviewError(message); setReviewQueue([]); }) .finally(() => { if (active) { setIsReviewLoading(false); } }); return () => { active = false; }; }, []); useEffect(() => { let active = true; setIsTodoLoading(true); setTodoError(null); workflowApi .getStoryTodos() .then((response) => { if (!active) return; setStoryTodos( response.stories.map((s) => ({ storyId: s.story_id, storyName: s.story_name, items: s.todos, error: s.error ?? null, })), ); setLastTodoRefresh(new Date()); }) .catch((error) => { if (!active) return; const message = error instanceof Error ? error.message : "Failed to load story TODOs."; setTodoError(message); setStoryTodos([]); }) .finally(() => { if (active) { setIsTodoLoading(false); } }); return () => { active = false; }; }, []); const refreshTodos = async () => { setIsTodoLoading(true); setTodoError(null); try { const response = await workflowApi.getStoryTodos(); setStoryTodos( response.stories.map((s) => ({ storyId: s.story_id, storyName: s.story_name, items: s.todos, error: s.error ?? null, })), ); setLastTodoRefresh(new Date()); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load story TODOs."; setTodoError(message); setStoryTodos([]); } finally { setIsTodoLoading(false); } }; const refreshGateState = async (targetStoryId: string = storyId) => { setIsGateLoading(true); setGateError(null); try { const response = await workflowApi.getAcceptance({ story_id: targetStoryId, }); setGateState({ canAccept: response.can_accept, reasons: response.reasons, warning: response.warning ?? null, summary: response.summary, missingCategories: response.missing_categories, coverageReport: response.coverage_report ? { currentPercent: response.coverage_report.current_percent, thresholdPercent: response.coverage_report.threshold_percent, baselinePercent: response.coverage_report.baseline_percent ?? null, } : null, }); setLastGateRefresh(new Date()); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load workflow gates."; setGateError(message); setGateState(null); } finally { setIsGateLoading(false); } }; const handleCollectCoverage = async () => { setIsCollectingCoverage(true); setCoverageError(null); try { await workflowApi.collectCoverage({ story_id: storyId }); await refreshGateState(storyId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to collect coverage."; setCoverageError(message); } finally { setIsCollectingCoverage(false); } }; useEffect(() => { let active = true; setIsUpcomingLoading(true); setUpcomingError(null); workflowApi .getUpcomingStories() .then((response) => { if (!active) return; setUpcomingStories(response.stories); setLastUpcomingRefresh(new Date()); }) .catch((error) => { if (!active) return; const message = error instanceof Error ? error.message : "Failed to load upcoming stories."; setUpcomingError(message); setUpcomingStories([]); }) .finally(() => { if (active) { setIsUpcomingLoading(false); } }); return () => { active = false; }; }, []); const refreshUpcomingStories = async () => { setIsUpcomingLoading(true); setUpcomingError(null); try { const response = await workflowApi.getUpcomingStories(); setUpcomingStories(response.stories); setLastUpcomingRefresh(new Date()); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load upcoming stories."; setUpcomingError(message); setUpcomingStories([]); } finally { setIsUpcomingLoading(false); } }; const refreshReviewQueue = async () => { setIsReviewLoading(true); setReviewError(null); try { const response = await workflowApi.getReviewQueueAll(); setReviewQueue(response.stories); setLastReviewRefresh(new Date()); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load review queue."; setReviewError(message); setReviewQueue([]); } finally { setIsReviewLoading(false); } }; const handleProceed = async (storyIdToProceed: string) => { setProceedingStoryId(storyIdToProceed); setProceedError(null); setProceedSuccess(null); try { await workflowApi.ensureAcceptance({ story_id: storyIdToProceed, }); setProceedSuccess(`Proceeding with ${storyIdToProceed}.`); await refreshReviewQueue(); if (storyIdToProceed === storyId) { await refreshGateState(storyId); } } catch (error) { const message = error instanceof Error ? error.message : "Failed to proceed with review."; setProceedError(message); } finally { setProceedingStoryId(null); } }; 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); }, }); 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(); }, []); 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} />
refreshGateState(storyId)} onCollectCoverage={handleCollectCoverage} isCollectingCoverage={isCollectingCoverage} />
{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...
)}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { sendMessage(); } }} placeholder="Send a message..." style={{ flex: 1, padding: "14px 20px", borderRadius: "24px", border: "1px solid #333", outline: "none", fontSize: "1rem", fontWeight: "500", background: "#2f2f2f", color: "#ececec", boxShadow: "0 2px 6px rgba(0,0,0,0.02)", }} />
{showApiKeyDialog && (

Enter Anthropic API Key

To use Claude models, please enter your Anthropic API key. Your key will be stored server-side and reused across sessions.

setApiKeyInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} placeholder="sk-ant-..." style={{ width: "100%", padding: "12px", borderRadius: "8px", border: "1px solid #555", backgroundColor: "#1a1a1a", color: "#ececec", fontSize: "1em", marginBottom: "20px", outline: "none", }} />
)}
); }