import React, { useState, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft, Terminal, Loader2, FolderOpen, Copy, ChevronDown, GitBranch, Settings, Globe } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; 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 { open } from "@tauri-apps/plugin-dialog"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; import { TokenCounter } from "./TokenCounter"; import { TimelineNavigator } from "./TimelineNavigator"; import { CheckpointSettings } from "./CheckpointSettings"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; interface ClaudeCodeSessionProps { /** * Optional session to resume (when clicking from SessionList) */ session?: Session; /** * Initial project path (for new sessions) */ initialProjectPath?: string; /** * Callback to go back */ onBack: () => void; /** * Optional className for styling */ className?: string; } /** * ClaudeCodeSession component for interactive Claude Code sessions * * @example * setView('projects')} /> */ export const ClaudeCodeSession: React.FC = ({ session, initialProjectPath = "", onBack, className, }) => { const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || ""); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [rawJsonlOutput, setRawJsonlOutput] = useState([]); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [isFirstPrompt, setIsFirstPrompt] = useState(!session); const [totalTokens, setTotalTokens] = useState(0); const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null); const [claudeSessionId, setClaudeSessionId] = useState(null); const [showTimeline, setShowTimeline] = useState(false); const [timelineVersion, setTimelineVersion] = useState(0); const [showSettings, setShowSettings] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false); const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); const [isCancelling, setIsCancelling] = useState(false); // New state for preview feature const [showPreview, setShowPreview] = useState(false); const [previewUrl, setPreviewUrl] = useState(""); const [showPreviewPrompt, setShowPreviewPrompt] = useState(false); const [splitPosition, setSplitPosition] = useState(50); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { if (session) return session; if (extractedSessionInfo) { return { id: extractedSessionInfo.sessionId, project_id: extractedSessionInfo.projectId, project_path: projectPath, created_at: Date.now(), } as Session; } return null; }, [session, extractedSessionInfo, projectPath]); // Filter out messages that shouldn't be displayed const displayableMessages = useMemo(() => { return messages.filter((message, index) => { // Skip meta messages that don't have meaningful content if (message.isMeta && !message.leafUuid && !message.summary) { return false; } // Skip user messages that only contain tool results that are already displayed if (message.type === "user" && message.message) { if (message.isMeta) return false; const msg = message.message; if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) { return false; } if (Array.isArray(msg.content)) { let hasVisibleContent = false; for (const content of msg.content) { if (content.type === "text") { hasVisibleContent = true; break; } if (content.type === "tool_result") { let willBeSkipped = false; if (content.tool_use_id) { // Look for the matching tool_use in previous assistant messages for (let i = index - 1; i >= 0; i--) { const prevMsg = messages[i]; if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) { const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id ); if (toolUse) { const toolName = toolUse.name?.toLowerCase(); const toolsWithWidgets = [ 'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read', 'glob', 'bash', 'write', 'grep' ]; if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) { willBeSkipped = true; } break; } } } } if (!willBeSkipped) { hasVisibleContent = true; break; } } } if (!hasVisibleContent) { return false; } } } return true; }); }, [messages]); const rowVirtualizer = useVirtualizer({ count: displayableMessages.length, getScrollElement: () => parentRef.current, estimateSize: () => 150, // Estimate, will be dynamically measured overscan: 5, }); // Debug logging useEffect(() => { console.log('[ClaudeCodeSession] State update:', { projectPath, session, extractedSessionInfo, effectiveSession, messagesCount: messages.length, isLoading }); }, [projectPath, session, extractedSessionInfo, effectiveSession, messages.length, isLoading]); // Load session history if resuming useEffect(() => { if (session) { loadSessionHistory(); } }, [session]); // Auto-scroll to bottom when new messages arrive useEffect(() => { if (displayableMessages.length > 0) { rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' }); } }, [displayableMessages.length, rowVirtualizer]); // Calculate total tokens from messages useEffect(() => { const tokens = messages.reduce((total, msg) => { if (msg.message?.usage) { return total + msg.message.usage.input_tokens + msg.message.usage.output_tokens; } if (msg.usage) { return total + msg.usage.input_tokens + msg.usage.output_tokens; } return total; }, 0); setTotalTokens(tokens); }, [messages]); const loadSessionHistory = async () => { if (!session) return; try { setIsLoading(true); setError(null); const history = await api.loadSessionHistory(session.id, session.project_id); // Convert history to messages format const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({ ...entry, type: entry.type || "assistant" })); setMessages(loadedMessages); setRawJsonlOutput(history.map(h => JSON.stringify(h))); // After loading history, we're continuing a conversation setIsFirstPrompt(false); } catch (err) { console.error("Failed to load session history:", err); setError("Failed to load session history"); } finally { setIsLoading(false); } }; const handleSelectPath = async () => { try { const selected = await open({ directory: true, multiple: false, title: "Select Project Directory" }); if (selected) { setProjectPath(selected as string); setError(null); } } catch (err) { console.error("Failed to select directory:", err); const errorMessage = err instanceof Error ? err.message : String(err); setError(`Failed to select directory: ${errorMessage}`); } }; const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath }); if (!projectPath) { setError("Please select a project directory first"); return; } try { setIsLoading(true); setError(null); hasActiveSessionRef.current = true; // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Set up event listeners before executing console.log('[ClaudeCodeSession] Setting up event listeners...'); // Listen for the session started event to get the Claude session ID const sessionStartedUnlisten = await listen(`claude-session-started:*`, (event) => { const eventName = event.event; const sessionId = eventName.split(':')[1]; if (sessionId && !claudeSessionId) { console.log('[ClaudeCodeSession] Received Claude session ID:', sessionId); setClaudeSessionId(sessionId); } }); // If we already have a Claude session ID, use isolated listeners const eventSuffix = claudeSessionId ? `:${claudeSessionId}` : ''; const outputUnlisten = await listen(`claude-output${eventSuffix}`, async (event) => { try { console.log('[ClaudeCodeSession] Received claude-output:', event.payload); // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; console.log('[ClaudeCodeSession] Parsed message:', message); setMessages(prev => { console.log('[ClaudeCodeSession] Adding message to state. Previous count:', prev.length); return [...prev, message]; }); // Extract session info from system init message if (message.type === "system" && message.subtype === "init" && message.session_id && !extractedSessionInfo) { console.log('[ClaudeCodeSession] Extracting session info from init message'); // Extract project ID from the project path const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); setExtractedSessionInfo({ sessionId: message.session_id, projectId: projectId }); } } catch (err) { console.error("Failed to parse message:", err, event.payload); } }); const errorUnlisten = await listen(`claude-error${eventSuffix}`, (event) => { console.error("Claude error:", event.payload); setError(event.payload); }); const completeUnlisten = await listen(`claude-complete${eventSuffix}`, async (event) => { console.log('[ClaudeCodeSession] Received claude-complete:', event.payload); setIsLoading(false); hasActiveSessionRef.current = false; // Check if we should create an auto checkpoint after completion if (effectiveSession && event.payload) { try { const settings = await api.getCheckpointSettings( effectiveSession.id, effectiveSession.project_id, projectPath ); if (settings.auto_checkpoint_enabled) { await api.checkAutoCheckpoint( effectiveSession.id, effectiveSession.project_id, projectPath, prompt ); // Reload timeline to show new checkpoint setTimelineVersion((v) => v + 1); } } catch (err) { console.error('Failed to check auto checkpoint:', err); } } }); unlistenRefs.current = [sessionStartedUnlisten, outputUnlisten, errorUnlisten, completeUnlisten]; // Add the user message immediately to the UI (after setting up listeners) const userMessage: ClaudeStreamMessage = { type: "user", message: { content: [ { type: "text", text: prompt } ] } }; setMessages(prev => [...prev, userMessage]); // Execute the appropriate command if (effectiveSession && !isFirstPrompt) { console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); } else { console.log('[ClaudeCodeSession] Starting new session'); setIsFirstPrompt(false); await api.executeClaudeCode(projectPath, prompt, model); } } catch (err) { console.error("Failed to send prompt:", err); setError("Failed to send prompt"); setIsLoading(false); hasActiveSessionRef.current = false; } }; const handleCopyAsJsonl = async () => { const jsonl = rawJsonlOutput.join('\n'); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); }; const handleCopyAsMarkdown = async () => { let markdown = `# Claude Code Session\n\n`; markdown += `**Project:** ${projectPath}\n`; markdown += `**Date:** ${new Date().toISOString()}\n\n`; markdown += `---\n\n`; for (const msg of messages) { if (msg.type === "system" && msg.subtype === "init") { markdown += `## System Initialization\n\n`; markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`; markdown += `- Model: \`${msg.model || 'default'}\`\n`; if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`; if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`; markdown += `\n`; } else if (msg.type === "assistant" && msg.message) { markdown += `## Assistant\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { const textContent = typeof content.text === 'string' ? content.text : (content.text?.text || JSON.stringify(content.text || content)); markdown += `${textContent}\n\n`; } else if (content.type === "tool_use") { markdown += `### Tool: ${content.name}\n\n`; markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`; } } if (msg.message.usage) { markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`; } } else if (msg.type === "user" && msg.message) { markdown += `## User\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { const textContent = typeof content.text === 'string' ? content.text : (content.text?.text || JSON.stringify(content.text)); markdown += `${textContent}\n\n`; } else if (content.type === "tool_result") { markdown += `### Tool Result\n\n`; let contentText = ''; if (typeof content.content === 'string') { contentText = content.content; } else if (content.content && typeof content.content === 'object') { if (content.content.text) { contentText = content.content.text; } else if (Array.isArray(content.content)) { contentText = content.content .map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c))) .join('\n'); } else { contentText = JSON.stringify(content.content, null, 2); } } markdown += `\`\`\`\n${contentText}\n\`\`\`\n\n`; } } } else if (msg.type === "result") { markdown += `## Execution Result\n\n`; if (msg.result) { markdown += `${msg.result}\n\n`; } if (msg.error) { markdown += `**Error:** ${msg.error}\n\n`; } } } await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); }; const handleCheckpointSelect = async () => { // Reload messages from the checkpoint await loadSessionHistory(); // Ensure timeline reloads to highlight current checkpoint setTimelineVersion((v) => v + 1); }; const handleCancelExecution = async () => { if (!isLoading || isCancelling) return; try { setIsCancelling(true); // Cancel the Claude execution with session ID if available await api.cancelClaudeExecution(claudeSessionId || undefined); // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Add a system message indicating cancellation const cancelMessage: ClaudeStreamMessage = { type: "system", subtype: "cancelled", result: "Execution cancelled by user", timestamp: new Date().toISOString() }; setMessages(prev => [...prev, cancelMessage]); // Reset states setIsLoading(false); hasActiveSessionRef.current = false; setError(null); } catch (err) { console.error("Failed to cancel execution:", err); setError("Failed to cancel execution"); } finally { setIsCancelling(false); } }; const handleFork = (checkpointId: string) => { setForkCheckpointId(checkpointId); setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`); setShowForkDialog(true); }; const handleConfirmFork = async () => { if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return; try { setIsLoading(true); setError(null); const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; await api.forkFromCheckpoint( forkCheckpointId, effectiveSession.id, effectiveSession.project_id, projectPath, newSessionId, forkSessionName ); // Open the new forked session // You would need to implement navigation to the new session console.log("Forked to new session:", newSessionId); setShowForkDialog(false); setForkCheckpointId(null); setForkSessionName(""); } catch (err) { console.error("Failed to fork checkpoint:", err); setError("Failed to fork checkpoint"); } finally { setIsLoading(false); } }; // Handle URL detection from terminal output const handleLinkDetected = (url: string) => { if (!showPreview && !showPreviewPrompt) { setPreviewUrl(url); setShowPreviewPrompt(true); } }; const handleClosePreview = () => { setShowPreview(false); setIsPreviewMaximized(false); // Keep the previewUrl so it can be restored when reopening }; const handlePreviewScreenshot = async (imagePath: string) => { console.log("Screenshot captured:", imagePath); // Add the screenshot to the floating prompt input if (floatingPromptRef.current) { floatingPromptRef.current.addImage(imagePath); // Show a subtle animation/feedback that the image was added // You could add a toast notification here if desired } }; const handlePreviewUrlChange = (url: string) => { console.log('[ClaudeCodeSession] Preview URL changed to:', url); setPreviewUrl(url); }; const handleTogglePreviewMaximize = () => { setIsPreviewMaximized(!isPreviewMaximized); // Reset split position when toggling maximize if (isPreviewMaximized) { setSplitPosition(50); } }; // Clean up listeners on component unmount useEffect(() => { return () => { unlistenRefs.current.forEach(unlisten => unlisten()); // Clear checkpoint manager when session ends if (effectiveSession) { api.clearCheckpointManager(effectiveSession.id).catch(err => { console.error("Failed to clear checkpoint manager:", err); }); } }; }, []); const messagesList = (
{rowVirtualizer.getVirtualItems().map((virtualItem) => { const message = displayableMessages[virtualItem.index]; return ( el && rowVirtualizer.measureElement(el)} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.3 }} className="absolute inset-x-4 pb-4" style={{ top: virtualItem.start, }} > ); })}
{/* Loading and Error indicators positioned relative to the scroll container */}
{isLoading && ( )} {error && ( {error} )}
); const projectPathInput = !session && (
setProjectPath(e.target.value)} placeholder="/path/to/your/project" className="flex-1" disabled={isLoading} />
); // If preview is maximized, render only the WebviewPreview in full screen if (showPreview && isPreviewMaximized) { return ( ); } return (
{/* Header */}

Claude Code Session

{session ? `Resuming session ${session.id.slice(0, 8)}...` : 'Interactive session'}

{effectiveSession && ( <> )} {/* Preview Button */} {showPreview ? "Close the preview pane" : "Open a browser preview to test your web applications" } {messages.length > 0 && ( Copy Output } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} /> )}
{/* Main Content Area */}
{showPreview ? ( // Split pane layout when preview is active {projectPathInput} {messagesList}
} right={ } initialSplit={splitPosition} onSplitChange={setSplitPosition} minLeftWidth={400} minRightWidth={400} className="h-full" /> ) : ( // Original layout when no preview
{projectPathInput} {messagesList}
)} {isLoading && messages.length === 0 && (
{session ? "Loading session history..." : "Initializing Claude Code..."}
)}
{/* Floating Prompt Input - Always visible */} {/* Timeline */} {showTimeline && effectiveSession && ( )}
{/* Fork Dialog */} Fork Session Create a new session branch from the selected checkpoint.
setForkSessionName(e.target.value)} onKeyPress={(e) => { if (e.key === "Enter" && !isLoading) { handleConfirmFork(); } }} />
{/* Settings Dialog */} {showSettings && effectiveSession && ( setShowSettings(false)} /> )} ); };