import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft, Terminal as TerminalIcon, FolderOpen, Copy, ChevronDown, GitBranch, Settings, Settings2, ChevronUp, X, Hash, Command, PanelLeftOpen, PanelRightOpen, ArrowUp, ArrowDown, Eye, EyeOff, FileText, FilePlus, FileX, Clock, Square } 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 { useTranslation } from "react-i18next"; 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 { TimelineNavigator } from "./TimelineNavigator"; import { CheckpointSettings } from "./CheckpointSettings"; import { fileSyncManager } from "@/lib/fileSyncManager"; 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 { FileExplorerPanelEnhanced } from "./FileExplorerPanelEnhanced"; import { GitPanelEnhanced } from "./GitPanelEnhanced"; import { FileEditorEnhanced } from "./FileEditorEnhanced"; import { SlashCommandsManager } from "./SlashCommandsManager"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks"; // import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout"; // 文件变化监控接口 interface FileChange { path: string; changeType: 'created' | 'modified' | 'deleted' | 'renamed'; timestamp: number; oldPath?: string; // 用于重命名操作 } // 新增布局组件导入 import { FlexLayoutContainer } from "@/components/layout/FlexLayoutContainer"; import { MainContentArea } from "@/components/layout/MainContentArea"; import { SidePanel } from "@/components/layout/SidePanel"; import { ChatView } from "@/components/layout/ChatView"; import { Terminal } from "@/components/Terminal"; 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; /** * Callback to open hooks configuration */ onProjectSettings?: (projectPath: string) => void; /** * Optional className for styling */ className?: string; /** * Callback when streaming state changes */ onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void; } /** * ClaudeCodeSession component for interactive Claude Code sessions * * @example * setView('projects')} /> */ export const ClaudeCodeSession: React.FC = ({ session, initialProjectPath = "", onBack, onProjectSettings, className, onStreamingChange, }) => { const { t } = useTranslation(); const layoutManager = useLayoutManager(initialProjectPath || session?.project_path); const { layout, breakpoints, toggleFileExplorer, toggleGitPanel, toggleTimeline, setPanelWidth, setSplitPosition: setLayoutSplitPosition, getResponsiveClasses, openFileEditor, closeFileEditor, openPreview: openLayoutPreview, closePreview: closeLayoutPreview, openTerminal, closeTerminal, toggleTerminalMaximize } = layoutManager; 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 [timelineVersion, setTimelineVersion] = useState(0); const [showSettings, setShowSettings] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false); const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false); const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); // Queued prompts state const [queuedPrompts, setQueuedPrompts] = useState>([]); // 使用布局管理器的预览功能 // Note: openLayoutPreview is used directly instead of wrapping in handleOpenPreview const handleClosePreview = useCallback(() => { closeLayoutPreview(); setIsPreviewMaximized(false); }, [closeLayoutPreview]); // 添加临时状态用于预览提示 const [showPreviewPrompt, setShowPreviewPrompt] = useState(false); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); const [showScrollButtons, setShowScrollButtons] = useState(false); const [isAtTop, setIsAtTop] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true); // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); // 文件监控相关状态 const [fileChanges, setFileChanges] = useState([]); const [isFileWatching, setIsFileWatching] = useState(false); const [fileMonitorCollapsed, setFileMonitorCollapsed] = useState(false); const [fileMonitorExpanded, setFileMonitorExpanded] = useState(false); // File editor state // 移除重复的状态,使用 layout 中的状态 // const [editingFile, setEditingFile] = useState(null); // 移除,使用 layout.editingFile const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); const queuedPromptsRef = useRef>([]); const isMountedRef = useRef(true); const isListeningRef = useRef(false); const sessionStartTime = useRef(Date.now()); const fileWatcherUnlistenRef = useRef(null); // Session metrics state for enhanced analytics const sessionMetrics = useRef({ firstMessageTime: null as number | null, promptsSent: 0, toolsExecuted: 0, toolsFailed: 0, filesCreated: 0, filesModified: 0, filesDeleted: 0, codeBlocksGenerated: 0, errorsEncountered: 0, lastActivityTime: Date.now(), toolExecutionTimes: [] as number[], checkpointCount: 0, wasResumed: !!session, modelChanges: [] as Array<{ from: string; to: string; timestamp: number }>, }); // Analytics tracking const trackEvent = useTrackEvent(); useComponentMetrics('ClaudeCodeSession'); // const aiTracking = useAIInteractionTracking('sonnet'); // Default model const workflowTracking = useWorkflowTracking('claude_session'); // 启动文件监控 const startFileWatching = useCallback(async () => { if (!projectPath || isFileWatching) return; try { console.log('[FileMonitor] Starting file watching for:', projectPath); // 启动项目目录文件监控 await api.watchDirectory(projectPath, true); // recursive = true // 启动 Claude 项目目录监控 try { await api.watchClaudeProjectDirectory(projectPath); console.log('[FileMonitor] Claude project directory watching started for:', projectPath); } catch (claudeErr) { console.warn('[FileMonitor] Failed to start Claude project directory watching:', claudeErr); // 不影响主要的文件监控功能 } setIsFileWatching(true); console.log('[FileMonitor] File watching started successfully'); // 监听文件系统变化事件 const unlisten = await listen('file-system-change', (event) => { if (!isMountedRef.current) return; const { path, change_type } = event.payload; console.log('[FileMonitor] File change detected:', { path, change_type }); // 过滤掉隐藏文件和临时文件 const fileName = path.split('/').pop() || ''; if (fileName.startsWith('.') || fileName.includes('~') || fileName.endsWith('.tmp')) { return; } // 通知文件同步管理器 fileSyncManager.notifyFileChange(path, change_type); // 判断是否是 Claude 项目文件变化 const isClaudeProjectFile = path.includes('/.claude/projects/'); const displayPath = isClaudeProjectFile ? path.replace(/.*\/\.claude\/projects\/[^/]+\//, '[Claude] ') // 简化 Claude 项目文件路径显示 : path.replace(projectPath + '/', ''); // 项目文件相对路径 const newChange: FileChange = { path: displayPath, changeType: change_type, timestamp: Date.now(), }; setFileChanges(prev => { // 限制最多保存100个变化记录 const updated = [newChange, ...prev].slice(0, 100); return updated; }); // 如果是 Claude 项目文件变化且文件被修改,重新加载会话历史 if (isClaudeProjectFile && change_type === 'modified' && session) { const fileName = path.split('/').pop() || ''; // 检查是否是当前会话的 JSONL 文件 if (fileName === `${session.id}.jsonl`) { console.log('[FileMonitor] Claude session file updated, reloading history'); // 使用 setTimeout 避免频繁刷新 setTimeout(() => { loadSessionHistory(); }, 500); } } }); fileWatcherUnlistenRef.current = unlisten; } catch (err) { console.error('[FileMonitor] Failed to start file watching:', err); setIsFileWatching(false); } }, [projectPath, isFileWatching]); // 停止文件监控 const stopFileWatching = useCallback(async () => { if (!projectPath || !isFileWatching) return; try { console.log('[FileMonitor] Stopping file watching for:', projectPath); // 停止监听事件 if (fileWatcherUnlistenRef.current) { fileWatcherUnlistenRef.current(); fileWatcherUnlistenRef.current = null; } // 停止项目目录文件监控 await api.unwatchDirectory(projectPath); // 停止 Claude 项目目录监控 try { await api.unwatchClaudeProjectDirectory(projectPath); console.log('[FileMonitor] Claude project directory watching stopped for:', projectPath); } catch (claudeErr) { console.warn('[FileMonitor] Failed to stop Claude project directory watching:', claudeErr); // 不影响主要的停止功能 } setIsFileWatching(false); // 清空文件变化记录 setFileChanges([]); console.log('[FileMonitor] File watching stopped successfully'); } catch (err) { console.error('[FileMonitor] Failed to stop file watching:', err); // 即使后端出错,也要更新前端状态 setIsFileWatching(false); setFileChanges([]); } }, [projectPath, isFileWatching]); // 切换文件监控状态 const toggleFileWatching = useCallback(() => { if (isFileWatching) { stopFileWatching(); } else { startFileWatching(); } }, [isFileWatching, startFileWatching, stopFileWatching]); // 清空文件变化历史 const clearFileChanges = useCallback(() => { setFileChanges([]); }, []); // Keep ref in sync with state useEffect(() => { queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); // 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) { // Set the claudeSessionId immediately when we have a session setClaudeSessionId(session.id); // Load session history first, then check for active session const initializeSession = async () => { await loadSessionHistory(); // After loading history, check if the session is still active if (isMountedRef.current) { await checkForActiveSession(); } }; initializeSession(); } }, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount // Report streaming state changes useEffect(() => { onStreamingChange?.(isLoading, claudeSessionId); }, [isLoading, claudeSessionId, onStreamingChange]); // 滚动到顶部 const scrollToTop = useCallback(() => { if (parentRef.current) { parentRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } }, []); // 滚动到底部 const scrollToBottom = useCallback(() => { if (parentRef.current) { parentRef.current.scrollTo({ top: parentRef.current.scrollHeight, behavior: 'smooth' }); } }, []); // Auto-scroll to bottom when new messages arrive useEffect(() => { if (displayableMessages.length > 0) { // 使用setTimeout确保DOM更新后再滚动 setTimeout(() => { scrollToBottom(); }, 100); } }, [displayableMessages.length, scrollToBottom]); // 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); // 加载完成后自动滚动到底部 setTimeout(() => { scrollToBottom(); }, 200); } catch (err) { console.error("Failed to load session history:", err); setError("Failed to load session history"); } finally { setIsLoading(false); } }; const checkForActiveSession = async () => { // If we have a session prop, check if it's still active if (session) { try { const activeSessions = await api.listRunningClaudeSessions(); const activeSession = activeSessions.find((s: any) => { if ('process_type' in s && s.process_type && 'ClaudeSession' in s.process_type) { return (s.process_type as any).ClaudeSession.session_id === session.id; } return false; }); if (activeSession) { // Session is still active, reconnect to its stream console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id); // IMPORTANT: Set claudeSessionId before reconnecting setClaudeSessionId(session.id); // Don't add buffered messages here - they've already been loaded by loadSessionHistory // Just set up listeners for new messages // Set up listeners for the active session reconnectToSession(session.id); } } catch (err) { console.error('Failed to check for active sessions:', err); } } }; const reconnectToSession = async (sessionId: string) => { console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId); // Prevent duplicate listeners if (isListeningRef.current) { console.log('[ClaudeCodeSession] Already listening to session, skipping reconnect'); return; } // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // IMPORTANT: Set the session ID before setting up listeners setClaudeSessionId(sessionId); // Mark as listening isListeningRef.current = true; // Set up session-specific listeners const outputUnlisten = await listen(`claude-output:${sessionId}`, async (event) => { try { console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload); if (!isMountedRef.current) return; // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; setMessages(prev => [...prev, message]); } catch (err) { console.error("Failed to parse message:", err, event.payload); } }); const errorUnlisten = await listen(`claude-error:${sessionId}`, (event) => { console.error("Claude error:", event.payload); if (isMountedRef.current) { setError(event.payload); } }); const completeUnlisten = await listen(`claude-complete:${sessionId}`, async (event) => { console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload); if (isMountedRef.current) { setIsLoading(false); hasActiveSessionRef.current = false; } }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; // Mark as loading to show the session is active if (isMountedRef.current) { setIsLoading(true); hasActiveSessionRef.current = true; } }; 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" | "opus-plan") => { console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); if (!projectPath) { setError("Please select a project directory first"); return; } // If already loading, queue the prompt if (isLoading) { const newPrompt = { id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, prompt, model }; setQueuedPrompts(prev => [...prev, newPrompt]); return; } try { setIsLoading(true); setError(null); hasActiveSessionRef.current = true; // For resuming sessions, ensure we have the session ID if (effectiveSession && !claudeSessionId) { setClaudeSessionId(effectiveSession.id); } // Only clean up and set up new listeners if not already listening if (!isListeningRef.current) { // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Mark as setting up listeners isListeningRef.current = true; // -------------------------------------------------------------------- // 1️⃣ Event Listener Setup Strategy // -------------------------------------------------------------------- // Claude Code may emit a *new* session_id even when we pass --resume. If // we listen only on the old session-scoped channel we will miss the // stream until the user navigates away & back. To avoid this we: // • Always start with GENERIC listeners (no suffix) so we catch the // very first "system:init" message regardless of the session id. // • Once that init message provides the *actual* session_id, we // dynamically switch to session-scoped listeners and stop the // generic ones to prevent duplicate handling. // -------------------------------------------------------------------- console.log('[ClaudeCodeSession] Setting up generic event listeners first'); let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null; // Helper to attach session-specific listeners **once we are sure** const attachSessionSpecificListeners = async (sid: string) => { console.log('[ClaudeCodeSession] Attaching session-specific listeners for', sid); const specificOutputUnlisten = await listen(`claude-output:${sid}`, (evt) => { handleStreamMessage(evt.payload); }); const specificErrorUnlisten = await listen(`claude-error:${sid}`, (evt) => { console.error('Claude error (scoped):', evt.payload); setError(evt.payload); }); const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, (evt) => { console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload); processComplete(evt.payload); }); // Replace existing unlisten refs with these new ones (after cleaning up) unlistenRefs.current.forEach((u) => u()); unlistenRefs.current = [specificOutputUnlisten, specificErrorUnlisten, specificCompleteUnlisten]; }; // Generic listeners (catch-all) const genericOutputUnlisten = await listen('claude-output', async (event) => { handleStreamMessage(event.payload); // Attempt to extract session_id on the fly (for the very first init) try { const msg = JSON.parse(event.payload) as ClaudeStreamMessage; if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) { if (!currentSessionId || currentSessionId !== msg.session_id) { console.log('[ClaudeCodeSession] Detected new session_id from generic listener:', msg.session_id); currentSessionId = msg.session_id; setClaudeSessionId(msg.session_id); // If we haven't extracted session info before, do it now if (!extractedSessionInfo) { const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); setExtractedSessionInfo({ sessionId: msg.session_id, projectId }); } // Switch to session-specific listeners await attachSessionSpecificListeners(msg.session_id); } } } catch { /* ignore parse errors */ } }); // Helper to process any JSONL stream message string function handleStreamMessage(payload: string) { try { // Don't process if component unmounted if (!isMountedRef.current) return; // Store raw JSONL setRawJsonlOutput((prev) => [...prev, payload]); const message = JSON.parse(payload) as ClaudeStreamMessage; // Track enhanced tool execution if (message.type === 'assistant' && message.message?.content) { const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use'); toolUses.forEach((toolUse: any) => { // Increment tools executed counter sessionMetrics.current.toolsExecuted += 1; sessionMetrics.current.lastActivityTime = Date.now(); // Track file operations const toolName = toolUse.name?.toLowerCase() || ''; if (toolName.includes('create') || toolName.includes('write')) { sessionMetrics.current.filesCreated += 1; } else if (toolName.includes('edit') || toolName.includes('multiedit') || toolName.includes('search_replace')) { sessionMetrics.current.filesModified += 1; } else if (toolName.includes('delete')) { sessionMetrics.current.filesDeleted += 1; } // Track tool start - we'll track completion when we get the result workflowTracking.trackStep(toolUse.name); }); } // Track tool results if (message.type === 'user' && message.message?.content) { const toolResults = message.message.content.filter((c: any) => c.type === 'tool_result'); toolResults.forEach((result: any) => { const isError = result.is_error || false; // Note: We don't have execution time here, but we can track success/failure if (isError) { sessionMetrics.current.toolsFailed += 1; sessionMetrics.current.errorsEncountered += 1; trackEvent.enhancedError({ error_type: 'tool_execution', error_code: 'tool_failed', error_message: result.content, context: `Tool execution failed`, user_action_before_error: 'executing_tool', recovery_attempted: false, recovery_successful: false, error_frequency: 1, stack_trace_hash: undefined }); } }); } // Track code blocks generated if (message.type === 'assistant' && message.message?.content) { const codeBlocks = message.message.content.filter((c: any) => c.type === 'text' && c.text?.includes('```') ); if (codeBlocks.length > 0) { // Count code blocks in text content codeBlocks.forEach((block: any) => { const matches = (block.text.match(/```/g) || []).length; sessionMetrics.current.codeBlocksGenerated += Math.floor(matches / 2); }); } } // Track errors in system messages if (message.type === 'system' && (message.subtype === 'error' || message.error)) { sessionMetrics.current.errorsEncountered += 1; } setMessages((prev) => [...prev, message]); } catch (err) { console.error('Failed to parse message:', err, payload); } } // Helper to handle completion events (both generic and scoped) const processComplete = async (success: boolean) => { setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; // Reset listening state // Track enhanced session stopped metrics when session completes if (effectiveSession && claudeSessionId) { const sessionStartTimeValue = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); const duration = Date.now() - sessionStartTimeValue; const metrics = sessionMetrics.current; const timeToFirstMessage = metrics.firstMessageTime ? metrics.firstMessageTime - sessionStartTime.current : undefined; const idleTime = Date.now() - metrics.lastActivityTime; const avgResponseTime = metrics.toolExecutionTimes.length > 0 ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / metrics.toolExecutionTimes.length : undefined; trackEvent.enhancedSessionStopped({ // Basic metrics duration_ms: duration, messages_count: messages.length, reason: success ? 'completed' : 'error', // Timing metrics time_to_first_message_ms: timeToFirstMessage, average_response_time_ms: avgResponseTime, idle_time_ms: idleTime, // Interaction metrics prompts_sent: metrics.promptsSent, tools_executed: metrics.toolsExecuted, tools_failed: metrics.toolsFailed, files_created: metrics.filesCreated, files_modified: metrics.filesModified, files_deleted: metrics.filesDeleted, // Content metrics total_tokens_used: totalTokens, code_blocks_generated: metrics.codeBlocksGenerated, errors_encountered: metrics.errorsEncountered, // Session context model: metrics.modelChanges.length > 0 ? metrics.modelChanges[metrics.modelChanges.length - 1].to : 'sonnet', has_checkpoints: metrics.checkpointCount > 0, checkpoint_count: metrics.checkpointCount, was_resumed: metrics.wasResumed, // Agent context (if applicable) agent_type: undefined, // TODO: Pass from agent execution agent_name: undefined, // TODO: Pass from agent execution agent_success: success, // Stop context stop_source: 'completed', final_state: success ? 'success' : 'failed', has_pending_prompts: queuedPrompts.length > 0, pending_prompts_count: queuedPrompts.length, }); } if (effectiveSession && success) { 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); } } // Process queued prompts after completion if (queuedPromptsRef.current.length > 0) { const [nextPrompt, ...remainingPrompts] = queuedPromptsRef.current; setQueuedPrompts(remainingPrompts); // Small delay to ensure UI updates setTimeout(() => { handleSendPrompt(nextPrompt.prompt, nextPrompt.model); }, 100); } }; const genericErrorUnlisten = await listen('claude-error', (evt) => { console.error('Claude error:', evt.payload); setError(evt.payload); }); const genericCompleteUnlisten = await listen('claude-complete', (evt) => { console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload); processComplete(evt.payload); }); // Store the generic unlisteners for now; they may be replaced later. unlistenRefs.current = [genericOutputUnlisten, genericErrorUnlisten, genericCompleteUnlisten]; // -------------------------------------------------------------------- // 2️⃣ Auto-checkpoint logic moved after listener setup (unchanged) // -------------------------------------------------------------------- // 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]); // Update session metrics sessionMetrics.current.promptsSent += 1; sessionMetrics.current.lastActivityTime = Date.now(); if (!sessionMetrics.current.firstMessageTime) { sessionMetrics.current.firstMessageTime = Date.now(); } // Track model changes const lastModel = sessionMetrics.current.modelChanges.length > 0 ? sessionMetrics.current.modelChanges[sessionMetrics.current.modelChanges.length - 1].to : (sessionMetrics.current.wasResumed ? 'sonnet' : model); // Default to sonnet if resumed if (lastModel !== model) { sessionMetrics.current.modelChanges.push({ from: lastModel, to: model, timestamp: Date.now() }); } // Track enhanced prompt submission const codeBlockMatches = prompt.match(/```[\s\S]*?```/g) || []; const hasCode = codeBlockMatches.length > 0; const conversationDepth = messages.filter(m => m.user_message).length; const sessionAge = sessionStartTime.current ? Date.now() - sessionStartTime.current : 0; const wordCount = prompt.split(/\s+/).filter(word => word.length > 0).length; trackEvent.enhancedPromptSubmitted({ prompt_length: prompt.length, model: model, has_attachments: false, // TODO: Add attachment support when implemented source: 'keyboard', // TODO: Track actual source (keyboard vs button) word_count: wordCount, conversation_depth: conversationDepth, prompt_complexity: wordCount < 20 ? 'simple' : wordCount < 100 ? 'moderate' : 'complex', contains_code: hasCode, language_detected: hasCode ? codeBlockMatches?.[0]?.match(/```(\w+)/)?.[1] : undefined, session_age_ms: sessionAge }); // Execute the appropriate command if (effectiveSession && !isFirstPrompt) { console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); trackEvent.sessionResumed(effectiveSession.id); trackEvent.modelSelected(model); await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); } else { console.log('[ClaudeCodeSession] Starting new session'); setIsFirstPrompt(false); trackEvent.sessionCreated(model, 'prompt_input'); trackEvent.modelSelected(model); 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 handleCheckpointCreated = () => { // Update checkpoint count in session metrics sessionMetrics.current.checkpointCount += 1; }; const handleCancelExecution = async () => { if (!claudeSessionId || !isLoading) return; try { const sessionStartTime = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); const duration = Date.now() - sessionStartTime; await api.cancelClaudeExecution(claudeSessionId); // Calculate metrics for enhanced analytics const metrics = sessionMetrics.current; const timeToFirstMessage = metrics.firstMessageTime ? metrics.firstMessageTime - sessionStartTime.current : undefined; const idleTime = Date.now() - metrics.lastActivityTime; const avgResponseTime = metrics.toolExecutionTimes.length > 0 ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / metrics.toolExecutionTimes.length : undefined; // Track enhanced session stopped trackEvent.enhancedSessionStopped({ // Basic metrics duration_ms: duration, messages_count: messages.length, reason: 'user_stopped', // Timing metrics time_to_first_message_ms: timeToFirstMessage, average_response_time_ms: avgResponseTime, idle_time_ms: idleTime, // Interaction metrics prompts_sent: metrics.promptsSent, tools_executed: metrics.toolsExecuted, tools_failed: metrics.toolsFailed, files_created: metrics.filesCreated, files_modified: metrics.filesModified, files_deleted: metrics.filesDeleted, // Content metrics total_tokens_used: totalTokens, code_blocks_generated: metrics.codeBlocksGenerated, errors_encountered: metrics.errorsEncountered, // Session context model: metrics.modelChanges.length > 0 ? metrics.modelChanges[metrics.modelChanges.length - 1].to : 'sonnet', // Default to sonnet has_checkpoints: metrics.checkpointCount > 0, checkpoint_count: metrics.checkpointCount, was_resumed: metrics.wasResumed, // Agent context (if applicable) agent_type: undefined, // TODO: Pass from agent execution agent_name: undefined, // TODO: Pass from agent execution agent_success: undefined, // TODO: Pass from agent execution // Stop context stop_source: 'user_button', final_state: 'cancelled', has_pending_prompts: queuedPrompts.length > 0, pending_prompts_count: queuedPrompts.length, }); // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Reset states setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; setError(null); // Clear queued prompts setQueuedPrompts([]); // Add a message indicating the session was cancelled const cancelMessage: ClaudeStreamMessage = { type: "system", subtype: "info", result: "Session cancelled by user", timestamp: new Date().toISOString() }; setMessages(prev => [...prev, cancelMessage]); } catch (err) { console.error("Failed to cancel execution:", err); // Even if backend fails, we should update UI to reflect stopped state // Add error message but still stop the UI loading state const errorMessage: ClaudeStreamMessage = { type: "system", subtype: "error", result: `Failed to cancel execution: ${err instanceof Error ? err.message : 'Unknown error'}. The process may still be running in the background.`, timestamp: new Date().toISOString() }; setMessages(prev => [...prev, errorMessage]); // Clean up listeners anyway unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Reset states to allow user to continue setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; setError(null); } }; 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).substring(2, 11)}`; 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); } }; // 处理URL检测 const handleLinkDetected = (url: string) => { if (!layout.previewUrl && !showPreviewPrompt) { openLayoutPreview(url); setShowPreviewPrompt(true); } }; // 监听滚动位置 useEffect(() => { const scrollContainer = parentRef.current; if (!scrollContainer) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = scrollContainer; setIsAtTop(scrollTop < 10); setIsAtBottom(scrollTop + clientHeight >= scrollHeight - 10); setShowScrollButtons(scrollHeight > clientHeight); }; handleScroll(); // 初始检查 scrollContainer.addEventListener('scroll', handleScroll); // 监听内容变化 const observer = new ResizeObserver(handleScroll); observer.observe(scrollContainer); return () => { scrollContainer.removeEventListener('scroll', handleScroll); observer.disconnect(); }; }, []); const handleTogglePreviewMaximize = () => { setIsPreviewMaximized(!isPreviewMaximized); // 重置分割位置 if (isPreviewMaximized) { setLayoutSplitPosition(50); } }; const handlePreviewUrlChange = (url: string) => { console.log('[ClaudeCodeSession] Preview URL changed to:', url); openLayoutPreview(url); }; // Cleanup event listeners and track mount state useEffect(() => { isMountedRef.current = true; return () => { console.log('[ClaudeCodeSession] Component unmounting, cleaning up listeners'); isMountedRef.current = false; isListeningRef.current = false; // Track session completion with engagement metrics if (effectiveSession) { trackEvent.sessionCompleted(); // Track session engagement const sessionDuration = sessionStartTime.current ? Date.now() - sessionStartTime.current : 0; const messageCount = messages.filter(m => m.user_message).length; const toolsUsed = new Set(); messages.forEach(msg => { if (msg.type === 'assistant' && msg.message?.content) { const tools = msg.message.content.filter((c: any) => c.type === 'tool_use'); tools.forEach((tool: any) => toolsUsed.add(tool.name)); } }); // Calculate engagement score (0-100) const engagementScore = Math.min(100, (messageCount * 10) + (toolsUsed.size * 5) + (sessionDuration > 300000 ? 20 : sessionDuration / 15000) // 5+ min session gets 20 points ); trackEvent.sessionEngagement({ session_duration_ms: sessionDuration, messages_sent: messageCount, tools_used: Array.from(toolsUsed), files_modified: 0, // TODO: Track file modifications engagement_score: Math.round(engagementScore) }); } // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // 清理文件监控 if (fileWatcherUnlistenRef.current) { fileWatcherUnlistenRef.current(); fileWatcherUnlistenRef.current = null; } // 停止文件监控 if (projectPath && isFileWatching) { api.unwatchDirectory(projectPath).catch(err => { console.error("[FileMonitor] Failed to unwatch directory:", err); }); } // Clear checkpoint manager when session ends if (effectiveSession) { api.clearCheckpointManager(effectiveSession.id).catch(err => { console.error("Failed to clear checkpoint manager:", err); }); } }; }, [effectiveSession, projectPath]); const messagesList = (
{displayableMessages.length === 0 ? (

开始对话或等待消息加载...

) : ( 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-3" style={{ top: virtualItem.start, }} > ); }) )}
{/* Loading indicator under the latest message */} {isLoading && (
)} {/* Error indicator */} {error && ( {error} )} {/* 滚动按钮和文件监控小点 */} {(showScrollButtons || isFileWatching) && ( {/* 文件监控小绿点 */} {isFileWatching && !fileMonitorExpanded && (
setFileMonitorExpanded(true)} className="relative cursor-pointer group self-center" >
{/* 脉冲效果 */} {isFileWatching && fileChanges.length > 0 && (
)}
{/* 悬浮提示 */}
文件监控 {fileChanges.length > 0 && `(${fileChanges.length})`}
{/* 变化数量小徽章 */} {fileChanges.length > 0 && (
{fileChanges.length > 9 ? '9+' : fileChanges.length}
)}
)} {/* 滚动到顶部按钮 */} {!isAtTop && (

滚动到顶部

)} {/* 滚动到底部按钮 */} {!isAtBottom && (

滚动到底部

)} )}
); const projectPathInput = !session && (
setProjectPath(e.target.value)} placeholder="/path/to/your/project" className="flex-1" disabled={isLoading} />
); // If terminal is maximized, render only the Terminal in full screen if (layout.activeView === 'terminal' && layout.isTerminalMaximized) { return ( ); } // If preview is maximized, render only the WebviewPreview in full screen if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) { return ( ); } return (
{/* Header */}

