mirror of
https://github.com/getAsterisk/claudia.git
synced 2025-12-23 11:37:27 +00:00
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:
parent
c9ac4239f4
commit
f394a246f8
17 changed files with 1616 additions and 348 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue