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 { SettingsPage } from "./components/SettingsPage"; import type { Project, Session, Message, Phase, TokenUsage } from "./types"; import "./styles/globals.css"; const api = window.api; /** * Derive a short session name (≤30 chars) from research.md content. * Looks for the first non-empty body line under ## Overview. * Falls back to the first non-heading line in the document. * Returns null if nothing usable is found. */ function extractSessionName(content: string): string | null { const truncate = (s: string) => s.length > 30 ? s.slice(0, 28) + "\u2026" : s; // Primary: first sentence of ## Overview body const overviewMatch = content.match(/##\s+Overview\s*\n([\s\S]*?)(?=\n##|\n---)/); if (overviewMatch) { const firstLine = overviewMatch[1] .split("\n") .map((l) => l.trim()) .find((l) => l.length > 0 && !l.startsWith("#")); if (firstLine) { const firstSentence = firstLine.split(/[.?!]/)[0].trim(); if (firstSentence.length > 0) return truncate(firstSentence); } } // Fallback: first non-heading line anywhere in the document const firstLine = content .split("\n") .map((l) => l.trim()) .find((l) => l.length > 0 && !l.startsWith("#")); if (!firstLine) return null; return truncate(firstLine); } 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 [showSettings, setShowSettings] = useState(false); 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 session-specific artifact (from ~/.claude-flow/) const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; api.readSessionArtifact(selectedProject.id, selectedSession.id, 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 && selectedSession) { const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; // Capture for the async closure const sessionAtTrigger = selectedSession; api .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) .then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); // Auto-name: only during research, only while name is still the default if ( sessionAtTrigger.phase === "research" && /^Session \d+$/.test(sessionAtTrigger.name) && text.length > 0 ) { const derived = extractSessionName(text); if (derived) { handleRenameSession(sessionAtTrigger.id, derived); } } }); } } 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 (session-specific, stored in ~/.claude-flow/) const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; await api.writeSessionArtifact(selectedProject.id, selectedSession.id, 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 (session-specific, stored in ~/.claude-flow/) const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); const advanced = await api.advancePhase(selectedSession.id); if (advanced) { setSelectedSession({ ...selectedSession, phase: advanced.phase, git_branch: advanced.git_branch, }); // Trigger initial message for next phase setIsLoading(true); const initialMsg = advanced.phase === "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(""); // Note: Each session has its own artifact directory, no need to clear }; 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"); } }; const handleRenameSession = async (id: string, name: string) => { await api.renameSession(id, name); setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)) ); // Use functional updater to avoid stale-closure issues setSelectedSession((prev) => prev?.id === id ? { ...prev, name } : prev ); }; return (
setShowSettings(true)} />
{error && (
⚠️ {error}
)} { if (selectedSession) { api.setPermissionMode(selectedSession.id, mode); setSelectedSession({ ...selectedSession, permission_mode: mode }); } }} disabled={!selectedSession} /> {showSettings && ( setShowSettings(false)} /> )}
); }