{t('app.claudeCodeSession')}

{projectPath ? `${projectPath}` : "No project selected"}

{/* Token计数器 */} {totalTokens > 0 && (
{totalTokens.toLocaleString()} tokens
)} {/* Terminal Toggle */} {projectPath && (

终端

)} {/* File Explorer Toggle */} {projectPath && (

File Explorer

)} {/* Git Panel Toggle */} {projectPath && (

Git Panel

)} {/* File Monitor Toggle */} {projectPath && (

{isFileWatching ? '停止文件监控' : '启动文件监控'}

)} {projectPath && onProjectSettings && (

{t('agents.hooks')}

)} {projectPath && (

{t('app.commands')}

)}

{t('checkpoint.checkpointSettingsTitle')}

{effectiveSession && (

{t('app.timeline')}

)} {messages.length > 0 && ( {t('app.copyOutput')} } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} /> )}
{/* 使用新的 FlexLayoutContainer 替代 GridLayoutContainer */} { floatingPromptRef.current?.addImage(path); }} onFileOpen={(path) => { openFileEditor(path); }} onToggle={toggleFileExplorer} /> ) }, // 主内容区域 { id: 'main-content', position: 'center', visible: true, content: ( {layout.activeView === 'terminal' ? ( // 终端视图 ) : layout.activeView === 'editor' && layout.editingFile ? ( // 文件编辑器视图 ) : layout.activeView === 'preview' && layout.previewUrl ? ( // 预览视图
} /> } right={ } initialSplit={layout.splitPosition} onSplitChange={(position) => { setLayoutSplitPosition(position); }} minLeftWidth={400} minRightWidth={400} className="h-full" /> ) : ( // 默认聊天视图
} floatingElements={ <> {/* 文件监控展开面板 */} {isFileWatching && fileMonitorExpanded && (
文件变化监控
{!fileMonitorCollapsed && (
{fileChanges.map((change, index) => { const getChangeIcon = () => { switch (change.changeType) { case 'created': return ; case 'modified': return ; case 'deleted': return ; case 'renamed': return ; default: return ; } }; return ( {getChangeIcon()}
{change.path}
{change.changeType} • {new Date(change.timestamp).toLocaleTimeString()}
); })} {fileChanges.length === 0 && isFileWatching && (
监控中,等待文件变化...
)}
)}
)} {/* 排队提示显示 */} {queuedPrompts.length > 0 && (
Queued Prompts ({queuedPrompts.length})
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
#{index + 1} {queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}

{queuedPrompt.prompt}

))}
)}
} /> )} ) }, // Git 面板 { id: 'git-panel', position: 'right', visible: layout.showGitPanel, defaultWidth: layout.gitPanelWidth, minWidth: 200, maxWidth: 500, resizable: !breakpoints.isMobile, content: ( ) }, // 时间线面板(仅桌面端) ...(layout.showTimeline && effectiveSession && !breakpoints.isMobile ? [{ id: 'timeline', position: 'right' as const, visible: true, defaultWidth: layout.timelineWidth, minWidth: 320, maxWidth: 600, resizable: true, content: ( ) }] : []) ]} onPanelResize={(panelId, width) => { if (panelId === 'file-explorer') { setPanelWidth('fileExplorer', width); } else if (panelId === 'git-panel') { setPanelWidth('gitPanel', width); } else if (panelId === 'timeline') { setPanelWidth('timeline', width); } }} savedWidths={{ 'file-explorer': layout.fileExplorerWidth, 'git-panel': layout.gitPanelWidth, 'timeline': layout.timelineWidth, }} />
{/* Fork Dialog */} Fork Session Create a new session branch from the selected checkpoint.
setForkSessionName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) { handleConfirmFork(); } }} />
{/* Settings Dialog */} {showSettings && effectiveSession && ( {t('checkpoint.checkpointSettingsTitle')} {t('app.checkpointingWarning')} setShowSettings(false)} /> )} {/* Slash Commands Settings Dialog */} {showSlashCommandsSettings && ( {t('slashCommands.slashCommands')} {t('slashCommands.manageProjectCommands')} {projectPath}
)}
); }; // Add default export for lazy loading export default ClaudeCodeSession;