import React, { useState, useEffect } from "react"; import { Header } from "./components/Header"; import { DocumentPane } from "./components/DocumentPane"; import { ChatPane } from "./components/ChatPane"; import { ActionBar } from "./components/ActionBar"; import type { Project, Session, Message, Phase, TokenUsage } from "./types"; import "./styles/globals.css"; const api = window.api; type Theme = "dark" | "light"; export function App() { const [projects, setProjects] = useState([]); const [sessions, setSessions] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); const [messages, setMessages] = useState([]); const [documentContent, setDocumentContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [isLoading, setIsLoading] = useState(false); const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0, }); const [error, setError] = useState(null); const [theme, setTheme] = useState( () => (localStorage.getItem("cf-theme") as Theme) ?? "dark" ); // Keep document.documentElement in sync and persist to localStorage useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("cf-theme", theme); }, [theme]); const handleToggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark")); const hasChanges = documentContent !== originalContent; // Clear error after 5 seconds useEffect(() => { if (error) { const timer = setTimeout(() => setError(null), 5000); return () => clearTimeout(timer); } }, [error]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Escape to interrupt if (e.key === "Escape" && isLoading && selectedSession) { api.interruptSession(selectedSession.id); setIsLoading(false); } // Cmd/Ctrl + Enter to submit if ( e.key === "Enter" && (e.metaKey || e.ctrlKey) && selectedSession && selectedSession.phase !== "implement" && !isLoading ) { e.preventDefault(); handleSubmit(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedSession, isLoading]); // Load projects on mount useEffect(() => { api.listProjects().then(setProjects); }, []); // Load sessions when project changes useEffect(() => { if (selectedProject) { api.listSessions(selectedProject.id).then(setSessions); } else { setSessions([]); } }, [selectedProject]); // Load messages and artifact when session changes useEffect(() => { if (selectedSession && selectedProject) { // Load messages api.listMessages(selectedSession.id).then(setMessages); // Load artifact const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; api.readArtifact(selectedProject.path, filename).then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); }); } else { setMessages([]); setDocumentContent(""); setOriginalContent(""); } }, [selectedSession?.id, selectedSession?.phase, selectedProject]); // Subscribe to Claude messages useEffect(() => { const unsubscribe = api.onClaudeMessage((sessionId, msg) => { if (sessionId !== selectedSession?.id) return; if (msg.type === "result" && msg.subtype === "success") { setIsLoading(false); if (msg.usage) { setTokenUsage({ inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens, cacheHits: msg.usage.cache_read_input_tokens, }); } // Reload artifact after Claude updates it if (selectedProject) { const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; api.readArtifact(selectedProject.path, filename).then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); }); } } if (msg.type === "assistant") { const content = ( msg.message.content as Array<{ type: string; text?: string }> ) .filter((c) => c.type === "text" && c.text) .map((c) => c.text!) .join("\n"); if (content) { setMessages((prev) => { const last = prev[prev.length - 1]; if (last?.role === "assistant") { // Update existing assistant message return [...prev.slice(0, -1), { ...last, content }]; } // Add new assistant message return [ ...prev, { id: crypto.randomUUID(), session_id: sessionId, role: "assistant", content, created_at: Date.now() / 1000, }, ]; }); } } }); return unsubscribe; }, [selectedSession?.id, selectedSession?.phase, selectedProject]); const handleSendMessage = async (message: string) => { if (!selectedSession) return; setIsLoading(true); setError(null); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), session_id: selectedSession.id, role: "user", content: message, created_at: Date.now() / 1000, }, ]); try { await api.sendMessage(selectedSession.id, message); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send message"); setIsLoading(false); } }; const handleReview = async () => { if (!selectedSession || !selectedProject) return; setError(null); try { // Save user edits first const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; await api.writeArtifact(selectedProject.path, filename, documentContent); setOriginalContent(documentContent); setIsLoading(true); await api.triggerReview(selectedSession.id); } catch (err) { setError(err instanceof Error ? err.message : "Review failed"); setIsLoading(false); } }; const handleSubmit = async () => { if (!selectedSession || !selectedProject) return; setError(null); try { // Save any pending edits const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; await api.writeArtifact(selectedProject.path, filename, documentContent); const nextPhase = await api.advancePhase(selectedSession.id); if (nextPhase) { setSelectedSession({ ...selectedSession, phase: nextPhase }); // Trigger initial message for next phase setIsLoading(true); const initialMsg = nextPhase === "plan" ? "Create a detailed implementation plan based on the research." : "Begin implementing the plan."; await api.sendMessage(selectedSession.id, initialMsg); } } catch (err) { setError(err instanceof Error ? err.message : "Submit failed"); setIsLoading(false); } }; const handleCreateProject = async () => { const path = await api.selectDirectory(); if (!path) return; const name = path.split("/").pop() || "New Project"; const project = await api.createProject(name, path); setProjects((prev) => [project, ...prev]); setSelectedProject(project); }; const handleCreateSession = async () => { if (!selectedProject) return; const name = `Session ${sessions.length + 1}`; const session = await api.createSession(selectedProject.id, name); setSessions((prev) => [session, ...prev]); setSelectedSession(session); setMessages([]); setDocumentContent(""); setOriginalContent(""); }; const handleDeleteProject = async (id: string) => { try { await api.deleteProject(id); setProjects((prev) => prev.filter((p) => p.id !== id)); if (selectedProject?.id === id) { setSelectedProject(null); setSelectedSession(null); setSessions([]); setMessages([]); setDocumentContent(""); setOriginalContent(""); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete project"); } }; const handleDeleteSession = async (id: string) => { try { await api.deleteSession(id); setSessions((prev) => prev.filter((s) => s.id !== id)); if (selectedSession?.id === id) { setSelectedSession(null); setMessages([]); setDocumentContent(""); setOriginalContent(""); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete session"); } }; return (
{error && (
⚠️ {error}
)} { if (selectedSession) { api.setPermissionMode(selectedSession.id, mode); setSelectedSession({ ...selectedSession, permission_mode: mode }); } }} disabled={!selectedSession} />
); }