feat: Add comprehensive UI improvements and session management features

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
This commit is contained in:
iflytwice 2025-09-23 18:20:33 -04:00
parent c9ac4239f4
commit f394a246f8
17 changed files with 1616 additions and 348 deletions

View file

@ -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<serde_json::Value,
Err(e) => 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<String, String> {
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<String>,
project_id: String,
) -> Result<String, String> {
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<String, String> {
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)
}

View file

@ -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,

View file

@ -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<AgentExecutionProps> = ({
const [elapsedTime, setElapsedTime] = useState(0);
const [hasUserScrolled, setHasUserScrolled] = useState(false);
const [isFullscreenModalOpen, setIsFullscreenModalOpen] = useState(false);
const { autoScrollEnabled } = useAutoScroll();
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -232,7 +233,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
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<AgentExecutionProps> = ({
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<AgentExecutionProps> = ({
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<AgentExecutionProps> = ({
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);

View file

@ -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<HTMLDivElement>) => {
if (!autoScrollEnabled) return;
const target = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = target;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

View file

@ -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<ClaudeCodeSessionProps> = ({
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<ClaudeStreamMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
@ -105,7 +111,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// Add collapsed state for queued prompts
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
// Auto-scroll hook
const { autoScrollEnabled } = useAutoScroll();
const parentRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const hasActiveSessionRef = useRef(false);
@ -250,10 +259,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// 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<ClaudeCodeSessionProps> = ({
await checkForActiveSession();
}
};
initializeSession();
}
}, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount
@ -273,10 +279,12 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// 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<ClaudeCodeSessionProps> = ({
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<ClaudeCodeSessionProps> = ({
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<ClaudeCodeSessionProps> = ({
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<ClaudeCodeSessionProps> = ({
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<ClaudeCodeSessionProps> = ({
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<ClaudeCodeSessionProps> = ({
// 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<ClaudeCodeSessionProps> = ({
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
projectId={effectiveSession?.project_id}
extraMenuItems={
<>
{effectiveSession && (

View file

@ -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<string[]>([]);
const [dragActive, setDragActive] = useState(false);
const [imagePathMap, setImagePathMap] = useState<Map<string, string>>(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<HTMLTextAreaElement>(null);
const expandedTextareaRef = useRef<HTMLTextAreaElement>(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<string>(); // 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<string>(); // 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 && (
<ImagePreview
images={embeddedImages}
images={imagePathsForPreview}
onRemove={handleRemoveImage}
className="border-t border-border pt-2"
/>
@ -1005,7 +1113,7 @@ const FloatingPromptInputInner = (
{/* Image previews */}
{embeddedImages.length > 0 && (
<ImagePreview
images={embeddedImages}
images={imagePathsForPreview}
onRemove={handleRemoveImage}
className="border-b border-border"
/>

View file

@ -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<SessionListProps> = ({
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<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set());
// Process sessions into hierarchical structure
const hierarchicalSessions = useMemo(() => {
const sessionMap = new Map<string, Session>();
const primarySessions: HierarchicalSession[] = [];
const childSessionsMap = new Map<string, Session[]>();
// 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 (
<TooltipProvider>
@ -86,64 +312,192 @@ export const SessionList: React.FC<SessionListProps> = ({
</motion.div>
)}
<AnimatePresence mode="popLayout">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{currentSessions.map((session, index) => (
<motion.div
key={session.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: [0.4, 0, 0.2, 1],
}}
>
<Card
className={cn(
"p-3 hover:bg-accent/50 transition-all duration-200 cursor-pointer group h-full",
session.todo_data && "bg-primary/5"
)}
onClick={() => {
// 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 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button
variant={isMultiSelectMode ? "default" : "outline"}
size="sm"
onClick={toggleMultiSelectMode}
>
{isMultiSelectMode ? "Exit Select" : "Select Multiple"}
</Button>
{isMultiSelectMode && (
<>
<Button
variant="outline"
size="sm"
onClick={allCurrentSessionsSelected ? clearAllSelections : selectAllSessions}
>
<div className="flex flex-col h-full">
<div className="flex-1">
{/* Session header */}
{allCurrentSessionsSelected ? (
<>
<Square className="h-3 w-3 mr-1" />
Deselect All
</>
) : (
<>
<CheckSquare className="h-3 w-3 mr-1" />
Select All
</>
)}
</Button>
{selectedSessions.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
>
<Trash className="h-3 w-3 mr-1" />
Delete Selected ({selectedSessions.size})
</Button>
)}
</>
)}
</div>
{isMultiSelectMode && selectedSessions.size > 0 && (
<span className="text-sm text-muted-foreground">
{selectedSessions.size} session{selectedSessions.size > 1 ? 's' : ''} selected
</span>
)}
</div>
<AnimatePresence mode="popLayout">
<div className="space-y-4">
{currentHierarchicalSessions.map((hierarchicalSession, index) => {
const { session, children } = hierarchicalSession;
const isExpanded = expandedSessions.has(session.id);
const hasChildren = children.length > 0;
return (
<motion.div
key={session.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: [0.4, 0, 0.2, 1],
}}
className="space-y-2"
>
{/* Primary Session Card */}
<Card
className={cn(
"p-3 hover:bg-accent/50 transition-all duration-200 cursor-pointer group",
session.todo_data && "bg-primary/5",
isMultiSelectMode && selectedSessions.has(session.id) && "bg-primary/10 border-primary",
hasChildren && "border-l-4 border-l-blue-500/30"
)}
onClick={() => {
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);
}
}}
>
<div className="flex flex-col">
{/* Primary Session Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-1.5 flex-1 min-w-0">
<Clock className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex items-start gap-2 flex-1 min-w-0">
{/* Folder icon and expand/collapse for sessions with children */}
{hasChildren ? (
<div className="flex items-center gap-1">
{isExpanded ? (
<>
<FolderOpen className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0 mt-1" />
</>
) : (
<>
<Folder className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0 mt-1" />
</>
)}
</div>
) : (
<Clock className="h-4 w-4 text-primary shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p className="text-body-small font-medium">
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'
})
}
</p>
<div className="flex items-center gap-2">
<p className="text-body-small font-medium">
{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'
})
}
</p>
{hasChildren && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{children.length + 1} session{children.length + 1 > 1 ? 's' : ''}
</span>
)}
</div>
</div>
</div>
{session.todo_data && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-caption font-medium bg-primary/10 text-primary">
Todo
</span>
)}
<div className="flex items-center gap-2">
{session.todo_data && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-caption font-medium bg-primary/10 text-primary">
Todo
</span>
)}
{/* Multi-select checkbox */}
{isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-100 hover:bg-primary/10"
onClick={(e) => {
e.stopPropagation();
toggleSessionSelection(session.id);
}}
title={selectedSessions.has(session.id) ? "Deselect session" : "Select session"}
>
{selectedSessions.has(session.id) ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</Button>
)}
{/* Delete button (hidden in multi-select mode) */}
{!isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => handleDeleteSession(e, session)}
title="Delete session"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* First message preview */}
{session.first_message ? (
<p className="text-caption text-muted-foreground line-clamp-2 mb-2">
@ -154,21 +508,154 @@ export const SessionList: React.FC<SessionListProps> = ({
No messages yet
</p>
)}
{/* Metadata footer */}
<div className="flex items-center justify-between pt-2 border-t">
<p className="text-caption text-muted-foreground font-mono">
{session.id.slice(-8)}
</p>
{session.todo_data && (
<MessageSquare className="h-3 w-3 text-primary" />
)}
</div>
</div>
{/* Metadata footer */}
<div className="flex items-center justify-between pt-2 border-t">
<p className="text-caption text-muted-foreground font-mono">
{session.id.slice(-8)}
</p>
{session.todo_data && (
<MessageSquare className="h-3 w-3 text-primary" />
)}
</div>
</div>
</Card>
</motion.div>
))}
</Card>
{/* Child Sessions */}
{hasChildren && isExpanded && (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="ml-8 space-y-2"
>
{children.map((childSession, childIndex) => (
<motion.div
key={childSession.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: childIndex * 0.05 }}
>
<Card
className={cn(
"p-3 hover:bg-accent/30 transition-all duration-200 cursor-pointer group border-l-2 border-l-muted-foreground/20",
childSession.todo_data && "bg-primary/5",
isMultiSelectMode && selectedSessions.has(childSession.id) && "bg-primary/10 border-primary"
)}
onClick={() => {
if (isMultiSelectMode) {
toggleSessionSelection(childSession.id);
} else {
const event = new CustomEvent('claude-session-selected', {
detail: { session: childSession, projectPath }
});
window.dispatchEvent(event);
onSessionClick?.(childSession);
}
}}
>
<div className="flex flex-col">
{/* Child Session Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs"></span>
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{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'
})
}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Latest session indicator */}
{childIndex === 0 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-500/10 text-green-600 border border-green-500/20">
Latest
</span>
)}
{childSession.todo_data && (
<span className="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary">
Todo
</span>
)}
{/* Multi-select checkbox */}
{isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-100 hover:bg-primary/10"
onClick={(e) => {
e.stopPropagation();
toggleSessionSelection(childSession.id);
}}
title={selectedSessions.has(childSession.id) ? "Deselect session" : "Select session"}
>
{selectedSessions.has(childSession.id) ? (
<CheckSquare className="h-3.5 w-3.5 text-primary" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</Button>
)}
{/* Delete button (hidden in multi-select mode) */}
{!isMultiSelectMode && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => handleDeleteSession(e, childSession)}
title="Delete session"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Child Session Message Preview */}
{childSession.first_message && (
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
{truncateText(getFirstLine(childSession.first_message), 100)}
</p>
)}
{/* Child Session Footer */}
<div className="flex items-center justify-between pt-1 border-t border-muted-foreground/10">
<p className="text-xs text-muted-foreground font-mono">
{childSession.id.slice(-8)}
</p>
{childSession.todo_data && (
<MessageSquare className="h-3 w-3 text-primary" />
)}
</div>
</div>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
)}
</motion.div>
);
})}
</div>
</AnimatePresence>

View file

@ -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<HTMLDivElement>(null);
const outputEndRef = useRef<HTMLDivElement>(null);
const fullscreenScrollRef = useRef<HTMLDivElement>(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);

View file

@ -96,6 +96,8 @@ export const Settings: React.FC<SettingsProps> = ({
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<SettingsProps> = ({
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<SettingsProps> = ({
}}
/>
</div>
{/* Auto-scroll Setting */}
<div className="flex items-center justify-between py-3 border-b border-border/50 last:border-b-0">
<div className="space-y-1">
<Label htmlFor="auto-scroll">Enable Auto-scroll in Conversations</Label>
<p className="text-caption text-muted-foreground">
Automatically scroll to the bottom when new messages arrive during conversations
</p>
</div>
<Switch
id="auto-scroll"
checked={autoScrollEnabled}
onCheckedChange={async (checked) => {
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' });
}
}}
/>
</div>
</div>
</div>
</Card>

View file

@ -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<StreamMessageProps> = ({ message, classNa
return (
<SystemInitializedWidget
sessionId={message.session_id}
childSessionId={message.original_session_id || message.session_id}
model={message.model}
cwd={message.cwd}
tools={message.tools}
@ -122,10 +141,13 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ 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 (
<div key={idx} className="prose prose-sm dark:prose-invert max-w-none">
@ -358,10 +380,11 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
return <CommandOutputWidget output={output} onLinkDetected={onLinkDetected} />;
}
// Otherwise render as plain text
// Otherwise render as plain text with cleaned image paths
const cleanedContent = cleanImagePaths(contentStr);
return (
<div className="text-sm">
{contentStr}
{cleanedContent}
</div>
);
})()
@ -612,10 +635,13 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ 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 (
<div key={idx} className="text-sm">

View file

@ -223,10 +223,16 @@ const TabPanel: React.FC<TabPanelProps> = ({ 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 }
}));
}}
/>

View file

@ -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 (
<div className="space-y-2">
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<FileText className="h-4 w-4 text-primary" />
<span className="text-sm">File content:</span>
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded flex-1 truncate">
{filePath}
</code>
</div>
{resultContent && <ReadResultWidget content={resultContent} filePath={filePath} />}
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-blue-500/10 transition-colors"
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
{filePath}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">({lineCount} lines)</span>
<ChevronRight className={cn(
"h-4 w-4 text-blue-500 transition-transform",
isExpanded && "rotate-90"
)} />
</div>
</button>
{isExpanded && resultContent && (
<div className="border-t border-blue-500/20">
<ReadResultWidget content={resultContent} filePath={filePath} forceExpanded={true} />
</div>
)}
{!isExpanded && (
<div className="px-4 py-3 text-xs text-muted-foreground text-center bg-blue-500/5 border-t border-blue-500/20">
Click to expand and view file content ({lineCount} lines)
</div>
)}
</div>
);
}
@ -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 (
<div className="relative overflow-x-auto">
<SyntaxHighlighter
language={language}
style={syntaxTheme}
showLineNumbers
startingLineNumber={startLineNumber}
wrapLongLines={false}
customStyle={{
margin: 0,
background: 'transparent',
lineHeight: '1.6'
}}
codeTagProps={{
style: {
fontSize: '0.75rem'
}
}}
>
{codeContent}
</SyntaxHighlighter>
</div>
);
}
return (
<div className="rounded-lg overflow-hidden border bg-zinc-950 w-full">
@ -518,18 +571,16 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }>
</span>
)}
</div>
{isLargeFile && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
{isExpanded ? "Collapse" : "Expand"}
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
{isExpanded ? "Collapse" : `Expand (${lineCount} lines)`}
</button>
</div>
{(!isLargeFile || isExpanded) && (
{isExpanded && (
<div className="relative overflow-x-auto">
<SyntaxHighlighter
language={language}
@ -559,9 +610,9 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }>
</div>
)}
{isLargeFile && !isExpanded && (
{!isExpanded && (
<div className="px-4 py-3 text-xs text-muted-foreground text-center bg-zinc-900/30">
Click "Expand" to view the full file
Click "Expand" to view the file content ({lineCount} lines)
</div>
)}
</div>
@ -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?:
</div>
);
const lineCount = content.split('\n').length;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
@ -857,8 +911,23 @@ export const WriteWidget: React.FC<{ filePath: string; content: string; result?:
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded flex-1 truncate">
{filePath}
</code>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
{isExpanded ? "Collapse" : `Preview (${lineCount} lines)`}
</button>
</div>
<CodePreview codeContent={displayContent} truncated={true} />
{isExpanded && <CodePreview codeContent={displayContent} truncated={true} />}
{!isExpanded && (
<div className="px-4 py-3 text-xs text-muted-foreground text-center bg-muted/30 rounded-lg">
Click "Preview" to view the file content ({lineCount} lines)
</div>
)}
<MaximizedView />
</div>
);
@ -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 (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
@ -1143,9 +1214,17 @@ export const EditWidget: React.FC<{
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded flex-1 truncate">
{file_path}
</code>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
{isExpanded ? "Collapse" : "Show Diff"}
</button>
</div>
<div className="rounded-lg border bg-zinc-950 overflow-hidden text-xs font-mono">
{isExpanded && (
<div className="rounded-lg border bg-zinc-950 overflow-hidden text-xs font-mono">
<div className="max-h-[440px] overflow-y-auto overflow-x-auto">
{diffResult.map((part, index) => {
const partClass = part.added
@ -1194,7 +1273,14 @@ export const EditWidget: React.FC<{
);
})}
</div>
</div>
</div>
)}
{!isExpanded && (
<div className="px-4 py-3 text-xs text-muted-foreground text-center bg-muted/30 rounded-lg">
Click "Show Diff" to view the edit changes
</div>
)}
</div>
);
};
@ -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 (
<div className="rounded-lg border bg-zinc-950 overflow-hidden">
<div className="px-4 py-2 border-b bg-emerald-950/30 flex items-center gap-2">
<GitBranch className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-mono text-emerald-400">Edit Result</span>
{filePath && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">{filePath}</span>
</>
)}
</div>
<div className="overflow-x-auto max-h-[440px]">
<SyntaxHighlighter
language={language}
style={syntaxTheme}
showLineNumbers
startingLineNumber={startLineNumber}
wrapLongLines={false}
customStyle={{
margin: 0,
background: 'transparent',
lineHeight: '1.6'
}}
codeTagProps={{
style: {
fontSize: '0.75rem'
}
}}
lineNumberStyle={{
minWidth: "3.5rem",
paddingRight: "1rem",
textAlign: "right",
opacity: 0.5,
}}
<div className="px-4 py-2 border-b bg-emerald-950/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-mono text-emerald-400">Edit Result</span>
{filePath && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">{filePath}</span>
</>
)}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{codeContent}
</SyntaxHighlighter>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
{isExpanded ? "Collapse" : `View (${resultLineCount} lines)`}
</button>
</div>
{isExpanded && (
<div className="overflow-x-auto max-h-[440px]">
<SyntaxHighlighter
language={language}
style={syntaxTheme}
showLineNumbers
startingLineNumber={startLineNumber}
wrapLongLines={false}
customStyle={{
margin: 0,
background: 'transparent',
lineHeight: '1.6'
}}
codeTagProps={{
style: {
fontSize: '0.75rem'
}
}}
lineNumberStyle={{
minWidth: "3.5rem",
paddingRight: "1rem",
textAlign: "right",
opacity: 0.5,
}}
>
{codeContent}
</SyntaxHighlighter>
</div>
)}
{!isExpanded && (
<div className="px-4 py-3 text-xs text-muted-foreground text-center bg-emerald-950/10">
Edit completed successfully. Click "View" to see the result ({resultLineCount} lines)
</div>
)}
</div>
);
};
@ -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 (
<div className="space-y-3">
<div className="flex items-center gap-2 px-3 py-2 bg-green-500/10 rounded-t-md border-b border-green-500/20">
<GitBranch className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-green-600 dark:text-green-400">
{edits.length} Changes Applied
</span>
<div className="flex items-center justify-between px-3 py-2 bg-green-500/10 rounded-t-md border-b border-green-500/20">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-green-600 dark:text-green-400">
{edits.length} Changes Applied
</span>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
{isExpanded ? "Collapse" : "View Changes"}
</button>
</div>
<div className="space-y-4">
{isExpanded && (
<div className="space-y-4">
{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<{
</div>
);
})}
</div>
</div>
)}
{!isExpanded && (
<div className="px-4 py-3 text-xs text-muted-foreground text-center bg-green-500/5 rounded-md">
{edits.length} changes applied successfully. Click "View Changes" to see details.
</div>
)}
</div>
);
}
@ -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<string, LucideIcon> = {
'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<string, string[]>);
return (
<Card className="border-blue-500/20 bg-blue-500/5">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-blue-500 mt-0.5" />
<div className="flex-1 space-y-4">
<h4 className="font-semibold text-sm">System Initialized</h4>
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-blue-500/10 transition-colors"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
System Initialized
</span>
</div>
<ChevronRight className={cn(
"h-4 w-4 text-blue-500 transition-transform",
isExpanded && "rotate-90"
)} />
</button>
{isExpanded && (
<div className="px-4 pb-4 pt-2 border-t border-blue-500/20">
<div className="space-y-4">
{/* Session Info */}
<div className="space-y-2">
<div className="space-y-1">
{sessionId && (
<div className="flex items-center gap-2 text-xs">
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Session ID:</span>
<span className="text-muted-foreground">Primary Session:</span>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{sessionId}
...{sessionId.slice(-8)}
</code>
<span className="text-muted-foreground text-xs">
({new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })})
</span>
</div>
)}
{model && (
<div className="flex items-center gap-2 text-xs">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Model:</span>
{childSessionId && childSessionId !== sessionId && (
<div className="flex items-center gap-2 text-xs ml-4">
<span className="text-muted-foreground"> Child:</span>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{model}
</code>
</div>
)}
{cwd && (
<div className="flex items-center gap-2 text-xs">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Working Directory:</span>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded break-all">
{cwd}
...{childSessionId.slice(-8)}
</code>
<span className="text-muted-foreground text-xs">
(current)
</span>
</div>
)}
</div>
{model && (
<div className="flex items-center gap-2 text-xs">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Model:</span>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{model}
</code>
</div>
)}
{cwd && (
<div className="flex items-center gap-2 text-xs">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Working Directory:</span>
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded break-all">
{cwd}
</code>
</div>
)}
{/* Regular Tools */}
{regularTools.length > 0 && (
<div className="space-y-2">
@ -1928,9 +2085,9 @@ export const SystemInitializedWidget: React.FC<{
{regularTools.map((tool, idx) => {
const Icon = getToolIcon(tool);
return (
<Badge
key={idx}
variant="secondary"
<Badge
key={idx}
variant="secondary"
className="text-xs py-0.5 px-2 flex items-center gap-1"
>
<Icon className="h-3 w-3" />
@ -1941,7 +2098,7 @@ export const SystemInitializedWidget: React.FC<{
</div>
</div>
)}
{/* MCP Tools */}
{mcpTools.length > 0 && (
<div className="space-y-2">
@ -1956,7 +2113,7 @@ export const SystemInitializedWidget: React.FC<{
mcpExpanded && "rotate-180"
)} />
</button>
{mcpExpanded && (
<div className="ml-5 space-y-3">
{Object.entries(mcpToolsByProvider).map(([provider, providerTools]) => (
@ -1970,9 +2127,9 @@ export const SystemInitializedWidget: React.FC<{
{providerTools.map((tool, idx) => {
const { method } = formatMcpToolName(tool);
return (
<Badge
key={idx}
variant="outline"
<Badge
key={idx}
variant="outline"
className="text-xs py-0 px-1.5 font-normal"
>
{method}
@ -1986,7 +2143,7 @@ export const SystemInitializedWidget: React.FC<{
)}
</div>
)}
{/* Show message if no tools */}
{tools.length === 0 && (
<div className="text-xs text-muted-foreground italic">
@ -1995,8 +2152,8 @@ export const SystemInitializedWidget: React.FC<{
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
};

View file

@ -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<MessageListProps> = React.memo(({
const scrollContainerRef = useRef<HTMLDivElement>(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<MessageListProps> = 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<MessageListProps> = 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 (

View file

@ -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';

View file

@ -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<string> {
try {
return await invoke<string>('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<string> {
try {
return await invoke<string>('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<string> {
try {
return await invoke<string>('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

View file

@ -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 [];
}
}
}

View file

@ -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;