Files
claudia/src/components/ClaudeCodeSession.tsx
2025-08-15 00:29:57 +08:00

2270 lines
88 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
* <ClaudeCodeSession onBack={() => setView('projects')} />
*/
export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
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<ClaudeStreamMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
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<string | null>(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<string | null>(null);
const [forkSessionName, setForkSessionName] = useState("");
// Queued prompts state
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" | "opus-plan" }>>([]);
// 使用布局管理器的预览功能
// 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<FileChange[]>([]);
const [isFileWatching, setIsFileWatching] = useState(false);
const [fileMonitorCollapsed, setFileMonitorCollapsed] = useState(false);
const [fileMonitorExpanded, setFileMonitorExpanded] = useState(false);
// File editor state
// 移除重复的状态,使用 layout 中的状态
// const [editingFile, setEditingFile] = useState<string | null>(null); // 移除,使用 layout.editingFile
const parentRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const hasActiveSessionRef = useRef(false);
const floatingPromptRef = useRef<FloatingPromptInputRef>(null);
const queuedPromptsRef = useRef<Array<{ id: string; prompt: string; model: "sonnet" | "opus" | "opus-plan" }>>([]);
const isMountedRef = useRef(true);
const isListeningRef = useRef(false);
const sessionStartTime = useRef<number>(Date.now());
const fileWatcherUnlistenRef = useRef<UnlistenFn | null>(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<any>('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<string>(`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<string>(`claude-error:${sessionId}`, (event) => {
console.error("Claude error:", event.payload);
if (isMountedRef.current) {
setError(event.payload);
}
});
const completeUnlisten = await listen<boolean>(`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<string>(`claude-output:${sid}`, (evt) => {
handleStreamMessage(evt.payload);
});
const specificErrorUnlisten = await listen<string>(`claude-error:${sid}`, (evt) => {
console.error('Claude error (scoped):', evt.payload);
setError(evt.payload);
});
const specificCompleteUnlisten = await listen<boolean>(`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<string>('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<string>('claude-error', (evt) => {
console.error('Claude error:', evt.payload);
setError(evt.payload);
});
const genericCompleteUnlisten = await listen<boolean>('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<string>();
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 = (
<div
ref={parentRef}
className="h-full overflow-y-auto relative pb-2"
>
<div
className="relative w-full max-w-5xl mx-auto px-4 pt-3 pb-2"
style={{
height: displayableMessages.length === 0 ? '100%' : `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
minHeight: '100px',
}}
>
<AnimatePresence>
{displayableMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
<TerminalIcon className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">...</p>
</div>
) : (
rowVirtualizer.getVirtualItems().map((virtualItem) => {
const message = displayableMessages[virtualItem.index];
return (
<motion.div
key={virtualItem.key}
data-index={virtualItem.index}
ref={(el) => 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,
}}
>
<StreamMessage
message={message}
streamMessages={messages}
onLinkDetected={handleLinkDetected}
/>
</motion.div>
);
})
)}
</AnimatePresence>
</div>
{/* Loading indicator under the latest message */}
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center py-2 mb-4"
>
<div className="rotating-symbol text-primary" />
</motion.div>
)}
{/* Error indicator */}
{error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive w-full max-w-5xl mx-auto mb-4"
>
{error}
</motion.div>
)}
{/* 滚动按钮和文件监控小点 */}
<AnimatePresence>
{(showScrollButtons || isFileWatching) && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="fixed bottom-20 right-6 z-40 flex flex-col gap-2"
>
{/* 文件监控小绿点 */}
{isFileWatching && !fileMonitorExpanded && (
<div
onClick={() => setFileMonitorExpanded(true)}
className="relative cursor-pointer group self-center"
>
<div className={cn(
"w-4 h-4 rounded-full shadow-lg border-2 border-background transition-all duration-200 group-hover:scale-110",
isFileWatching ? "bg-green-500" : "bg-gray-400"
)}>
{/* 脉冲效果 */}
{isFileWatching && fileChanges.length > 0 && (
<div className="absolute inset-0 rounded-full bg-green-500 animate-ping opacity-30" />
)}
</div>
{/* 悬浮提示 */}
<div className="absolute bottom-full right-0 mb-2 px-2 py-1 bg-background/95 backdrop-blur-sm border rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
{fileChanges.length > 0 && `(${fileChanges.length})`}
</div>
{/* 变化数量小徽章 */}
{fileChanges.length > 0 && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 text-white text-[8px] rounded-full flex items-center justify-center font-bold">
{fileChanges.length > 9 ? '9+' : fileChanges.length}
</div>
)}
</div>
)}
{/* 滚动到顶部按钮 */}
{!isAtTop && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={scrollToTop}
className="h-9 w-9 rounded-full shadow-lg bg-background/95 backdrop-blur"
>
<ArrowUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* 滚动到底部按钮 */}
{!isAtBottom && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={scrollToBottom}
className="h-9 w-9 rounded-full shadow-lg bg-background/95 backdrop-blur"
>
<ArrowDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
const projectPathInput = !session && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="p-4 border-b border-border flex-shrink-0"
>
<Label htmlFor="project-path" className="text-sm font-medium">
Project Directory
</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id="project-path"
value={projectPath}
onChange={(e) => setProjectPath(e.target.value)}
placeholder="/path/to/your/project"
className="flex-1"
disabled={isLoading}
/>
<Button
onClick={handleSelectPath}
size="icon"
variant="outline"
disabled={isLoading}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</motion.div>
);
// If terminal is maximized, render only the Terminal in full screen
if (layout.activeView === 'terminal' && layout.isTerminalMaximized) {
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 bg-background"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<Terminal
onClose={closeTerminal}
isMaximized={layout.isTerminalMaximized}
onToggleMaximize={toggleTerminalMaximize}
projectPath={projectPath}
className="h-full"
/>
</motion.div>
</AnimatePresence>
);
}
// If preview is maximized, render only the WebviewPreview in full screen
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 bg-background"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<WebviewPreview
initialUrl={layout.previewUrl || ''}
onClose={handleClosePreview}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
onUrlChange={handlePreviewUrlChange}
className="h-full"
/>
</motion.div>
</AnimatePresence>
);
}
return (
<div className={cn("flex flex-col h-full bg-background relative", getResponsiveClasses(), className)}>
<div className="w-full h-full flex flex-col">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center justify-between p-4 border-b border-border"
>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
<div className="flex-1">
<h1 className="text-xl font-bold">{t('app.claudeCodeSession')}</h1>
<p className="text-sm text-muted-foreground">
{projectPath ? `${projectPath}` : "No project selected"}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Token计数器 */}
{totalTokens > 0 && (
<div className="flex items-center gap-1.5 text-xs bg-muted/50 rounded-full px-2.5 py-1">
<Hash className="h-3 w-3 text-muted-foreground" />
<span className="font-mono">{totalTokens.toLocaleString()}</span>
<span className="text-muted-foreground">tokens</span>
</div>
)}
{/* Terminal Toggle */}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={openTerminal}
className={cn("h-8 w-8", layout.activeView === 'terminal' && "text-primary")}
>
<Square className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* File Explorer Toggle */}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleFileExplorer}
className={cn("h-8 w-8", layout.showFileExplorer && "text-primary")}
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>File Explorer</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Git Panel Toggle */}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleGitPanel}
className={cn("h-8 w-8", layout.showGitPanel && "text-primary")}
>
<PanelRightOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Git Panel</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* File Monitor Toggle */}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleFileWatching}
className={cn("h-8 w-8", isFileWatching && "text-primary")}
>
{isFileWatching ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isFileWatching ? '停止文件监控' : '启动文件监控'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{projectPath && onProjectSettings && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onProjectSettings(projectPath)}
disabled={isLoading}
className="h-8 w-8"
>
<Settings2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('agents.hooks')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{projectPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSlashCommandsSettings(true)}
disabled={isLoading}
className="h-8 w-8"
>
<Command className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.commands')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSettings(!showSettings)}
className="h-8 w-8"
>
<Settings className={cn("h-4 w-4", showSettings && "text-primary")} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('checkpoint.checkpointSettingsTitle')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{effectiveSession && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleTimeline}
className="h-8 w-8"
>
<GitBranch className={cn("h-4 w-4", layout.showTimeline && "text-primary")} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('app.timeline')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{messages.length > 0 && (
<Popover
trigger={
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
{t('app.copyOutput')}
<ChevronDown className="h-3 w-3" />
</Button>
}
content={
<div className="w-44 p-1">
<Button
variant="ghost"
size="sm"
onClick={handleCopyAsMarkdown}
className="w-full justify-start"
>
{t('app.copyAsMarkdown')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCopyAsJsonl}
className="w-full justify-start"
>
{t('app.copyAsJsonl')}
</Button>
</div>
}
open={copyPopoverOpen}
onOpenChange={setCopyPopoverOpen}
/>
)}
</div>
</div>
</motion.div>
{/* 使用新的 FlexLayoutContainer 替代 GridLayoutContainer */}
<FlexLayoutContainer
className="flex-1 overflow-hidden"
mainContentId="main-content"
panels={[
// 文件浏览器面板
{
id: 'file-explorer',
position: 'left',
visible: layout.showFileExplorer,
defaultWidth: layout.fileExplorerWidth,
minWidth: 200,
maxWidth: 500,
resizable: !breakpoints.isMobile,
content: (
<FileExplorerPanelEnhanced
projectPath={projectPath}
isVisible={true}
onFileSelect={(path) => {
floatingPromptRef.current?.addImage(path);
}}
onFileOpen={(path) => {
openFileEditor(path);
}}
onToggle={toggleFileExplorer}
/>
)
},
// 主内容区域
{
id: 'main-content',
position: 'center',
visible: true,
content: (
<MainContentArea isEditing={layout.activeView === 'editor'}>
{layout.activeView === 'terminal' ? (
// 终端视图
<Terminal
onClose={closeTerminal}
isMaximized={layout.isTerminalMaximized}
onToggleMaximize={toggleTerminalMaximize}
projectPath={projectPath}
className="h-full"
/>
) : layout.activeView === 'editor' && layout.editingFile ? (
// 文件编辑器视图
<FileEditorEnhanced
filePath={layout.editingFile}
onClose={closeFileEditor}
className="h-full"
/>
) : layout.activeView === 'preview' && layout.previewUrl ? (
// 预览视图
<SplitPane
left={
<ChatView
projectPathInput={projectPathInput}
messagesList={messagesList}
floatingInput={
<div className="w-full max-w-5xl mx-auto px-4">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
}
/>
}
right={
<WebviewPreview
initialUrl={layout.previewUrl}
onClose={handleClosePreview}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
onUrlChange={handlePreviewUrlChange}
/>
}
initialSplit={layout.splitPosition}
onSplitChange={(position) => {
setLayoutSplitPosition(position);
}}
minLeftWidth={400}
minRightWidth={400}
className="h-full"
/>
) : (
// 默认聊天视图
<ChatView
projectPathInput={projectPathInput}
messagesList={messagesList}
floatingInput={
<div className="w-full max-w-5xl mx-auto px-4">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
}
floatingElements={
<>
{/* 文件监控展开面板 */}
<AnimatePresence>
{isFileWatching && fileMonitorExpanded && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="fixed bottom-20 right-4 z-30 pointer-events-auto w-80"
>
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"></span>
<div className={cn(
"w-2 h-2 rounded-full",
isFileWatching ? "bg-green-500" : "bg-gray-400"
)} />
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setFileMonitorCollapsed(!fileMonitorCollapsed)}
className="h-6 w-6"
>
{fileMonitorCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={clearFileChanges}
className="h-6 w-6"
>
<X className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setFileMonitorExpanded(false)}
className="h-6 w-6"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{!fileMonitorCollapsed && (
<div className="max-h-64 overflow-y-auto space-y-1">
{fileChanges.map((change, index) => {
const getChangeIcon = () => {
switch (change.changeType) {
case 'created':
return <FilePlus className="h-3 w-3 text-green-500" />;
case 'modified':
return <FileText className="h-3 w-3 text-yellow-500" />;
case 'deleted':
return <FileX className="h-3 w-3 text-red-500" />;
case 'renamed':
return <FileText className="h-3 w-3 text-blue-500" />;
default:
return <FileText className="h-3 w-3 text-gray-500" />;
}
};
return (
<motion.div
key={`${change.path}-${change.timestamp}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.02 }}
className="flex items-start gap-2 p-2 bg-muted/30 rounded text-xs"
>
{getChangeIcon()}
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate" title={change.path}>
{change.path}
</div>
<div className="text-xs text-muted-foreground">
{change.changeType} {new Date(change.timestamp).toLocaleTimeString()}
</div>
</div>
</motion.div>
);
})}
{fileChanges.length === 0 && isFileWatching && (
<div className="text-center py-4 text-muted-foreground text-xs">
...
</div>
)}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* 排队提示显示 */}
<AnimatePresence>
{queuedPrompts.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="absolute bottom-20 left-0 right-0 z-30 pointer-events-auto px-4"
>
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground mb-1">
Queued Prompts ({queuedPrompts.length})
</div>
<Button variant="ghost" size="icon" onClick={() => setQueuedPromptsCollapsed(prev => !prev)}>
{queuedPromptsCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</Button>
</div>
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
<motion.div
key={queuedPrompt.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
className="flex items-start gap-2 bg-muted/50 rounded-md p-2"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground">#{index + 1}</span>
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
</span>
</div>
<p className="text-sm line-clamp-2 break-words">{queuedPrompt.prompt}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</>
}
/>
)}
</MainContentArea>
)
},
// Git 面板
{
id: 'git-panel',
position: 'right',
visible: layout.showGitPanel,
defaultWidth: layout.gitPanelWidth,
minWidth: 200,
maxWidth: 500,
resizable: !breakpoints.isMobile,
content: (
<GitPanelEnhanced
projectPath={projectPath}
isVisible={true}
onToggle={toggleGitPanel}
/>
)
},
// 时间线面板(仅桌面端)
...(layout.showTimeline && effectiveSession && !breakpoints.isMobile ? [{
id: 'timeline',
position: 'right' as const,
visible: true,
defaultWidth: layout.timelineWidth,
minWidth: 320,
maxWidth: 600,
resizable: true,
content: (
<SidePanel
title={t('app.sessionTimeline')}
onClose={toggleTimeline}
position="right"
>
<TimelineNavigator
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
projectPath={projectPath}
currentMessageIndex={messages.length - 1}
onCheckpointSelect={handleCheckpointSelect}
onFork={handleFork}
onCheckpointCreated={handleCheckpointCreated}
refreshVersion={timelineVersion}
/>
</SidePanel>
)
}] : [])
]}
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,
}}
/>
</div>
{/* Fork Dialog */}
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Fork Session</DialogTitle>
<DialogDescription>
Create a new session branch from the selected checkpoint.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="fork-name">New Session Name</Label>
<Input
id="fork-name"
placeholder="e.g., Alternative approach"
value={forkSessionName}
onChange={(e) => setForkSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isLoading) {
handleConfirmFork();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowForkDialog(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirmFork}
disabled={isLoading || !forkSessionName.trim()}
>
Create Fork
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Settings Dialog */}
{showSettings && effectiveSession && (
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('checkpoint.checkpointSettingsTitle')}</DialogTitle>
<DialogDescription>
{t('app.checkpointingWarning')}
</DialogDescription>
</DialogHeader>
<CheckpointSettings
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
projectPath={projectPath}
onClose={() => setShowSettings(false)}
/>
</DialogContent>
</Dialog>
)}
{/* Slash Commands Settings Dialog */}
{showSlashCommandsSettings && (
<Dialog open={showSlashCommandsSettings} onOpenChange={setShowSlashCommandsSettings}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogTitle>{t('slashCommands.slashCommands')}</DialogTitle>
<DialogDescription>
{t('slashCommands.manageProjectCommands')} {projectPath}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<SlashCommandsManager projectPath={projectPath} />
</div>
</DialogContent>
</Dialog>
)}
</div>
);
};
// Add default export for lazy loading
export default ClaudeCodeSession;