From f394a246f8866337fe7ab4183a2f0091fa482daa Mon Sep 17 00:00:00 2001 From: iflytwice Date: Tue, 23 Sep 2025 18:20:33 -0400 Subject: [PATCH] feat: Add comprehensive UI improvements and session management features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces multiple user experience improvements and new functionality: ## โœจ New Features ### Auto-scroll Control - Add auto-scroll setting in Settings panel to control conversation auto-scrolling behavior - Implement `useAutoScroll` hook with dual-storage (localStorage + SQLite) for persistence - Allow users to manually scroll through conversation history without being forced to bottom - Setting persists across sessions and is immediately applied ### Session Deletion Functionality - Add individual session deletion with confirmation dialog - Add bulk session deletion with multi-select mode - Implement proper async confirmation handling for Tauri environment - Add session persistence cleanup when sessions are deleted - Support for hierarchical session deletion (primary + child sessions) ### Collapsible Tool Widgets - Make SystemInitializedWidget, ReadWidget, WriteWidget, and EditWidget collapsible - Prevent UI freezing with large file content previews - Add expand/collapse functionality with smooth animations - Maintain functionality while improving performance ### Image Placeholder System - Replace long image file paths with clean `[image]` placeholders in UI - Support multiple images with `[image #2]`, `[image #3]` numbering - Maintain actual file paths for backend processing - Add image preview functionality above prompt input - Implement proper path mapping between placeholders and actual files ## ๐Ÿ”ง Backend Improvements - Add `delete_session` and `delete_sessions_bulk` Tauri commands - Add `save_pasted_image` command for handling image uploads - Implement session persistence service with conversation tracking - Add proper error handling and logging for all new operations - Update API layer with new session management endpoints ## ๐ŸŽจ UI/UX Enhancements - Improve message display with clean image path presentation - Add hierarchical session organization with expand/collapse - Add proper loading states and error handling - Implement smooth animations for better user experience - Add proper confirmation dialogs with clear messaging ## ๐Ÿ› ๏ธ Technical Details - Update session store with deletion capabilities - Add proper TypeScript types for all new functionality - Implement proper cleanup of localStorage data - Add comprehensive error handling and user feedback - Follow existing code patterns and conventions ## ๐Ÿงช Testing - All features tested in development environment - Confirmed proper persistence across app restarts - Verified error handling and edge cases - Tested with various file types and sizes Breaking Changes: None Deprecations: None Fixes: Session deletion confirmation, image preview display, large file UI freezing --- src-tauri/src/commands/claude.rs | 244 +++++++ src-tauri/src/main.rs | 21 +- src/components/AgentExecution.tsx | 17 +- src/components/AgentRunOutputViewer.tsx | 12 +- src/components/ClaudeCodeSession.tsx | 131 +++- src/components/FloatingPromptInput.tsx | 264 +++++--- src/components/SessionList.tsx | 633 ++++++++++++++++-- src/components/SessionOutputViewer.tsx | 18 +- src/components/Settings.tsx | 36 + src/components/StreamMessage.tsx | 42 +- src/components/TabContent.tsx | 10 +- src/components/ToolWidgets.tsx | 399 +++++++---- .../claude-code-session/MessageList.tsx | 19 +- src/hooks/index.ts | 7 +- src/lib/api.ts | 46 ++ src/services/sessionPersistence.ts | 58 +- src/stores/sessionStore.ts | 7 +- 17 files changed, 1616 insertions(+), 348 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index b27fc2c..58e5aac 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use base64; use serde::{Deserialize, Serialize}; use std::fs; use std::io::{BufRead, BufReader}; @@ -2179,3 +2180,246 @@ pub async fn validate_hook_command(command: String) -> Result Err(format!("Failed to validate command: {}", e)), } } + +/// Deletes a session file and its associated data +/// +/// This function removes a session's JSONL file from the project directory +/// and also cleans up any associated todo data if it exists. +/// +/// # Arguments +/// * `session_id` - The UUID of the session to delete +/// * `project_id` - The ID of the project containing the session +/// +/// # Returns +/// * `Ok(String)` - Success message with session ID +/// * `Err(String)` - Error message if deletion fails +/// +/// # Errors +/// * Project directory not found +/// * Permission denied when deleting files +/// * File system errors during deletion +#[tauri::command] +pub async fn delete_session(session_id: String, project_id: String) -> Result { + log::info!( + "Deleting session: {} from project: {}", + session_id, + project_id + ); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let project_dir = claude_dir.join("projects").join(&project_id); + + // Check if project directory exists + if !project_dir.exists() { + return Err(format!("Project directory not found: {}", project_id)); + } + + // Delete the session JSONL file + let session_file = project_dir.join(format!("{}.jsonl", session_id)); + if session_file.exists() { + fs::remove_file(&session_file) + .map_err(|e| format!("Failed to delete session file: {}", e))?; + log::info!("Deleted session file: {:?}", session_file); + } else { + log::warn!("Session file not found: {:?}", session_file); + } + + // Delete associated todo data if it exists + let todos_dir = project_dir.join("todos"); + if todos_dir.exists() { + let todo_file = todos_dir.join(format!("{}.json", session_id)); + if todo_file.exists() { + fs::remove_file(&todo_file) + .map_err(|e| format!("Failed to delete todo file: {}", e))?; + log::info!("Deleted todo file: {:?}", todo_file); + } + } + + Ok(format!("Session {} deleted successfully", session_id)) +} + +/// Deletes multiple sessions and their associated data +/// +/// This function deletes multiple session files and their associated todo data. +/// +/// # Arguments +/// * `session_ids` - A vector of session IDs to delete +/// * `project_id` - The ID of the project containing the sessions +/// +/// # Returns +/// * `Ok(String)` - Success message with count of deleted sessions +/// * `Err(String)` - Error message if deletion fails +/// +/// # Errors +/// * Project directory not found +/// * Permission denied when deleting files +/// * File system errors during deletion +#[tauri::command] +pub async fn delete_sessions_bulk( + session_ids: Vec, + project_id: String, +) -> Result { + log::info!( + "Bulk deleting {} sessions from project: {}", + session_ids.len(), + project_id + ); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let project_dir = claude_dir.join("projects").join(&project_id); + + // Check if project directory exists + if !project_dir.exists() { + return Err(format!("Project directory not found: {}", project_id)); + } + + let mut deleted_count = 0; + let mut errors = Vec::new(); + + for session_id in session_ids { + // Delete the session JSONL file + let session_file = project_dir.join(format!("{}.jsonl", session_id)); + if session_file.exists() { + match fs::remove_file(&session_file) { + Ok(_) => { + log::info!("Deleted session file: {:?}", session_file); + deleted_count += 1; + } + Err(e) => { + let error_msg = format!("Failed to delete session {}: {}", session_id, e); + log::error!("{}", error_msg); + errors.push(error_msg); + continue; + } + } + } else { + log::warn!("Session file not found: {:?}", session_file); + } + + // Delete associated todo data if it exists + let todos_dir = project_dir.join("todos"); + if todos_dir.exists() { + let todo_file = todos_dir.join(format!("{}.json", session_id)); + if todo_file.exists() { + if let Err(e) = fs::remove_file(&todo_file) { + log::warn!("Failed to delete todo file for session {}: {}", session_id, e); + // Don't count this as a fatal error since the main session was deleted + } else { + log::info!("Deleted todo file: {:?}", todo_file); + } + } + } + } + + if !errors.is_empty() { + return Err(format!( + "Deleted {} sessions successfully, but {} failed: {}", + deleted_count, + errors.len(), + errors.join("; ") + )); + } + + Ok(format!("{} sessions deleted successfully", deleted_count)) +} + +/// Saves a pasted image (base64 data) to a file and returns the relative path +/// +/// This function takes base64 image data and saves it as a file in the actual project's +/// images directory (not the Claude storage), so Claude can access it directly. +/// +/// # Arguments +/// * `project_id` - The ID of the project to save the image in +/// * `session_id` - The ID of the current session (used for unique naming) +/// * `base64_data` - The base64 data URL (e.g., "data:image/png;base64,...") +/// +/// # Returns +/// * `Ok(String)` - The relative path to the saved image (e.g., "./images/session_123_image_1.png") +/// * `Err(String)` - Error message if saving fails +/// +/// # Errors +/// * Project directory not found +/// * Invalid base64 data format +/// * File system errors during image creation +/// * Permission denied when creating directories or files +#[tauri::command] +pub async fn save_pasted_image( + project_id: String, + session_id: String, + base64_data: String, +) -> Result { + log::info!( + "Saving pasted image for project: {}, session: {}", + project_id, + session_id + ); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let claude_project_dir = claude_dir.join("projects").join(&project_id); + + // Check if Claude project directory exists + if !claude_project_dir.exists() { + return Err(format!("Project directory not found: {}", project_id)); + } + + // Get the actual project path from the Claude project directory + let actual_project_path = get_project_path_from_sessions(&claude_project_dir) + .unwrap_or_else(|_| decode_project_path(&project_id)); + + // Create images directory in the actual project directory (where Claude can find it) + let images_dir = std::path::PathBuf::from(&actual_project_path).join("images"); + if !images_dir.exists() { + fs::create_dir_all(&images_dir) + .map_err(|e| format!("Failed to create images directory: {}", e))?; + log::info!("Created images directory: {:?}", images_dir); + } + + // Parse the base64 data URL + if !base64_data.starts_with("data:image/") { + return Err("Invalid image data format".to_string()); + } + + // Extract file extension from MIME type + let mime_part = base64_data + .split(';') + .next() + .ok_or("Invalid data URL format")?; + let extension = match mime_part { + "data:image/png" => "png", + "data:image/jpeg" => "jpg", + "data:image/jpg" => "jpg", + "data:image/gif" => "gif", + "data:image/webp" => "webp", + _ => "png", // Default to PNG + }; + + // Extract base64 content + let base64_content = base64_data + .split(',') + .nth(1) + .ok_or("Invalid base64 data format")?; + + // Decode base64 + use base64::Engine; + let image_data = base64::engine::general_purpose::STANDARD + .decode(base64_content) + .map_err(|e| format!("Failed to decode base64: {}", e))?; + + // Generate unique filename + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + let filename = format!("session_{}_image_{}.{}", session_id, timestamp, extension); + let image_path = images_dir.join(&filename); + + // Write image file + fs::write(&image_path, image_data) + .map_err(|e| format!("Failed to write image file: {}", e))?; + + log::info!("Saved image: {:?}", image_path); + + // Return relative path for use in prompts + let relative_path = format!("./images/{}", filename); + Ok(relative_path) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc93adb..2091d0b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,15 +20,15 @@ use commands::agents::{ use commands::claude::{ cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, clear_checkpoint_manager, continue_claude_code, create_checkpoint, create_project, - execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, - get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, - get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, - get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, - list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, - open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, - save_claude_md_file, save_claude_settings, save_system_prompt, search_files, - track_checkpoint_message, track_session_messages, update_checkpoint_settings, - update_hooks_config, validate_hook_command, ClaudeProcessState, + delete_session, delete_sessions_bulk, execute_claude_code, find_claude_md_files, fork_from_checkpoint, + get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, + get_claude_session_output, get_claude_settings, get_home_directory, get_hooks_config, + get_project_sessions, get_recently_modified_files, get_session_timeline, get_system_prompt, + list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, + load_session_history, open_new_session, read_claude_md_file, restore_checkpoint, + resume_claude_code, save_claude_md_file, save_claude_settings, save_pasted_image, + save_system_prompt, search_files, track_checkpoint_message, track_session_messages, + update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState, }; use commands::mcp::{ mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, @@ -188,6 +188,9 @@ fn main() { list_projects, create_project, get_project_sessions, + delete_session, + delete_sessions_bulk, + save_pasted_image, get_home_directory, get_claude_settings, open_new_session, diff --git a/src/components/AgentExecution.tsx b/src/components/AgentExecution.tsx index 41fbb1f..3ee3e61 100644 --- a/src/components/AgentExecution.tsx +++ b/src/components/AgentExecution.tsx @@ -32,7 +32,7 @@ import { ExecutionControlBar } from "./ExecutionControlBar"; import { ErrorBoundary } from "./ErrorBoundary"; import { useVirtualizer } from "@tanstack/react-virtual"; import { HooksEditor } from "./HooksEditor"; -import { useTrackEvent, useComponentMetrics, useFeatureAdoptionTracking } from "@/hooks"; +import { useTrackEvent, useComponentMetrics, useFeatureAdoptionTracking, useAutoScroll } from "@/hooks"; import { useTabState } from "@/hooks/useTabState"; interface AgentExecutionProps { @@ -115,7 +115,8 @@ export const AgentExecution: React.FC = ({ const [elapsedTime, setElapsedTime] = useState(0); const [hasUserScrolled, setHasUserScrolled] = useState(false); const [isFullscreenModalOpen, setIsFullscreenModalOpen] = useState(false); - + const { autoScrollEnabled } = useAutoScroll(); + const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const scrollContainerRef = useRef(null); @@ -232,7 +233,7 @@ export const AgentExecution: React.FC = ({ if (displayableMessages.length === 0) return; // Auto-scroll only if the user has not manually scrolled OR they are still at the bottom - const shouldAutoScroll = !hasUserScrolled || isAtBottom(); + const shouldAutoScroll = autoScrollEnabled && (!hasUserScrolled || isAtBottom()); if (shouldAutoScroll) { if (isFullscreenModalOpen) { @@ -241,7 +242,7 @@ export const AgentExecution: React.FC = ({ rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: "end", behavior: "smooth" }); } } - }, [displayableMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer]); + }, [displayableMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer, autoScrollEnabled]); // Update elapsed time while running useEffect(() => { @@ -753,11 +754,13 @@ export const AgentExecution: React.FC = ({ ref={scrollContainerRef} className="h-full overflow-y-auto p-6 space-y-8" onScroll={() => { + if (!autoScrollEnabled) return; + // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } - + // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); @@ -895,11 +898,13 @@ export const AgentExecution: React.FC = ({ ref={fullscreenScrollRef} className="h-full overflow-y-auto space-y-8" onScroll={() => { + if (!autoScrollEnabled) return; + // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } - + // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); diff --git a/src/components/AgentRunOutputViewer.tsx b/src/components/AgentRunOutputViewer.tsx index d83fb93..b906f68 100644 --- a/src/components/AgentRunOutputViewer.tsx +++ b/src/components/AgentRunOutputViewer.tsx @@ -27,6 +27,7 @@ import { formatISOTimestamp } from '@/lib/date-utils'; import { AGENT_ICONS } from './CCAgents'; import type { ClaudeStreamMessage } from './AgentExecution'; import { useTabState } from '@/hooks/useTabState'; +import { useAutoScroll } from '@/hooks'; interface AgentRunOutputViewerProps { /** @@ -67,7 +68,8 @@ export function AgentRunOutputViewer({ const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [hasUserScrolled, setHasUserScrolled] = useState(false); - + const { autoScrollEnabled } = useAutoScroll(); + // Track whether we're in the initial load phase const isInitialLoadRef = useRef(true); const hasSetupListenersRef = useRef(false); @@ -91,7 +93,7 @@ export function AgentRunOutputViewer({ }; const scrollToBottom = () => { - if (!hasUserScrolled) { + if (autoScrollEnabled && !hasUserScrolled) { const endRef = isFullscreen ? fullscreenMessagesEndRef.current : outputEndRef.current; if (endRef) { endRef.scrollIntoView({ behavior: 'smooth' }); @@ -132,11 +134,11 @@ export function AgentRunOutputViewer({ // Auto-scroll when messages change useEffect(() => { - const shouldAutoScroll = !hasUserScrolled || isAtBottom(); + const shouldAutoScroll = autoScrollEnabled && (!hasUserScrolled || isAtBottom()); if (shouldAutoScroll) { scrollToBottom(); } - }, [messages, hasUserScrolled, isFullscreen]); + }, [messages, hasUserScrolled, isFullscreen, autoScrollEnabled]); const loadOutput = async (skipCache = false) => { if (!run?.id) return; @@ -442,6 +444,8 @@ export function AgentRunOutputViewer({ }; const handleScroll = (e: React.UIEvent) => { + if (!autoScrollEnabled) return; + const target = e.currentTarget; const { scrollTop, scrollHeight, clientHeight } = target; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index d4563e2..8c2ebac 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -28,7 +28,7 @@ import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks"; +import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useAutoScroll } from "@/hooks"; import { SessionPersistenceService } from "@/services/sessionPersistence"; interface ClaudeCodeSessionProps { @@ -75,6 +75,12 @@ export const ClaudeCodeSession: React.FC = ({ onStreamingChange, onProjectPathChange, }) => { + console.log('[ClaudeCodeSession] Component initialized/remounted with:', { + session: session?.id, + initialProjectPath, + hasSession: !!session + }); + const [projectPath] = useState(initialProjectPath || session?.project_path || ""); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -105,7 +111,10 @@ export const ClaudeCodeSession: React.FC = ({ // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); - + + // Auto-scroll hook + const { autoScrollEnabled } = useAutoScroll(); + const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); @@ -250,10 +259,7 @@ export const ClaudeCodeSession: React.FC = ({ // 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 + // Load session history first, which will set up extractedSessionInfo and claudeSessionId properly const initializeSession = async () => { await loadSessionHistory(); // After loading history, check if the session is still active @@ -261,7 +267,7 @@ export const ClaudeCodeSession: React.FC = ({ await checkForActiveSession(); } }; - + initializeSession(); } }, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount @@ -273,10 +279,12 @@ export const ClaudeCodeSession: React.FC = ({ // Auto-scroll to bottom when new messages arrive useEffect(() => { + if (!autoScrollEnabled) return; + if (displayableMessages.length > 0) { rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' }); } - }, [displayableMessages.length, rowVirtualizer]); + }, [displayableMessages.length, rowVirtualizer, autoScrollEnabled]); // Calculate total tokens from messages useEffect(() => { @@ -294,35 +302,60 @@ export const ClaudeCodeSession: React.FC = ({ const loadSessionHistory = async () => { if (!session) return; - + try { setIsLoading(true); setError(null); - + const history = await api.loadSessionHistory(session.id, session.project_id); - - // Save session data for restoration + + // Check persistence data to understand session relationships + const persistenceData = SessionPersistenceService.loadSession(session.id); + + // Set up extracted session info for conversation continuation + const projectId = session.project_id; + if (persistenceData?.primaryConversationId && !persistenceData.isConversationRoot) { + // This is a child session - use the primary conversation ID + console.log('[ClaudeCodeSession] Loading child session, primary conversation ID:', persistenceData.primaryConversationId); + setExtractedSessionInfo({ + sessionId: persistenceData.primaryConversationId, + projectId + }); + setClaudeSessionId(persistenceData.primaryConversationId); + } else { + // This is a primary session or session without persistence data + console.log('[ClaudeCodeSession] Loading primary session:', session.id); + setExtractedSessionInfo({ + sessionId: session.id, + projectId + }); + setClaudeSessionId(session.id); + } + + // Save session data for restoration, preserving existing relationships if (history && history.length > 0) { SessionPersistenceService.saveSession( session.id, session.project_id, session.project_path, - history.length + history.length, + undefined, // scrollPosition + persistenceData?.primaryConversationId // preserve existing primaryConversationId ); } - + // 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); - + // Scroll to bottom after loading history setTimeout(() => { if (loadedMessages.length > 0) { @@ -432,6 +465,12 @@ export const ClaudeCodeSession: React.FC = ({ const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); + console.log('[ClaudeCodeSession] Current state:', { + extractedSessionInfo, + isFirstPrompt, + session, + hasExtractedInfo: !!extractedSessionInfo + }); if (!projectPath) { setError("Please select a project directory first"); @@ -519,13 +558,14 @@ export const ClaudeCodeSession: React.FC = ({ 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 we haven't extracted session info before, do it now (first session only) if (!extractedSessionInfo) { const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); + console.log('[ClaudeCodeSession] Setting primary conversation ID:', msg.session_id); + setClaudeSessionId(msg.session_id); // Only update UI session ID for the first session setExtractedSessionInfo({ sessionId: msg.session_id, projectId }); - + // Save session data for restoration SessionPersistenceService.saveSession( msg.session_id, @@ -533,6 +573,21 @@ export const ClaudeCodeSession: React.FC = ({ projectPath, messages.length ); + } else { + console.log('[ClaudeCodeSession] Continuing conversation, new execution ID:', msg.session_id, 'primary conversation ID:', extractedSessionInfo.sessionId); + console.log('[ClaudeCodeSession] UI will continue showing primary conversation ID:', extractedSessionInfo.sessionId); + + // Save this new session but link it to the primary conversation + const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); + SessionPersistenceService.saveSession( + msg.session_id, + projectId, + projectPath, + messages.length, + undefined, // scrollPosition + extractedSessionInfo.sessionId // primaryConversationId + ); + // Note: We do NOT call setClaudeSessionId() here, so UI keeps showing the primary conversation ID } // Switch to session-specific listeners @@ -621,7 +676,23 @@ export const ClaudeCodeSession: React.FC = ({ if (message.type === 'system' && (message.subtype === 'error' || message.error)) { sessionMetrics.current.errorsEncountered += 1; } - + + // Fix UI session ID display: Store original and replace with primary conversation ID + if (message.type === 'system' && message.subtype === 'init') { + const originalSessionId = message.session_id; + + if (extractedSessionInfo && message.session_id !== extractedSessionInfo.sessionId) { + console.log('[ClaudeCodeSession] Replacing UI session ID:', message.session_id, 'โ†’', extractedSessionInfo.sessionId); + message.original_session_id = originalSessionId; // Store child ID + message.session_id = extractedSessionInfo.sessionId; // Use primary ID + message.session_timestamp = new Date().toISOString(); + } else if (!extractedSessionInfo) { + // First session - mark as both primary and child initially + message.original_session_id = originalSessionId; + message.session_timestamp = new Date().toISOString(); + } + } + setMessages((prev) => [...prev, message]); } catch (err) { console.error('Failed to parse message:', err, payload); @@ -800,10 +871,19 @@ export const ClaudeCodeSession: React.FC = ({ // 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); + // Check if this is resuming an old session (session prop passed) or continuing current session + if (session) { + // Resuming an old session that was explicitly selected + console.log('[ClaudeCodeSession] Resuming old session:', effectiveSession.id); + trackEvent.sessionResumed(effectiveSession.id); + trackEvent.modelSelected(model); + await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); + } else { + // Continuing the current active session + console.log('[ClaudeCodeSession] Continuing current session:', effectiveSession.id); + trackEvent.modelSelected(model); + await api.continueClaudeCode(projectPath, prompt, model); + } } else { console.log('[ClaudeCodeSession] Starting new session'); setIsFirstPrompt(false); @@ -1442,6 +1522,7 @@ export const ClaudeCodeSession: React.FC = ({ isLoading={isLoading} disabled={!projectPath} projectPath={projectPath} + projectId={effectiveSession?.project_id} extraMenuItems={ <> {effectiveSession && ( diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index c3f5ea2..3e426ae 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -22,7 +22,7 @@ import { TooltipProvider, TooltipSimple, Tooltip, TooltipTrigger, TooltipContent import { FilePicker } from "./FilePicker"; import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; -import { type FileEntry, type SlashCommand } from "@/lib/api"; +import { api, type FileEntry, type SlashCommand } from "@/lib/api"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { @@ -46,6 +46,10 @@ interface FloatingPromptInputProps { * Project path for file picker */ projectPath?: string; + /** + * Project ID for image storage + */ + projectId?: string; /** * Optional className for styling */ @@ -206,6 +210,7 @@ const FloatingPromptInputInner = ( disabled = false, defaultModel = "sonnet", projectPath, + projectId, className, onCancel, extraMenuItems, @@ -225,6 +230,19 @@ const FloatingPromptInputInner = ( const [cursorPosition, setCursorPosition] = useState(0); const [embeddedImages, setEmbeddedImages] = useState([]); const [dragActive, setDragActive] = useState(false); + const [imagePathMap, setImagePathMap] = useState>(new Map()); + + // Computed array of actual image paths for preview (derived from placeholders and mapping) + const imagePathsForPreview = embeddedImages.map(placeholder => { + const actualPath = imagePathMap.get(placeholder); + if (actualPath) { + // For data URLs, use as-is; for file paths, convert to absolute + return actualPath.startsWith('data:') + ? actualPath + : (actualPath.startsWith('/') ? actualPath : (projectPath ? `${projectPath}/${actualPath}` : actualPath)); + } + return placeholder; // fallback to placeholder if mapping not found + }); const textareaRef = useRef(null); const expandedTextareaRef = useRef(null); @@ -274,50 +292,71 @@ const FloatingPromptInputInner = ( // Extract image paths from prompt text const extractImagePaths = (text: string): string[] => { console.log('[extractImagePaths] Input text length:', text.length); - - // Updated regex to handle both quoted and unquoted paths + + const pathsSet = new Set(); // Use Set to ensure uniqueness + + // First, look for image placeholders like [image] or [image #2] + const placeholderRegex = /\[image(?:\s*#\d+)?\]/g; + let placeholderMatches = Array.from(text.matchAll(placeholderRegex)); + console.log('[extractImagePaths] Placeholder matches:', placeholderMatches.length); + + for (const match of placeholderMatches) { + const placeholder = match[0]; + const actualPath = imagePathMap.get(placeholder); + if (actualPath) { + console.log('[extractImagePaths] Found placeholder:', placeholder, '-> actual path'); + // For data URLs, use as-is; for file paths, convert to absolute + const fullPath = actualPath.startsWith('data:') + ? actualPath + : (actualPath.startsWith('/') ? actualPath : (projectPath ? `${projectPath}/${actualPath}` : actualPath)); + + if (isImageFile(fullPath)) { + pathsSet.add(fullPath); + } + } + } + + // Also handle legacy @-mentions for backwards compatibility // Pattern 1: @"path with spaces or data URLs" - quoted paths // Pattern 2: @path - unquoted paths (continues until @ or end) const quotedRegex = /@"([^"]+)"/g; const unquotedRegex = /@([^@\n\s]+)/g; - - const pathsSet = new Set(); // Use Set to ensure uniqueness - + // First, extract quoted paths (including data URLs) let matches = Array.from(text.matchAll(quotedRegex)); - console.log('[extractImagePaths] Quoted matches:', matches.length); - + console.log('[extractImagePaths] Legacy quoted matches:', matches.length); + for (const match of matches) { const path = match[1]; // No need to trim, quotes preserve exact path - console.log('[extractImagePaths] Processing quoted path:', path.startsWith('data:') ? 'data URL' : path); - + console.log('[extractImagePaths] Processing legacy quoted path:', path.startsWith('data:') ? 'data URL' : path); + // For data URLs, use as-is; for file paths, convert to absolute - const fullPath = path.startsWith('data:') - ? path + const fullPath = path.startsWith('data:') + ? path : (path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path)); - + if (isImageFile(fullPath)) { pathsSet.add(fullPath); } } - + // Remove quoted mentions from text to avoid double-matching let textWithoutQuoted = text.replace(quotedRegex, ''); - + // Then extract unquoted paths (typically file paths) matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex)); - console.log('[extractImagePaths] Unquoted matches:', matches.length); - + console.log('[extractImagePaths] Legacy unquoted matches:', matches.length); + for (const match of matches) { const path = match[1].trim(); // Skip if it looks like a data URL fragment (shouldn't happen with proper quoting) if (path.includes('data:')) continue; - - console.log('[extractImagePaths] Processing unquoted path:', path); - + + console.log('[extractImagePaths] Processing legacy unquoted path:', path); + // Convert relative path to absolute if needed const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path); - + if (isImageFile(fullPath)) { pathsSet.add(fullPath); } @@ -328,12 +367,19 @@ const FloatingPromptInputInner = ( return uniquePaths; }; + // Extract placeholders from prompt text (for embeddedImages array) + const extractPlaceholders = (text: string): string[] => { + const placeholderRegex = /\[image(?:\s*#\d+)?\]/g; + const placeholders = Array.from(text.matchAll(placeholderRegex)).map(match => match[0]); + return placeholders; + }; + // Update embedded images when prompt changes useEffect(() => { console.log('[useEffect] Prompt changed:', prompt); - const imagePaths = extractImagePaths(prompt); - console.log('[useEffect] Setting embeddedImages to:', imagePaths); - setEmbeddedImages(imagePaths); + const placeholders = extractPlaceholders(prompt); + console.log('[useEffect] Setting embeddedImages to placeholders:', placeholders); + setEmbeddedImages(placeholders); // Auto-resize on prompt change (handles paste, programmatic changes, etc.) if (textareaRef.current && !isExpanded) { @@ -435,16 +481,22 @@ const FloatingPromptInputInner = ( const handleSend = () => { if (prompt.trim() && !disabled) { let finalPrompt = prompt.trim(); - + + // Replace image placeholders with actual file paths + imagePathMap.forEach((actualPath, placeholder) => { + finalPrompt = finalPrompt.replace(placeholder, `@"${actualPath}"`); + }); + // Append thinking phrase if not auto mode const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode); if (thinkingMode && thinkingMode.phrase) { finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`; } - + onSend(finalPrompt, selectedModel); setPrompt(""); setEmbeddedImages([]); + setImagePathMap(new Map()); setTextareaHeight(48); // Reset height after sending } }; @@ -692,7 +744,7 @@ const FloatingPromptInputInner = ( for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); - + // Get the image blob const blob = item.getAsFile(); if (!blob) continue; @@ -700,26 +752,100 @@ const FloatingPromptInputInner = ( try { // Convert blob to base64 const reader = new FileReader(); - reader.onload = () => { + reader.onload = async () => { const base64Data = reader.result as string; - - // Add the base64 data URL directly to the prompt - setPrompt(currentPrompt => { - // Use the data URL directly as the image reference - const mention = `@"${base64Data}"`; - const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; - - // Focus the textarea and move cursor to end - setTimeout(() => { - const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; - target?.focus(); - target?.setSelectionRange(newPrompt.length, newPrompt.length); - }, 0); - return newPrompt; - }); + // If we have project ID, save the image as a file instead of using base64 + if (projectId) { + try { + console.log('Saving image for projectId:', projectId); + + // Generate a temporary session ID for image storage + const tempSessionId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Save the image to file and get relative path + const imagePath = await api.savePastedImage(projectId, tempSessionId, base64Data); + + // Add clean image placeholder to the prompt instead of file path + setPrompt(currentPrompt => { + // Calculate the next image number based on existing placeholders + const existingPlaceholders = extractPlaceholders(currentPrompt); + const nextNumber = existingPlaceholders.length + 1; + const imagePlaceholder = nextNumber === 1 ? '[image]' : `[image #${nextNumber}]`; + const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + imagePlaceholder + ' '; + + // Store the mapping between placeholder and actual path + setImagePathMap(prevMap => { + const newMap = new Map(prevMap); + newMap.set(imagePlaceholder, imagePath); + return newMap; + }); + + // Focus the textarea and move cursor to end + setTimeout(() => { + const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + target?.focus(); + target?.setSelectionRange(newPrompt.length, newPrompt.length); + }, 0); + + return newPrompt; + }); + + console.log('Image saved as file:', imagePath); + } catch (error) { + console.error('Failed to save pasted image as file:', error); + // Fallback to the old base64 approach + setPrompt(currentPrompt => { + // Calculate the next image number based on existing placeholders + const existingPlaceholders = extractPlaceholders(currentPrompt); + const nextNumber = existingPlaceholders.length + 1; + const imagePlaceholder = nextNumber === 1 ? '[image]' : `[image #${nextNumber}]`; + const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + imagePlaceholder + ' '; + + // Store the mapping between placeholder and actual base64 data + setImagePathMap(prevMap => { + const newMap = new Map(prevMap); + newMap.set(imagePlaceholder, base64Data); + return newMap; + }); + + setTimeout(() => { + const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + target?.focus(); + target?.setSelectionRange(newPrompt.length, newPrompt.length); + }, 0); + + return newPrompt; + }); + } + } else { + // No project ID available, use base64 as fallback + console.log('No projectId available, falling back to base64. ProjectId:', projectId, 'ProjectPath:', projectPath); + setPrompt(currentPrompt => { + // Calculate the next image number based on existing placeholders + const existingPlaceholders = extractPlaceholders(currentPrompt); + const nextNumber = existingPlaceholders.length + 1; + const imagePlaceholder = nextNumber === 1 ? '[image]' : `[image #${nextNumber}]`; + const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + imagePlaceholder + ' '; + + // Store the mapping between placeholder and actual base64 data + setImagePathMap(prevMap => { + const newMap = new Map(prevMap); + newMap.set(imagePlaceholder, base64Data); + return newMap; + }); + + setTimeout(() => { + const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; + target?.focus(); + target?.setSelectionRange(newPrompt.length, newPrompt.length); + }, 0); + + return newPrompt; + }); + } }; - + reader.readAsDataURL(blob); } catch (error) { console.error('Failed to paste image:', error); @@ -743,40 +869,22 @@ const FloatingPromptInputInner = ( }; const handleRemoveImage = (index: number) => { - // Remove the corresponding @mention from the prompt - const imagePath = embeddedImages[index]; - - // For data URLs, we need to handle them specially since they're always quoted - if (imagePath.startsWith('data:')) { - // Simply remove the exact quoted data URL - const quotedPath = `@"${imagePath}"`; - const newPrompt = prompt.replace(quotedPath, '').trim(); - setPrompt(newPrompt); - return; - } - - // For file paths, use the original logic - const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedRelativePath = imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - // Create patterns for both quoted and unquoted mentions - const patterns = [ - // Quoted full path - new RegExp(`@"${escapedPath}"\\s?`, 'g'), - // Unquoted full path - new RegExp(`@${escapedPath}\\s?`, 'g'), - // Quoted relative path - new RegExp(`@"${escapedRelativePath}"\\s?`, 'g'), - // Unquoted relative path - new RegExp(`@${escapedRelativePath}\\s?`, 'g') - ]; + // Remove the corresponding placeholder from the prompt + const placeholder = embeddedImages[index]; - let newPrompt = prompt; - for (const pattern of patterns) { - newPrompt = newPrompt.replace(pattern, ''); - } + // Remove the placeholder from the prompt + const newPrompt = prompt.replace(placeholder, '').replace(/\s+/g, ' ').trim(); + setPrompt(newPrompt); - setPrompt(newPrompt.trim()); + // Remove from the image path mapping + setImagePathMap(prevMap => { + const newMap = new Map(prevMap); + newMap.delete(placeholder); + return newMap; + }); + + // Update embedded images array + setEmbeddedImages(prevImages => prevImages.filter((_, i) => i !== index)); }; const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0]; @@ -824,7 +932,7 @@ const FloatingPromptInputInner = ( {/* Image previews in expanded mode */} {embeddedImages.length > 0 && ( @@ -1005,7 +1113,7 @@ const FloatingPromptInputInner = ( {/* Image previews */} {embeddedImages.length > 0 && ( diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 9575c01..b76df83 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -1,13 +1,16 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Clock, MessageSquare } from "lucide-react"; +import { Clock, MessageSquare, Trash2, CheckSquare, Square, Trash, ChevronRight, ChevronDown, Folder, FolderOpen } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Pagination } from "@/components/ui/pagination"; import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { truncateText, getFirstLine } from "@/lib/date-utils"; +import { api } from "@/lib/api"; import type { Session, ClaudeMdFile } from "@/lib/api"; +import { SessionPersistenceService } from "@/services/sessionPersistence"; interface SessionListProps { /** @@ -26,6 +29,10 @@ interface SessionListProps { * Callback when a session is clicked */ onSessionClick?: (session: Session) => void; + /** + * Callback when a session is deleted + */ + onSessionDelete?: (sessionId: string) => void; /** * Callback when a CLAUDE.md file should be edited */ @@ -38,6 +45,13 @@ interface SessionListProps { const ITEMS_PER_PAGE = 12; +interface HierarchicalSession { + session: Session; + children: Session[]; + isPrimary: boolean; + isExpanded?: boolean; +} + /** * SessionList component - Displays paginated sessions for a specific project * @@ -53,21 +67,233 @@ export const SessionList: React.FC = ({ sessions, projectPath, onSessionClick, + onSessionDelete, onEditClaudeFile, className, }) => { const [currentPage, setCurrentPage] = useState(1); - - // Calculate pagination - const totalPages = Math.ceil(sessions.length / ITEMS_PER_PAGE); + const [selectedSessions, setSelectedSessions] = useState>(new Set()); + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false); + const [expandedSessions, setExpandedSessions] = useState>(new Set()); + + // Process sessions into hierarchical structure + const hierarchicalSessions = useMemo(() => { + const sessionMap = new Map(); + const primarySessions: HierarchicalSession[] = []; + const childSessionsMap = new Map(); + + // Create session map and identify primary sessions + sessions.forEach(session => { + sessionMap.set(session.id, session); + + // Try to get persistence data to identify primary vs child + const persistenceData = SessionPersistenceService.loadSession(session.id); + + if (persistenceData?.isConversationRoot || !persistenceData?.primaryConversationId) { + // This is a primary session + const hierarchicalSession: HierarchicalSession = { + session, + children: [], + isPrimary: true, + isExpanded: expandedSessions.has(session.id) + }; + primarySessions.push(hierarchicalSession); + childSessionsMap.set(session.id, []); + } + }); + + // Group child sessions under their primary sessions + sessions.forEach(session => { + const persistenceData = SessionPersistenceService.loadSession(session.id); + + if (persistenceData?.primaryConversationId && !persistenceData.isConversationRoot) { + // This is a child session + const primaryId = persistenceData.primaryConversationId; + const children = childSessionsMap.get(primaryId); + if (children) { + children.push(session); + } + } + }); + + // Add children to their primary sessions and sort by timestamp + primarySessions.forEach(primarySession => { + const children = childSessionsMap.get(primarySession.session.id) || []; + primarySession.children = children.sort((a, b) => { + const timeA = a.message_timestamp ? new Date(a.message_timestamp).getTime() : a.created_at * 1000; + const timeB = b.message_timestamp ? new Date(b.message_timestamp).getTime() : b.created_at * 1000; + return timeB - timeA; // Sort descending (latest first) + }); + }); + + // Sort primary sessions by most recent activity (primary or child) + return primarySessions.sort((a, b) => { + const getLatestTime = (hs: HierarchicalSession) => { + const primaryTime = hs.session.message_timestamp ? new Date(hs.session.message_timestamp).getTime() : hs.session.created_at * 1000; + const childTimes = hs.children.map(child => child.message_timestamp ? new Date(child.message_timestamp).getTime() : child.created_at * 1000); + return Math.max(primaryTime, ...childTimes); + }; + + return getLatestTime(b) - getLatestTime(a); // Sort descending (most recent first) + }); + }, [sessions, expandedSessions]); + + // Calculate pagination based on hierarchical sessions + const totalPages = Math.ceil(hierarchicalSessions.length / ITEMS_PER_PAGE); const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; - const currentSessions = sessions.slice(startIndex, endIndex); + const currentHierarchicalSessions = hierarchicalSessions.slice(startIndex, endIndex); + + // Function to toggle expand/collapse for primary sessions + const toggleSessionExpansion = (sessionId: string) => { + setExpandedSessions(prev => { + const newSet = new Set(prev); + if (newSet.has(sessionId)) { + newSet.delete(sessionId); + } else { + newSet.add(sessionId); + } + return newSet; + }); + }; // Reset to page 1 if sessions change React.useEffect(() => { setCurrentPage(1); - }, [sessions.length]); + }, [hierarchicalSessions.length]); + + // Multi-select helper functions + const toggleSessionSelection = (sessionId: string) => { + setSelectedSessions(prev => { + const newSet = new Set(prev); + if (newSet.has(sessionId)) { + newSet.delete(sessionId); + } else { + newSet.add(sessionId); + } + return newSet; + }); + }; + + const selectAllSessions = () => { + const allSessionIds: string[] = []; + currentHierarchicalSessions.forEach(hs => { + allSessionIds.push(hs.session.id); + allSessionIds.push(...hs.children.map(child => child.id)); + }); + setSelectedSessions(new Set(allSessionIds)); + }; + + const clearAllSelections = () => { + setSelectedSessions(new Set()); + }; + + const toggleMultiSelectMode = () => { + setIsMultiSelectMode(!isMultiSelectMode); + setSelectedSessions(new Set()); + }; + + // Check if all current sessions are selected + const allCurrentSessionsSelected = (() => { + const allSessionIds: string[] = []; + currentHierarchicalSessions.forEach(hs => { + allSessionIds.push(hs.session.id); + allSessionIds.push(...hs.children.map(child => child.id)); + }); + return allSessionIds.length > 0 && allSessionIds.every(id => selectedSessions.has(id)); + })(); + + // Bulk delete handler + const handleBulkDelete = async () => { + if (selectedSessions.size === 0) return; + + const sessionIds = Array.from(selectedSessions); + const projectId = currentHierarchicalSessions[0]?.session.project_id; + + if (!projectId) { + alert('Unable to determine project ID'); + return; + } + + const confirmMessage = `Are you sure you want to delete ${sessionIds.length} session${sessionIds.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`; + + if (!(await window.confirm(confirmMessage))) { + return; + } + + try { + await api.deleteSessionsBulk(sessionIds, projectId); + + // Clean up session persistence data for each deleted session + sessionIds.forEach(sessionId => { + SessionPersistenceService.removeSession(sessionId); + onSessionDelete?.(sessionId); + }); + + // Clear selections and exit multi-select mode + setSelectedSessions(new Set()); + setIsMultiSelectMode(false); + } catch (error) { + console.error('Failed to bulk delete sessions:', error); + alert('Failed to delete sessions. Please try again.'); + } + }; + + const handleDeleteSession = async (e: React.MouseEvent, session: Session) => { + e.preventDefault(); + e.stopPropagation(); // Prevent session click when delete button is clicked + + // Add a small delay to ensure the UI is stable + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check if this is a primary session with children + const persistenceData = SessionPersistenceService.loadSession(session.id); + const isPrimary = persistenceData?.isConversationRoot; + const children = isPrimary ? SessionPersistenceService.getConversationSessions(session.id).filter(id => id !== session.id) : []; + + let confirmMessage = `Are you sure you want to delete this session?\n\nSession: ${session.id.slice(-8)}\nDate: ${session.message_timestamp + ? new Date(session.message_timestamp).toLocaleDateString() + : new Date(session.created_at * 1000).toLocaleDateString()}`; + + if (isPrimary && children.length > 0) { + confirmMessage += `\n\nThis is a primary session with ${children.length} child session${children.length > 1 ? 's' : ''}. Deleting it will also delete all child sessions.`; + } + + confirmMessage += `\n\nThis action cannot be undone.`; + + // Use a more explicit confirmation approach + const confirmed = await window.confirm(confirmMessage); + console.log('Delete confirmation result:', confirmed); + + if (!confirmed) { + console.log('User cancelled deletion'); + return; + } + + console.log('Proceeding with deletion of session:', session.id); + + try { + // If primary session, delete all child sessions first + if (isPrimary && children.length > 0) { + for (const childId of children) { + await api.deleteSession(childId, session.project_id); + SessionPersistenceService.removeSession(childId); + onSessionDelete?.(childId); + } + } + + // Delete the main session + await api.deleteSession(session.id, session.project_id); + + // Clean up session persistence data + SessionPersistenceService.removeSession(session.id); + + onSessionDelete?.(session.id); + } catch (error) { + console.error('Failed to delete session:', error); + alert('Failed to delete session. Please try again.'); + } + }; return ( @@ -86,64 +312,192 @@ export const SessionList: React.FC = ({ )} - -
- {currentSessions.map((session, index) => ( - - { - // Emit a special event for Claude Code session navigation - const event = new CustomEvent('claude-session-selected', { - detail: { session, projectPath } - }); - window.dispatchEvent(event); - onSessionClick?.(session); - }} + {/* Multi-select toolbar */} +
+
+ + + {isMultiSelectMode && ( + <> + + + {selectedSessions.size > 0 && ( + + )} + + )} +
+ + {isMultiSelectMode && selectedSessions.size > 0 && ( + + {selectedSessions.size} session{selectedSessions.size > 1 ? 's' : ''} selected + + )} +
+ + +
+ {currentHierarchicalSessions.map((hierarchicalSession, index) => { + const { session, children } = hierarchicalSession; + const isExpanded = expandedSessions.has(session.id); + const hasChildren = children.length > 0; + + return ( + + {/* Primary Session Card */} + { + if (isMultiSelectMode) { + toggleSessionSelection(session.id); + } else if (hasChildren) { + // Primary sessions with children expand/collapse on click + toggleSessionExpansion(session.id); + } else { + // Primary sessions without children open directly + const event = new CustomEvent('claude-session-selected', { + detail: { session, projectPath } + }); + window.dispatchEvent(event); + onSessionClick?.(session); + } + }} + > +
+ {/* Primary Session Header */}
-
- +
+ {/* Folder icon and expand/collapse for sessions with children */} + {hasChildren ? ( +
+ {isExpanded ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ) : ( + + )} +
-

- Session on {session.message_timestamp - ? new Date(session.message_timestamp).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }) - : new Date(session.created_at * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }) - } -

+
+

+ {hasChildren ? 'Conversation' : 'Session'} on {session.message_timestamp + ? new Date(session.message_timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + : new Date(session.created_at * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + } +

+ {hasChildren && ( + + {children.length + 1} session{children.length + 1 > 1 ? 's' : ''} + + )} +
- {session.todo_data && ( - - Todo - - )} + +
+ {session.todo_data && ( + + Todo + + )} + + {/* Multi-select checkbox */} + {isMultiSelectMode && ( + + )} + + {/* Delete button (hidden in multi-select mode) */} + {!isMultiSelectMode && ( + + )} +
- + {/* First message preview */} {session.first_message ? (

@@ -154,21 +508,154 @@ export const SessionList: React.FC = ({ No messages yet

)} + + {/* Metadata footer */} +
+

+ {session.id.slice(-8)} +

+ {session.todo_data && ( + + )} +
- - {/* Metadata footer */} -
-

- {session.id.slice(-8)} -

- {session.todo_data && ( - - )} -
-
-
-
- ))} + + + {/* Child Sessions */} + {hasChildren && isExpanded && ( + + + {children.map((childSession, childIndex) => ( + + { + if (isMultiSelectMode) { + toggleSessionSelection(childSession.id); + } else { + const event = new CustomEvent('claude-session-selected', { + detail: { session: childSession, projectPath } + }); + window.dispatchEvent(event); + onSessionClick?.(childSession); + } + }} + > +
+ {/* Child Session Header */} +
+
+
+ โ””โ”€ + +
+ +
+

+ {childSession.message_timestamp + ? new Date(childSession.message_timestamp).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }) + : new Date(childSession.created_at * 1000).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }) + } +

+
+
+ +
+ {/* Latest session indicator */} + {childIndex === 0 && ( + + Latest + + )} + + {childSession.todo_data && ( + + Todo + + )} + + {/* Multi-select checkbox */} + {isMultiSelectMode && ( + + )} + + {/* Delete button (hidden in multi-select mode) */} + {!isMultiSelectMode && ( + + )} +
+
+ + {/* Child Session Message Preview */} + {childSession.first_message && ( +

+ {truncateText(getFirstLine(childSession.first_message), 100)} +

+ )} + + {/* Child Session Footer */} +
+

+ {childSession.id.slice(-8)} +

+ {childSession.todo_data && ( + + )} +
+
+
+
+ ))} +
+
+ )} + + ); + })}
diff --git a/src/components/SessionOutputViewer.tsx b/src/components/SessionOutputViewer.tsx index eafcc01..b298f37 100644 --- a/src/components/SessionOutputViewer.tsx +++ b/src/components/SessionOutputViewer.tsx @@ -8,6 +8,7 @@ import { Toast, ToastContainer } from '@/components/ui/toast'; import { Popover } from '@/components/ui/popover'; import { api } from '@/lib/api'; import { useOutputCache } from '@/lib/outputCache'; +import { useAutoScroll } from '@/hooks'; import type { AgentRun } from '@/lib/api'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { StreamMessage } from './StreamMessage'; @@ -46,7 +47,8 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [hasUserScrolled, setHasUserScrolled] = useState(false); - + const { autoScrollEnabled } = useAutoScroll(); + const scrollAreaRef = useRef(null); const outputEndRef = useRef(null); const fullscreenScrollRef = useRef(null); @@ -66,7 +68,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp }; const scrollToBottom = () => { - if (!hasUserScrolled) { + if (autoScrollEnabled && !hasUserScrolled) { const endRef = isFullscreen ? fullscreenMessagesEndRef.current : outputEndRef.current; if (endRef) { endRef.scrollIntoView({ behavior: 'smooth' }); @@ -83,11 +85,11 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp // Auto-scroll when messages change useEffect(() => { - const shouldAutoScroll = !hasUserScrolled || isAtBottom(); + const shouldAutoScroll = autoScrollEnabled && (!hasUserScrolled || isAtBottom()); if (shouldAutoScroll) { scrollToBottom(); } - }, [messages, hasUserScrolled, isFullscreen]); + }, [messages, hasUserScrolled, isFullscreen, autoScrollEnabled]); const loadOutput = async (skipCache = false) => { @@ -480,11 +482,13 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp className="h-full overflow-y-auto p-6 space-y-3" ref={scrollAreaRef} onScroll={() => { + if (!autoScrollEnabled) return; + // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } - + // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); @@ -614,11 +618,13 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp ref={fullscreenScrollRef} className="h-full overflow-y-auto space-y-3" onScroll={() => { + if (!autoScrollEnabled) return; + // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } - + // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 06d338a..2c7a157 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -96,6 +96,8 @@ export const Settings: React.FC = ({ const [tabPersistenceEnabled, setTabPersistenceEnabled] = useState(true); // Startup intro preference const [startupIntroEnabled, setStartupIntroEnabled] = useState(true); + // Auto-scroll preference + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); // Load settings on mount useEffect(() => { @@ -109,6 +111,11 @@ export const Settings: React.FC = ({ const pref = await api.getSetting('startup_intro_enabled'); setStartupIntroEnabled(pref === null ? true : pref === 'true'); })(); + // Load auto-scroll setting (default to true if not set) + (async () => { + const pref = await api.getSetting('auto_scroll_enabled'); + setAutoScrollEnabled(pref === null ? true : pref === 'true'); + })(); }, []); /** @@ -767,6 +774,35 @@ export const Settings: React.FC = ({ }} />
+ + {/* Auto-scroll Setting */} +
+
+ +

+ Automatically scroll to the bottom when new messages arrive during conversations +

+
+ { + setAutoScrollEnabled(checked); + try { + await api.saveSetting('auto_scroll_enabled', checked ? 'true' : 'false'); + trackEvent.settingsChanged('auto_scroll_enabled', checked); + setToast({ + message: checked + ? 'Auto-scroll enabled' + : 'Auto-scroll disabled', + type: 'success' + }); + } catch (e) { + setToast({ message: 'Failed to update preference', type: 'error' }); + } + }} + /> +
diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index ae43a59..dc6edc9 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -48,6 +48,24 @@ interface StreamMessageProps { onLinkDetected?: (url: string) => void; } +/** + * Utility function to convert image file paths back to clean placeholders for display + */ +const cleanImagePaths = (text: string): string => { + if (!text || typeof text !== 'string') return text; + + // Pattern to match image paths: @"./images/session_temp_..." or @"data:image/..." + const imagePathRegex = /@"([^"]*(?:images\/session_temp_[^"]*|data:image\/[^"]*))[^"]*"/g; + + let imageCounter = 0; + const cleanedText = text.replace(imagePathRegex, () => { + imageCounter++; + return imageCounter === 1 ? '[image]' : `[image #${imageCounter}]`; + }); + + return cleanedText; +}; + /** * Component to render a single Claude Code stream message */ @@ -99,6 +117,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa return ( = ({ message, classNa // Text content - render as markdown if (content.type === "text") { // Ensure we have a string to render - const textContent = typeof content.text === 'string' - ? content.text + const rawTextContent = typeof content.text === 'string' + ? content.text : (content.text?.text || JSON.stringify(content.text || content)); - + + // Clean image paths for display + const textContent = cleanImagePaths(rawTextContent); + renderedSomething = true; return (
@@ -358,10 +380,11 @@ const StreamMessageComponent: React.FC = ({ message, classNa return ; } - // Otherwise render as plain text + // Otherwise render as plain text with cleaned image paths + const cleanedContent = cleanImagePaths(contentStr); return (
- {contentStr} + {cleanedContent}
); })() @@ -612,10 +635,13 @@ const StreamMessageComponent: React.FC = ({ message, classNa // Text content if (content.type === "text") { // Handle both string and object formats - const textContent = typeof content.text === 'string' - ? content.text + const rawTextContent = typeof content.text === 'string' + ? content.text : (content.text?.text || JSON.stringify(content.text)); - + + // Clean image paths for display + const textContent = cleanImagePaths(rawTextContent); + renderedSomething = true; return (
diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx index e306324..39d6c71 100644 --- a/src/components/TabContent.tsx +++ b/src/components/TabContent.tsx @@ -223,10 +223,16 @@ const TabPanel: React.FC = ({ tab, isActive }) => { initialProjectPath: session.project_path }); }} + onSessionDelete={(sessionId: string) => { + // Remove the deleted session from the local state + setSessions(prevSessions => + prevSessions.filter(s => s.id !== sessionId) + ); + }} onEditClaudeFile={(file: ClaudeMdFile) => { // Open CLAUDE.md file in a new tab - window.dispatchEvent(new CustomEvent('open-claude-file', { - detail: { file } + window.dispatchEvent(new CustomEvent('open-claude-file', { + detail: { file } })); }} /> diff --git a/src/components/ToolWidgets.tsx b/src/components/ToolWidgets.tsx index fdb00e8..455368d 100644 --- a/src/components/ToolWidgets.tsx +++ b/src/components/ToolWidgets.tsx @@ -56,7 +56,7 @@ import { useTheme } from "@/hooks"; import { Button } from "@/components/ui/button"; import { createPortal } from "react-dom"; import * as Diff from 'diff'; -import { Card, CardContent } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { detectLinks, makeLinksClickable } from "@/lib/linkDetector"; import ReactMarkdown from "react-markdown"; import { open } from "@tauri-apps/plugin-shell"; @@ -348,6 +348,8 @@ export const LSResultWidget: React.FC<{ content: string }> = ({ content }) => { * Widget for Read tool */ export const ReadWidget: React.FC<{ filePath: string; result?: any }> = ({ filePath, result }) => { + const [isExpanded, setIsExpanded] = useState(false); + // If we have a result, show it using the ReadResultWidget if (result) { let resultContent = ''; @@ -364,17 +366,41 @@ export const ReadWidget: React.FC<{ filePath: string; result?: any }> = ({ fileP resultContent = JSON.stringify(result.content, null, 2); } } - + + const lineCount = resultContent.split('\n').filter(line => line.trim()).length; + return ( -
-
- - File content: - - {filePath} - -
- {resultContent && } +
+ + + {isExpanded && resultContent && ( +
+ +
+ )} + + {!isExpanded && ( +
+ Click to expand and view file content ({lineCount} lines) +
+ )}
); } @@ -399,7 +425,7 @@ export const ReadWidget: React.FC<{ filePath: string; result?: any }> = ({ fileP /** * Widget for Read tool result - shows file content with line numbers */ -export const ReadResultWidget: React.FC<{ content: string; filePath?: string }> = ({ content, filePath }) => { +export const ReadResultWidget: React.FC<{ content: string; filePath?: string; forceExpanded?: boolean }> = ({ content, filePath, forceExpanded = false }) => { const [isExpanded, setIsExpanded] = useState(false); const { theme } = useTheme(); const syntaxTheme = getClaudeSyntaxTheme(theme); @@ -502,7 +528,34 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }> const language = getLanguage(filePath); const { codeContent, startLineNumber } = parseContent(content); const lineCount = content.split('\n').filter(line => line.trim()).length; - const isLargeFile = lineCount > 20; + const isLargeFile = lineCount > 10; // Lower threshold for collapse + + // When forceExpanded is true, show content directly without any wrapper styling + if (forceExpanded) { + return ( +
+ + {codeContent} + +
+ ); + } return (
@@ -518,18 +571,16 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }> )}
- {isLargeFile && ( - - )} +
- - {(!isLargeFile || isExpanded) && ( + + {isExpanded && (
)} - {isLargeFile && !isExpanded && ( + {!isExpanded && (
- Click "Expand" to view the full file + Click "Expand" to view the file content ({lineCount} lines)
)}
@@ -698,6 +749,7 @@ export const BashWidget: React.FC<{ */ export const WriteWidget: React.FC<{ filePath: string; content: string; result?: any }> = ({ filePath, content, result: _result }) => { const [isMaximized, setIsMaximized] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const { theme } = useTheme(); const syntaxTheme = getClaudeSyntaxTheme(theme); @@ -849,6 +901,8 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?:
); + const lineCount = content.split('\n').length; + return (
@@ -857,8 +911,23 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?: {filePath} +
- + + {isExpanded && } + + {!isExpanded && ( +
+ Click "Preview" to view the file content ({lineCount} lines) +
+ )} +
); @@ -1120,12 +1189,13 @@ const getLanguage = (path: string) => { /** * Widget for Edit tool - shows the edit operation */ -export const EditWidget: React.FC<{ - file_path: string; - old_string: string; +export const EditWidget: React.FC<{ + file_path: string; + old_string: string; new_string: string; result?: any; }> = ({ file_path, old_string, new_string, result: _result }) => { + const [isExpanded, setIsExpanded] = useState(false); const { theme } = useTheme(); const syntaxTheme = getClaudeSyntaxTheme(theme); @@ -1135,6 +1205,7 @@ export const EditWidget: React.FC<{ }); const language = getLanguage(file_path); + return (
@@ -1143,9 +1214,17 @@ export const EditWidget: React.FC<{ {file_path} +
-
+ {isExpanded && ( +
{diffResult.map((part, index) => { const partClass = part.added @@ -1194,7 +1273,14 @@ export const EditWidget: React.FC<{ ); })}
-
+
+ )} + + {!isExpanded && ( +
+ Click "Show Diff" to view the edit changes +
+ )}
); }; @@ -1203,6 +1289,7 @@ export const EditWidget: React.FC<{ * Widget for Edit tool result - shows a diff view */ export const EditResultWidget: React.FC<{ content: string }> = ({ content }) => { + const [isExpanded, setIsExpanded] = useState(false); const { theme } = useTheme(); const syntaxTheme = getClaudeSyntaxTheme(theme); @@ -1240,45 +1327,65 @@ export const EditResultWidget: React.FC<{ content: string }> = ({ content }) => const startLineNumber = firstNumberedLine ? parseInt(firstNumberedLine.lineNumber) : 1; const language = getLanguage(filePath); + const resultLineCount = codeContent.split('\n').length; + return (
-
- - Edit Result - {filePath && ( - <> - - {filePath} - - )} -
-
- +
+ + Edit Result + {filePath && ( + <> + + {filePath} + + )} +
+
+ + {isExpanded && ( +
+ + {codeContent} + +
+ )} + + {!isExpanded && ( +
+ Edit completed successfully. Click "View" to see the result ({resultLineCount} lines) +
+ )}
); }; @@ -1696,22 +1803,34 @@ export const MultiEditWidget: React.FC<{ /** * Widget for displaying MultiEdit tool results with diffs */ -export const MultiEditResultWidget: React.FC<{ +export const MultiEditResultWidget: React.FC<{ content: string; edits?: Array<{ old_string: string; new_string: string }>; }> = ({ content, edits }) => { + const [isExpanded, setIsExpanded] = useState(false); + // If we have the edits array, show a nice diff view if (edits && edits.length > 0) { return (
-
- - - {edits.length} Changes Applied - +
+
+ + + {edits.length} Changes Applied + +
+
- -
+ + {isExpanded && ( +
{edits.map((edit, index) => { // Split the strings into lines for diff display const oldLines = edit.old_string.split('\n'); @@ -1757,7 +1876,14 @@ export const MultiEditResultWidget: React.FC<{
); })} -
+
+ )} + + {!isExpanded && ( +
+ {edits.length} changes applied successfully. Click "View Changes" to see details. +
+ )}
); } @@ -1800,16 +1926,18 @@ export const SystemReminderWidget: React.FC<{ message: string }> = ({ message }) */ export const SystemInitializedWidget: React.FC<{ sessionId?: string; + childSessionId?: string; model?: string; cwd?: string; tools?: string[]; -}> = ({ sessionId, model, cwd, tools = [] }) => { +}> = ({ sessionId, childSessionId, model, cwd, tools = [] }) => { + const [isExpanded, setIsExpanded] = useState(false); const [mcpExpanded, setMcpExpanded] = useState(false); - + // Separate regular tools from MCP tools const regularTools = tools.filter(tool => !tool.startsWith('mcp__')); const mcpTools = tools.filter(tool => tool.startsWith('mcp__')); - + // Tool icon mapping for regular tools const toolIcons: Record = { 'task': CheckSquare, @@ -1829,13 +1957,13 @@ export const SystemInitializedWidget: React.FC<{ 'todowrite': ListPlus, 'websearch': Globe2, }; - + // Get icon for a tool, fallback to Wrench const getToolIcon = (toolName: string) => { const normalizedName = toolName.toLowerCase(); return toolIcons[normalizedName] || Wrench; }; - + // Format MCP tool name (remove mcp__ prefix and format underscores) const formatMcpToolName = (toolName: string) => { // Remove mcp__ prefix @@ -1863,7 +1991,7 @@ export const SystemInitializedWidget: React.FC<{ .join(' ') }; }; - + // Group MCP tools by provider const mcpToolsByProvider = mcpTools.reduce((acc, tool) => { const { provider } = formatMcpToolName(tool); @@ -1873,48 +2001,77 @@ export const SystemInitializedWidget: React.FC<{ acc[provider].push(tool); return acc; }, {} as Record); - + return ( - - -
- -
-

System Initialized

- +
+ + + {isExpanded && ( +
+
+ {/* Session Info */} -
+
{sessionId && (
- Session ID: + Primary Session: - {sessionId} + ...{sessionId.slice(-8)} + + ({new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}) +
)} - - {model && ( -
- - Model: + + {childSessionId && childSessionId !== sessionId && ( +
+ โ””โ”€ Child: - {model} - -
- )} - - {cwd && ( -
- - Working Directory: - - {cwd} + ...{childSessionId.slice(-8)} + + (current) +
)}
- + + {model && ( +
+ + Model: + + {model} + +
+ )} + + {cwd && ( +
+ + Working Directory: + + {cwd} + +
+ )} + {/* Regular Tools */} {regularTools.length > 0 && (
@@ -1928,9 +2085,9 @@ export const SystemInitializedWidget: React.FC<{ {regularTools.map((tool, idx) => { const Icon = getToolIcon(tool); return ( - @@ -1941,7 +2098,7 @@ export const SystemInitializedWidget: React.FC<{
)} - + {/* MCP Tools */} {mcpTools.length > 0 && (
@@ -1956,7 +2113,7 @@ export const SystemInitializedWidget: React.FC<{ mcpExpanded && "rotate-180" )} /> - + {mcpExpanded && (
{Object.entries(mcpToolsByProvider).map(([provider, providerTools]) => ( @@ -1970,9 +2127,9 @@ export const SystemInitializedWidget: React.FC<{ {providerTools.map((tool, idx) => { const { method } = formatMcpToolName(tool); return ( - {method} @@ -1986,7 +2143,7 @@ export const SystemInitializedWidget: React.FC<{ )}
)} - + {/* Show message if no tools */} {tools.length === 0 && (
@@ -1995,8 +2152,8 @@ export const SystemInitializedWidget: React.FC<{ )}
- - + )} +
); }; diff --git a/src/components/claude-code-session/MessageList.tsx b/src/components/claude-code-session/MessageList.tsx index c89912f..33040a6 100644 --- a/src/components/claude-code-session/MessageList.tsx +++ b/src/components/claude-code-session/MessageList.tsx @@ -4,6 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { StreamMessage } from '../StreamMessage'; import { Terminal } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useAutoScroll } from '@/hooks'; import type { ClaudeStreamMessage } from '../AgentExecution'; interface MessageListProps { @@ -24,6 +25,7 @@ export const MessageList: React.FC = React.memo(({ const scrollContainerRef = useRef(null); const shouldAutoScrollRef = useRef(true); const userHasScrolledRef = useRef(false); + const { autoScrollEnabled } = useAutoScroll(); // Virtual scrolling setup const virtualizer = useVirtualizer({ @@ -35,20 +37,23 @@ export const MessageList: React.FC = React.memo(({ // Auto-scroll to bottom when new messages arrive useEffect(() => { + // Only scroll if auto-scroll is enabled AND we should auto-scroll + if (!autoScrollEnabled) return; + if (shouldAutoScrollRef.current && scrollContainerRef.current) { const scrollElement = scrollContainerRef.current; scrollElement.scrollTop = scrollElement.scrollHeight; } - }, [messages]); + }, [messages, autoScrollEnabled]); // Handle scroll events to detect user scrolling const handleScroll = () => { - if (!scrollContainerRef.current) return; - + if (!autoScrollEnabled || !scrollContainerRef.current) return; + const scrollElement = scrollContainerRef.current; - const isAtBottom = + const isAtBottom = Math.abs(scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight) < 50; - + if (!isAtBottom) { userHasScrolledRef.current = true; shouldAutoScrollRef.current = false; @@ -60,11 +65,11 @@ export const MessageList: React.FC = React.memo(({ // Reset auto-scroll when streaming stops useEffect(() => { - if (!isStreaming) { + if (!isStreaming && autoScrollEnabled) { shouldAutoScrollRef.current = true; userHasScrolledRef.current = false; } - }, [isStreaming]); + }, [isStreaming, autoScrollEnabled]); if (messages.length === 0) { return ( diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b817e00..7ad401e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -19,8 +19,9 @@ export { useAIInteractionTracking, useNetworkPerformanceTracking } from './useAnalytics'; -export { - usePerformanceMonitor, - useAsyncPerformanceTracker +export { + usePerformanceMonitor, + useAsyncPerformanceTracker } from './usePerformanceMonitor'; +export { useAutoScroll } from './useAutoScroll'; export { TAB_SCREEN_NAMES } from './useAnalytics'; diff --git a/src/lib/api.ts b/src/lib/api.ts index dd3641d..330959e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -502,6 +502,52 @@ export const api = { } }, + /** + * Deletes a session and its associated data + * @param sessionId - The ID of the session to delete + * @param projectId - The ID of the project the session belongs to + * @returns Promise resolving when the session is deleted + */ + async deleteSession(sessionId: string, projectId: string): Promise { + try { + return await invoke('delete_session', { sessionId, projectId }); + } catch (error) { + console.error("Failed to delete session:", error); + throw error; + } + }, + + /** + * Deletes multiple sessions and their associated data + * @param sessionIds - Array of session IDs to delete + * @param projectId - The ID of the project the sessions belong to + * @returns Promise resolving with deletion results + */ + async deleteSessionsBulk(sessionIds: string[], projectId: string): Promise { + try { + return await invoke('delete_sessions_bulk', { sessionIds, projectId }); + } catch (error) { + console.error("Failed to bulk delete sessions:", error); + throw error; + } + }, + + /** + * Saves a pasted image to a file and returns the relative path + * @param projectId - The ID of the project to save the image in + * @param sessionId - The ID of the current session + * @param base64Data - The base64 data URL of the image + * @returns Promise resolving to the relative path of the saved image + */ + async savePastedImage(projectId: string, sessionId: string, base64Data: string): Promise { + try { + return await invoke('save_pasted_image', { projectId, sessionId, base64Data }); + } catch (error) { + console.error("Failed to save pasted image:", error); + throw error; + } + }, + /** * Fetch list of agents from GitHub repository * @returns Promise resolving to list of available agents on GitHub diff --git a/src/services/sessionPersistence.ts b/src/services/sessionPersistence.ts index 2c3f513..cbaebfd 100644 --- a/src/services/sessionPersistence.ts +++ b/src/services/sessionPersistence.ts @@ -15,13 +15,16 @@ export interface SessionRestoreData { lastMessageCount?: number; scrollPosition?: number; timestamp: number; + // Conversation tracking + primaryConversationId?: string; // The first session ID in this conversation + isConversationRoot?: boolean; // True for the first session in a conversation } export class SessionPersistenceService { /** * Save session data for later restoration */ - static saveSession(sessionId: string, projectId: string, projectPath: string, messageCount?: number, scrollPosition?: number): void { + static saveSession(sessionId: string, projectId: string, projectPath: string, messageCount?: number, scrollPosition?: number, primaryConversationId?: string): void { try { const sessionData: SessionRestoreData = { sessionId, @@ -29,7 +32,9 @@ export class SessionPersistenceService { projectPath, lastMessageCount: messageCount, scrollPosition, - timestamp: Date.now() + timestamp: Date.now(), + primaryConversationId: primaryConversationId || sessionId, // Use this sessionId as primary if none provided + isConversationRoot: !primaryConversationId // True if no primary provided (this is the first session) }; // Save individual session data @@ -171,4 +176,53 @@ export class SessionPersistenceService { first_message: "Restored session" }; } + + /** + * Get only the primary conversation sessions (filter out continuation sessions) + */ + static getPrimaryConversationSessions(): string[] { + try { + const index = this.getSessionIndex(); + const primarySessions: string[] = []; + + index.forEach(sessionId => { + const data = this.loadSession(sessionId); + if (data && data.isConversationRoot) { + primarySessions.push(sessionId); + } + }); + + return primarySessions; + } catch (error) { + console.error('Failed to get primary conversation sessions:', error); + return []; + } + } + + /** + * Get all sessions that belong to a specific conversation + */ + static getConversationSessions(primaryConversationId: string): string[] { + try { + const index = this.getSessionIndex(); + const conversationSessions: string[] = []; + + index.forEach(sessionId => { + const data = this.loadSession(sessionId); + if (data && data.primaryConversationId === primaryConversationId) { + conversationSessions.push(sessionId); + } + }); + + return conversationSessions.sort((a, b) => { + const dataA = this.loadSession(a); + const dataB = this.loadSession(b); + if (!dataA || !dataB) return 0; + return dataA.timestamp - dataB.timestamp; // Sort by timestamp + }); + } catch (error) { + console.error('Failed to get conversation sessions:', error); + return []; + } + } } diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index d30d4fa..c865ffc 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -124,9 +124,8 @@ const sessionStore: StateCreator< // Delete session deleteSession: async (sessionId: string, projectId: string) => { try { - // Note: API doesn't have a deleteSession method, so this is a placeholder - console.warn('deleteSession not implemented in API'); - + await api.deleteSession(sessionId, projectId); + // Update local state set((state) => ({ sessions: { @@ -140,7 +139,7 @@ const sessionStore: StateCreator< ) })); } catch (error) { - set({ + set({ error: error instanceof Error ? error.message : 'Failed to delete session' }); throw error;