import React, { useState, useEffect, useRef } from "react"; import { Header } from "./components/Header"; import { Sidebar } from "./components/Sidebar"; 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(""); // Per-session loading/activity state so switching sessions doesn't inherit // another session's "Thinking" indicator. const [loadingBySession, setLoadingBySession] = useState>({}); const [activityBySession, setActivityBySession] = useState>({}); const isLoading = selectedSession ? (loadingBySession[selectedSession.id] ?? false) : false; const activityStatus = selectedSession ? (activityBySession[selectedSession.id] ?? null) : null; const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0, }); const [error, setError] = useState(null); const [showSettings, setShowSettings] = useState(false); const [activeModel, setActiveModel] = useState(null); const [theme, setTheme] = useState( () => (localStorage.getItem("cf-theme") as Theme) ?? "dark" ); // Which phase's artifact is currently displayed in the DocumentPane. // Defaults to the session's current phase; can be overridden to view // a historical artifact (e.g. research.md while in implement phase). const [viewPhase, setViewPhase] = useState( (selectedSession?.phase ?? "research") as Phase ); // Reset viewPhase whenever the active session or its phase changes. useEffect(() => { setViewPhase((selectedSession?.phase ?? "research") as Phase); }, [selectedSession?.id, selectedSession?.phase]); const isViewingHistorical = !!selectedSession && viewPhase !== (selectedSession.phase as Phase); // Refs so the global claude:message handler always sees the latest values // without needing to re-subscribe when the selected session changes. const selectedSessionRef = useRef(selectedSession); const selectedProjectRef = useRef(selectedProject); const viewPhaseRef = useRef(viewPhase); useEffect(() => { selectedSessionRef.current = selectedSession; }, [selectedSession]); useEffect(() => { selectedProjectRef.current = selectedProject; }, [selectedProject]); useEffect(() => { viewPhaseRef.current = viewPhase; }, [viewPhase]); const [chatWidth, setChatWidth] = useState( () => Number(localStorage.getItem("cf-chat-width")) || 380 ); const [chatCollapsed, setChatCollapsed] = useState( () => localStorage.getItem("cf-chat-collapsed") === "true" ); const [sidebarWidth, setSidebarWidth] = useState( () => Number(localStorage.getItem("cf-sidebar-width")) || 260 ); const [sidebarCollapsed, setSidebarCollapsed] = useState( () => localStorage.getItem("cf-sidebar-collapsed") === "true" ); // Keep document.documentElement in sync and persist to localStorage useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("cf-theme", theme); }, [theme]); useEffect(() => { localStorage.setItem("cf-chat-width", String(chatWidth)); }, [chatWidth]); useEffect(() => { localStorage.setItem("cf-chat-collapsed", String(chatCollapsed)); }, [chatCollapsed]); useEffect(() => { localStorage.setItem("cf-sidebar-width", String(sidebarWidth)); }, [sidebarWidth]); useEffect(() => { localStorage.setItem("cf-sidebar-collapsed", String(sidebarCollapsed)); }, [sidebarCollapsed]); const handleCancel = () => { if (!selectedSession) return; const id = selectedSession.id; api.interruptSession(id); setLoadingBySession((prev) => ({ ...prev, [id]: false })); setActivityBySession((prev) => ({ ...prev, [id]: null })); }; const handleToggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark")); const handleResizeMouseDown = (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = chatWidth; const onMove = (ev: MouseEvent) => { const delta = startX - ev.clientX; // drag left = wider chat const next = Math.max(180, Math.min(700, startWidth + delta)); setChatWidth(next); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }; const handleSidebarResizeMouseDown = (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = sidebarWidth; const onMove = (ev: MouseEvent) => { const delta = ev.clientX - startX; // drag right = wider sidebar const next = Math.max(180, Math.min(400, startWidth + delta)); setSidebarWidth(next); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }; 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) { const id = selectedSession.id; api.interruptSession(id); setLoadingBySession((prev) => ({ ...prev, [id]: false })); setActivityBySession((prev) => ({ ...prev, [id]: null })); } // 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, all sessions, and initial model setting on mount. // Sessions for every project are fetched in parallel so the sidebar tree // can display all projects and their sessions immediately. useEffect(() => { (async () => { const loadedProjects = await api.listProjects(); setProjects(loadedProjects); const arrays = await Promise.all( loadedProjects.map((p) => api.listSessions(p.id)) ); setSessions(arrays.flat()); // Seed the model badge from the DB so it shows before any query fires. // system:init will overwrite this with the SDK-resolved name once a query runs. const s = await api.getSettings(["model"]); if (s["model"]) setActiveModel(s["model"]); })(); }, []); // Load messages when session changes (not affected by viewPhase). useEffect(() => { if (selectedSession) { api.listMessages(selectedSession.id).then(setMessages); } else { setMessages([]); } }, [selectedSession?.id]); // Load the viewed artifact whenever the session or the viewed phase changes. useEffect(() => { if (selectedSession && selectedProject) { const filename = viewPhase === "research" ? "research.md" : "plan.md"; api.readSessionArtifact(selectedProject.id, selectedSession.id, filename).then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); }); } else { setDocumentContent(""); setOriginalContent(""); } }, [selectedSession?.id, selectedProject, viewPhase]); // Subscribe to Claude messages once globally. // Loading/activity state is updated for ANY session (via per-session maps). // Content state (messages, artifacts) is only updated for the currently // selected session, using refs to avoid re-subscribing on every session switch. useEffect(() => { const unsubscribe = api.onClaudeMessage((sessionId, msg) => { // ── Live activity indicator ────────────────────────────────────── if (msg.type === "tool_progress") { const elapsed = Math.round(msg.elapsed_time_seconds); setActivityBySession((prev) => ({ ...prev, [sessionId]: elapsed > 0 ? `Using ${msg.tool_name} (${elapsed}s)` : `Using ${msg.tool_name}…`, })); } if ( msg.type === "system" && msg.subtype === "task_progress" && (msg as { last_tool_name?: string }).last_tool_name ) { setActivityBySession((prev) => ({ ...prev, [sessionId]: `Using ${(msg as { last_tool_name: string }).last_tool_name}…`, })); } // ── Model resolved by SDK ──────────────────────────────────────── if (msg.type === "system" && msg.subtype === "init") { setActiveModel((msg as { model?: string }).model ?? null); } // ── Result (success or error) ──────────────────────────────────── // Clear loading state for the session that completed, regardless of // which session is currently selected. if (msg.type === "result") { setLoadingBySession((prev) => ({ ...prev, [sessionId]: false })); setActivityBySession((prev) => ({ ...prev, [sessionId]: null })); // Content updates only matter for the currently visible session. const currentSession = selectedSessionRef.current; const currentProject = selectedProjectRef.current; if (sessionId === currentSession?.id && msg.subtype === "success") { if (msg.usage) { setTokenUsage({ inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens, cacheHits: msg.usage.cache_read_input_tokens, }); } // Only reload the artifact if the user is viewing the current phase's // document. If they're browsing a historical artifact, leave it alone; // the effect tied to viewPhase will reload when they navigate back. if ( currentProject && currentSession && viewPhaseRef.current === currentSession.phase ) { const filename = currentSession.phase === "research" ? "research.md" : "plan.md"; const sessionAtTrigger = currentSession; api .readSessionArtifact(currentProject.id, sessionAtTrigger.id, filename) .then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); if ( sessionAtTrigger.phase === "research" && /^Session \d+$/.test(sessionAtTrigger.name) && text.length > 0 ) { const derived = extractSessionName(text); if (derived) { handleRenameSession(sessionAtTrigger.id, derived); } } }); } } } // ── Assistant message ──────────────────────────────────────────── // Only update the chat pane for the currently visible session. if (msg.type === "assistant" && sessionId === selectedSessionRef.current?.id) { 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") { return [...prev.slice(0, -1), { ...last, content }]; } return [ ...prev, { id: crypto.randomUUID(), session_id: sessionId, role: "assistant", content, created_at: Date.now() / 1000, }, ]; }); } } }); return unsubscribe; }, []); // Subscribe once — uses refs for current session/project const handleSendMessage = async (message: string) => { if (!selectedSession) return; const id = selectedSession.id; setLoadingBySession((prev) => ({ ...prev, [id]: true })); setError(null); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), session_id: id, role: "user", content: message, created_at: Date.now() / 1000, }, ]); try { await api.sendMessage(id, message); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send message"); setLoadingBySession((prev) => ({ ...prev, [id]: false })); } }; const handleReview = async () => { if (!selectedSession || !selectedProject || isViewingHistorical) return; const id = selectedSession.id; setError(null); try { const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; await api.writeSessionArtifact(selectedProject.id, id, filename, documentContent); setOriginalContent(documentContent); setLoadingBySession((prev) => ({ ...prev, [id]: true })); await api.triggerReview(id); } catch (err) { setError(err instanceof Error ? err.message : "Review failed"); setLoadingBySession((prev) => ({ ...prev, [id]: false })); } }; const handleSubmit = async () => { if (!selectedSession || !selectedProject || isViewingHistorical) return; const id = selectedSession.id; setError(null); try { const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; await api.writeSessionArtifact(selectedProject.id, id, filename, documentContent); const advanced = await api.advancePhase(id); if (advanced) { setSelectedSession({ ...selectedSession, phase: advanced.phase, git_branch: advanced.git_branch, }); setLoadingBySession((prev) => ({ ...prev, [id]: true })); const initialMsg = advanced.phase === "plan" ? "Create a detailed implementation plan based on the research." : "Begin implementing the plan."; await api.sendMessage(id, initialMsg); } } catch (err) { setError(err instanceof Error ? err.message : "Submit failed"); setLoadingBySession((prev) => ({ ...prev, [id]: 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 (projectId: string) => { const project = projects.find((p) => p.id === projectId); if (!project) return; const projectSessions = sessions.filter((s) => s.project_id === projectId); const name = `Session ${projectSessions.length + 1}`; const session = await api.createSession(projectId, name); setSessions((prev) => [session, ...prev]); setSelectedProject(project); setSelectedSession(session); setMessages([]); setDocumentContent(""); setOriginalContent(""); }; const handleDeleteProject = async (id: string) => { try { await api.deleteProject(id); setProjects((prev) => prev.filter((p) => p.id !== id)); setSessions((prev) => prev.filter((s) => s.project_id !== id)); if (selectedProject?.id === id) { setSelectedProject(null); setSelectedSession(null); 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)} viewPhase={viewPhase} onViewPhase={setViewPhase} />
{ setSelectedProject(p); setSelectedSession(null); }} onSelectSession={(session) => { if (session) { const project = projects.find((p) => p.id === session.project_id); if (project) setSelectedProject(project); } setSelectedSession(session); }} onCreateProject={handleCreateProject} onCreateSession={handleCreateSession} onDeleteProject={handleDeleteProject} onDeleteSession={handleDeleteSession} onRenameSession={handleRenameSession} loadingBySession={loadingBySession} width={sidebarWidth} collapsed={sidebarCollapsed} onCollapsedChange={setSidebarCollapsed} /> {!sidebarCollapsed && (
)}
{!chatCollapsed && (
)} setChatCollapsed((c) => !c)} activityStatus={activityStatus} onCancel={handleCancel} />
{error && (
⚠️ {error}
)} { if (selectedSession) { api.setPermissionMode(selectedSession.id, mode); setSelectedSession({ ...selectedSession, permission_mode: mode }); } }} disabled={!selectedSession} modelName={activeModel} /> {showSettings && ( setShowSettings(false)} /> )}
); }