diff --git a/src/App.tsx b/src/App.tsx index eefaba13..f38f8414 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Plus, Loader2, Bot, FolderCode } from "lucide-react"; import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api"; import { OutputCacheProvider } from "@/lib/outputCache"; +import { SessionProvider, useSessionContext } from "@/contexts/SessionContext"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ProjectList } from "@/components/ProjectList"; @@ -17,14 +18,17 @@ import { UsageDashboard } from "@/components/UsageDashboard"; import { MCPManager } from "@/components/MCPManager"; import { NFOCredits } from "@/components/NFOCredits"; import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog"; +import { NavigationConfirmDialog } from "@/components/NavigationConfirmDialog"; +import { ActiveClaudeSessions } from "@/components/ActiveClaudeSessions"; import { Toast, ToastContainer } from "@/components/ui/toast"; type View = "welcome" | "projects" | "agents" | "editor" | "settings" | "claude-file-editor" | "claude-code-session" | "usage-dashboard" | "mcp"; /** - * Main App component - Manages the Claude directory browser UI + * Inner App component that uses the session context */ -function App() { +function AppInner() { + const { checkNavigationAllowed, activeSession } = useSessionContext(); const [view, setView] = useState("welcome"); const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); @@ -36,6 +40,39 @@ function App() { const [showNFO, setShowNFO] = useState(false); const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); + const [pendingView, setPendingView] = useState(null); + const [showNavConfirmDialog, setShowNavConfirmDialog] = useState(false); + + // Navigation guard handler + const handleNavigation = useCallback(async (newView: View) => { + // Skip check if we're already on the same view or navigating to claude-code-session + if (view === newView || newView === "claude-code-session") { + setView(newView); + return; + } + + // Check if navigation is allowed when leaving claude-code-session + if (view === "claude-code-session" && activeSession?.isActive) { + setPendingView(newView); + setShowNavConfirmDialog(true); + } else { + setView(newView); + } + }, [view, activeSession, checkNavigationAllowed]); + + // Handle navigation confirmation + const handleConfirmNavigation = useCallback(() => { + if (pendingView) { + setView(pendingView); + setPendingView(null); + } + setShowNavConfirmDialog(false); + }, [pendingView]); + + const handleCancelNavigation = useCallback(() => { + setPendingView(null); + setShowNavConfirmDialog(false); + }, []); // Load projects on mount when in projects view useEffect(() => { @@ -52,7 +89,7 @@ function App() { const handleSessionSelected = (event: CustomEvent) => { const { session } = event.detail; setSelectedSession(session); - setView("claude-code-session"); + handleNavigation("claude-code-session"); }; const handleClaudeNotFound = () => { @@ -65,7 +102,7 @@ function App() { window.removeEventListener('claude-session-selected', handleSessionSelected as EventListener); window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener); }; - }, []); + }, [handleNavigation]); /** * Loads all projects from the ~/.claude/projects directory @@ -106,7 +143,7 @@ function App() { * Opens a new Claude Code session in the interactive UI */ const handleNewSession = async () => { - setView("claude-code-session"); + handleNavigation("claude-code-session"); setSelectedSession(null); }; @@ -131,7 +168,7 @@ function App() { */ const handleBackFromClaudeFileEditor = () => { setEditingClaudeFile(null); - setView("projects"); + handleNavigation("projects"); }; const renderContent = () => { @@ -153,6 +190,11 @@ function App() { + {/* Active Sessions */} +
+ +
+ {/* Navigation Cards */}
{/* CC Agents Card */} @@ -163,7 +205,7 @@ function App() { > setView("agents")} + onClick={() => handleNavigation("agents")} >
@@ -180,7 +222,7 @@ function App() { > setView("projects")} + onClick={() => handleNavigation("projects")} >
@@ -197,21 +239,21 @@ function App() { case "agents": return (
- setView("welcome")} /> + handleNavigation("welcome")} />
); case "editor": return (
- setView("welcome")} /> + handleNavigation("welcome")} />
); case "settings": return (
- setView("welcome")} /> + handleNavigation("welcome")} />
); @@ -229,7 +271,7 @@ function App() { +
+ + +
+
+

Project Path

+

+ {session.projectPath} +

+
+ +
+

Messages

+

{session.messages.length} messages in conversation

+
+
+
+
+ + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index ac0c0156..ae042a35 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -17,6 +17,8 @@ import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { useSessionContext } from "@/contexts/SessionContext"; +import { sessionStorage } from "@/services/sessionStorage"; import { open } from "@tauri-apps/plugin-dialog"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; @@ -92,6 +94,7 @@ export const ClaudeCodeSession: React.FC = ({ const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); + const { setActiveSession } = useSessionContext(); // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { @@ -245,6 +248,29 @@ export const ClaudeCodeSession: React.FC = ({ setIsLoading(false); } }; + + // Update session context when loading state changes + useEffect(() => { + if (effectiveSession && hasActiveSessionRef.current) { + setActiveSession({ + sessionId: effectiveSession.id, + projectPath: projectPath, + isActive: isLoading, + hasUnsavedChanges: false, + }); + } + }, [isLoading, effectiveSession, projectPath, setActiveSession]); + + // Save messages to session storage periodically + useEffect(() => { + if (!effectiveSession || messages.length === 0) return; + + const saveTimer = setTimeout(() => { + sessionStorage.saveSession(effectiveSession.id, projectPath, messages); + }, 1000); // Debounce saves by 1 second + + return () => clearTimeout(saveTimer); + }, [effectiveSession, projectPath, messages]); const handleSelectPath = async () => { try { @@ -278,6 +304,15 @@ export const ClaudeCodeSession: React.FC = ({ setError(null); hasActiveSessionRef.current = true; + // Update the global session context + const sessionId = effectiveSession?.id || `new-${Date.now()}`; + setActiveSession({ + sessionId: sessionId, + projectPath: projectPath, + isActive: true, + hasUnsavedChanges: false, + }); + // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; @@ -337,6 +372,9 @@ export const ClaudeCodeSession: React.FC = ({ setIsLoading(false); hasActiveSessionRef.current = false; + // Update the global session context + setActiveSession(null); + // Check if we should create an auto checkpoint after completion if (effectiveSession && event.payload) { try { @@ -392,6 +430,7 @@ export const ClaudeCodeSession: React.FC = ({ setError("Failed to send prompt"); setIsLoading(false); hasActiveSessionRef.current = false; + setActiveSession(null); } }; @@ -506,6 +545,7 @@ export const ClaudeCodeSession: React.FC = ({ setIsLoading(false); hasActiveSessionRef.current = false; setError(null); + setActiveSession(null); } catch (err) { console.error("Failed to cancel execution:", err); setError("Failed to cancel execution"); @@ -595,6 +635,12 @@ export const ClaudeCodeSession: React.FC = ({ useEffect(() => { return () => { unlistenRefs.current.forEach(unlisten => unlisten()); + // Clear active session on unmount + setActiveSession(null); + // Clear session from storage if it was completed + if (effectiveSession && !hasActiveSessionRef.current) { + sessionStorage.removeSession(effectiveSession.id); + } // Clear checkpoint manager when session ends if (effectiveSession) { api.clearCheckpointManager(effectiveSession.id).catch(err => { @@ -602,7 +648,7 @@ export const ClaudeCodeSession: React.FC = ({ }); } }; - }, []); + }, [effectiveSession, setActiveSession]); const messagesList = (
void; + onCancel: () => void; +} + +export const NavigationConfirmDialog: React.FC = ({ + open, + onConfirm, + onCancel, +}) => { + return ( + !isOpen && onCancel()}> + + + + + Active Claude Session + + + You have an active Claude Code session that is currently running. + Navigating away will interrupt the conversation and may lose any ongoing work. +

+ Are you sure you want to leave this page? +
+
+ + + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx new file mode 100644 index 00000000..17077f50 --- /dev/null +++ b/src/contexts/SessionContext.tsx @@ -0,0 +1,110 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import { sessionStorage, type StoredSession } from '@/services/sessionStorage'; + +interface ActiveSession { + sessionId: string; + projectPath: string; + isActive: boolean; + hasUnsavedChanges: boolean; +} + +interface SessionContextType { + activeSession: ActiveSession | null; + setActiveSession: (session: ActiveSession | null) => void; + checkNavigationAllowed: () => Promise; + isNavigationBlocked: boolean; + activeSessions: StoredSession[]; + resumeSession: (sessionId: string) => void; +} + +const SessionContext = createContext(undefined); + +export const useSessionContext = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSessionContext must be used within a SessionProvider'); + } + return context; +}; + +interface SessionProviderProps { + children: ReactNode; +} + +export const SessionProvider: React.FC = ({ children }) => { + const [activeSession, setActiveSession] = useState(null); + const [activeSessions, setActiveSessions] = useState([]); + const [sessionToResume, setSessionToResume] = useState(null); + + const isNavigationBlocked = activeSession?.isActive || false; + + // Load active sessions on mount + useEffect(() => { + const sessions = sessionStorage.getActiveSessions(); + setActiveSessions(sessions); + }, []); + + // Save session periodically when active + useEffect(() => { + if (!activeSession?.isActive) return; + + const saveInterval = setInterval(() => { + // This would need to be enhanced to get actual messages from ClaudeCodeSession + // For now, just update timestamp to keep session alive + const sessions = sessionStorage.getAllSessions(); + if (sessions[activeSession.sessionId]) { + sessionStorage.saveSession( + activeSession.sessionId, + activeSession.projectPath, + sessions[activeSession.sessionId].messages + ); + } + }, 5000); // Save every 5 seconds + + return () => clearInterval(saveInterval); + }, [activeSession]); + + const checkNavigationAllowed = useCallback(async (): Promise => { + if (!activeSession?.isActive) { + return true; + } + + return new Promise((resolve) => { + const confirmed = window.confirm( + 'You have an active Claude Code session. Are you sure you want to navigate away? This will interrupt the current conversation.' + ); + resolve(confirmed); + }); + }, [activeSession]); + + const resumeSession = useCallback((sessionId: string) => { + const session = sessionStorage.getSession(sessionId); + if (session) { + // This will trigger navigation to the session + window.dispatchEvent(new CustomEvent('claude-session-selected', { + detail: { + session: { + id: session.sessionId, + project_id: session.projectPath, + project_path: session.projectPath, + } + } + })); + } + }, []); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/services/sessionStorage.ts b/src/services/sessionStorage.ts new file mode 100644 index 00000000..7a28472d --- /dev/null +++ b/src/services/sessionStorage.ts @@ -0,0 +1,93 @@ +export interface StoredSession { + sessionId: string; + projectPath: string; + messages: any[]; + timestamp: number; +} + +const SESSION_STORAGE_KEY = 'claudia_active_sessions'; +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +export const sessionStorage = { + saveSession(sessionId: string, projectPath: string, messages: any[]): void { + try { + const sessions = this.getAllSessions(); + sessions[sessionId] = { + sessionId, + projectPath, + messages, + timestamp: Date.now(), + }; + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions)); + } catch (err) { + console.error('Failed to save session to storage:', err); + } + }, + + getSession(sessionId: string): StoredSession | null { + try { + const sessions = this.getAllSessions(); + const session = sessions[sessionId]; + + if (!session) return null; + + // Check if session is expired + if (Date.now() - session.timestamp > SESSION_EXPIRY_MS) { + this.removeSession(sessionId); + return null; + } + + return session; + } catch (err) { + console.error('Failed to get session from storage:', err); + return null; + } + }, + + getAllSessions(): Record { + try { + const stored = localStorage.getItem(SESSION_STORAGE_KEY); + if (!stored) return {}; + + const sessions = JSON.parse(stored); + + // Clean up expired sessions + const now = Date.now(); + Object.keys(sessions).forEach(id => { + if (now - sessions[id].timestamp > SESSION_EXPIRY_MS) { + delete sessions[id]; + } + }); + + return sessions; + } catch (err) { + console.error('Failed to parse sessions from storage:', err); + return {}; + } + }, + + removeSession(sessionId: string): void { + try { + const sessions = this.getAllSessions(); + delete sessions[sessionId]; + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions)); + } catch (err) { + console.error('Failed to remove session from storage:', err); + } + }, + + getActiveSessions(): StoredSession[] { + const sessions = this.getAllSessions(); + return Object.values(sessions).filter(s => + Date.now() - s.timestamp < SESSION_EXPIRY_MS + ); + }, + + clearAllSessions(): void { + try { + localStorage.removeItem(SESSION_STORAGE_KEY); + } catch (err) { + console.error('Failed to clear sessions from storage:', err); + } + } +}; \ No newline at end of file