feat: add ability to stop Claude execution mid-way using loading icon as cancel button

This commit is contained in:
Vivek R
2025-06-23 00:45:58 +05:30
parent abe0891b0b
commit 3dc741fd6b
5 changed files with 257 additions and 97 deletions

View File

@@ -6,10 +6,25 @@ use std::time::SystemTime;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::process::Stdio; use std::process::Stdio;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tokio::process::Command; use tokio::process::{Command, Child};
use tokio::sync::Mutex;
use std::sync::Arc;
use crate::process::ProcessHandle; use crate::process::ProcessHandle;
use crate::checkpoint::{CheckpointResult, CheckpointDiff, SessionTimeline, Checkpoint}; use crate::checkpoint::{CheckpointResult, CheckpointDiff, SessionTimeline, Checkpoint};
/// Global state to track current Claude process
pub struct ClaudeProcessState {
pub current_process: Arc<Mutex<Option<Child>>>,
}
impl Default for ClaudeProcessState {
fn default() -> Self {
Self {
current_process: Arc::new(Mutex::new(None)),
}
}
}
/// Represents a project in the ~/.claude/projects directory /// Represents a project in the ~/.claude/projects directory
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project { pub struct Project {
@@ -925,6 +940,41 @@ pub async fn resume_claude_code(
spawn_claude_process(app, cmd).await spawn_claude_process(app, cmd).await
} }
/// Cancel the currently running Claude Code execution
#[tauri::command]
pub async fn cancel_claude_execution(app: AppHandle) -> Result<(), String> {
log::info!("Cancelling Claude Code execution");
let claude_state = app.state::<ClaudeProcessState>();
let mut current_process = claude_state.current_process.lock().await;
if let Some(mut child) = current_process.take() {
// Try to get the PID before killing
let pid = child.id();
log::info!("Attempting to kill Claude process with PID: {:?}", pid);
// Kill the process
match child.kill().await {
Ok(_) => {
log::info!("Successfully killed Claude process");
// Emit cancellation event
let _ = app.emit("claude-cancelled", true);
// Also emit complete with false to indicate failure
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = app.emit("claude-complete", false);
Ok(())
}
Err(e) => {
log::error!("Failed to kill Claude process: {}", e);
Err(format!("Failed to kill Claude process: {}", e))
}
}
} else {
log::warn!("No active Claude process to cancel");
Ok(())
}
}
/// Helper function to check if sandboxing should be used based on settings /// Helper function to check if sandboxing should be used based on settings
fn should_use_sandbox(app: &AppHandle) -> Result<bool, String> { fn should_use_sandbox(app: &AppHandle) -> Result<bool, String> {
// First check if sandboxing is even available on this platform // First check if sandboxing is even available on this platform
@@ -1097,10 +1147,21 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St
let stdout = child.stdout.take().ok_or("Failed to get stdout")?; let stdout = child.stdout.take().ok_or("Failed to get stdout")?;
let stderr = child.stderr.take().ok_or("Failed to get stderr")?; let stderr = child.stderr.take().ok_or("Failed to get stderr")?;
// Get the child PID for logging
let pid = child.id();
log::info!("Spawned Claude process with PID: {:?}", pid);
// Create readers // Create readers
let stdout_reader = BufReader::new(stdout); let stdout_reader = BufReader::new(stdout);
let stderr_reader = BufReader::new(stderr); let stderr_reader = BufReader::new(stderr);
// Store the child process in the global state
let claude_state = app.state::<ClaudeProcessState>();
{
let mut current_process = claude_state.current_process.lock().await;
*current_process = Some(child);
}
// Spawn tasks to read stdout and stderr // Spawn tasks to read stdout and stderr
let app_handle = app.clone(); let app_handle = app.clone();
let stdout_task = tokio::spawn(async move { let stdout_task = tokio::spawn(async move {
@@ -1123,24 +1184,33 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St
}); });
// Wait for the process to complete // Wait for the process to complete
let app_handle_wait = app.clone();
let claude_state_wait = claude_state.current_process.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _ = stdout_task.await; let _ = stdout_task.await;
let _ = stderr_task.await; let _ = stderr_task.await;
match child.wait().await { // Get the child from the state to wait on it
Ok(status) => { let mut current_process = claude_state_wait.lock().await;
log::info!("Claude process exited with status: {}", status); if let Some(mut child) = current_process.take() {
// Add a small delay to ensure all messages are processed match child.wait().await {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; Ok(status) => {
let _ = app.emit("claude-complete", status.success()); log::info!("Claude process exited with status: {}", status);
} // Add a small delay to ensure all messages are processed
Err(e) => { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
log::error!("Failed to wait for Claude process: {}", e); let _ = app_handle_wait.emit("claude-complete", status.success());
// Add a small delay to ensure all messages are processed }
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; Err(e) => {
let _ = app.emit("claude-complete", false); log::error!("Failed to wait for Claude process: {}", e);
// Add a small delay to ensure all messages are processed
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = app_handle_wait.emit("claude-complete", false);
}
} }
} }
// Clear the process from state
*current_process = None;
}); });
Ok(()) Ok(())

View File

@@ -17,7 +17,7 @@ use commands::claude::{
get_session_timeline, update_checkpoint_settings, get_checkpoint_diff, get_session_timeline, update_checkpoint_settings, get_checkpoint_diff,
track_checkpoint_message, track_session_messages, check_auto_checkpoint, cleanup_old_checkpoints, track_checkpoint_message, track_session_messages, check_auto_checkpoint, cleanup_old_checkpoints,
get_checkpoint_settings, clear_checkpoint_manager, get_checkpoint_state_stats, get_checkpoint_settings, clear_checkpoint_manager, get_checkpoint_state_stats,
get_recently_modified_files, get_recently_modified_files, cancel_claude_execution, ClaudeProcessState,
}; };
use commands::agents::{ use commands::agents::{
init_database, list_agents, create_agent, update_agent, delete_agent, init_database, list_agents, create_agent, update_agent, delete_agent,
@@ -93,6 +93,9 @@ fn main() {
// Initialize process registry // Initialize process registry
app.manage(ProcessRegistryState::default()); app.manage(ProcessRegistryState::default());
// Initialize Claude process state
app.manage(ClaudeProcessState::default());
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -111,6 +114,7 @@ fn main() {
execute_claude_code, execute_claude_code,
continue_claude_code, continue_claude_code,
resume_claude_code, resume_claude_code,
cancel_claude_execution,
list_directory_contents, list_directory_contents,
search_files, search_files,
create_checkpoint, create_checkpoint,

View File

@@ -9,7 +9,8 @@ import {
ChevronDown, ChevronDown,
GitBranch, GitBranch,
Settings, Settings,
Globe Globe,
Square
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -84,6 +85,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [showForkDialog, setShowForkDialog] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false);
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null); const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
const [forkSessionName, setForkSessionName] = useState(""); const [forkSessionName, setForkSessionName] = useState("");
const [isCancelling, setIsCancelling] = useState(false);
// New state for preview feature // New state for preview feature
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
@@ -278,6 +280,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const completeUnlisten = await listen<boolean>("claude-complete", async (event) => { const completeUnlisten = await listen<boolean>("claude-complete", async (event) => {
console.log('[ClaudeCodeSession] Received claude-complete:', event.payload); console.log('[ClaudeCodeSession] Received claude-complete:', event.payload);
setIsLoading(false); setIsLoading(false);
setIsCancelling(false);
hasActiveSessionRef.current = false; hasActiveSessionRef.current = false;
if (!event.payload) { if (!event.payload) {
setError("Claude execution failed"); setError("Claude execution failed");
@@ -437,6 +440,40 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
setTimelineVersion((v) => v + 1); setTimelineVersion((v) => v + 1);
}; };
const handleCancelExecution = async () => {
if (!isLoading || isCancelling) return;
try {
setIsCancelling(true);
// Cancel the Claude execution
await api.cancelClaudeExecution();
// Clean up listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Add a system message indicating cancellation
const cancelMessage: ClaudeStreamMessage = {
type: "system",
subtype: "cancelled",
result: "Execution cancelled by user",
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, cancelMessage]);
// Reset states
setIsLoading(false);
hasActiveSessionRef.current = false;
setError(null);
} catch (err) {
console.error("Failed to cancel execution:", err);
setError("Failed to cancel execution");
} finally {
setIsCancelling(false);
}
};
const handleFork = (checkpointId: string) => { const handleFork = (checkpointId: string) => {
setForkCheckpointId(checkpointId); setForkCheckpointId(checkpointId);
setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`); setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`);
@@ -817,6 +854,42 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
{messagesList} {messagesList}
</div> </div>
)} )}
{isLoading && enhancedMessages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm text-muted-foreground">
{session ? "Loading session history..." : "Initializing Claude Code..."}
</span>
</div>
</div>
)}
<AnimatePresence>
{enhancedMessages.map((message, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
</ErrorBoundary>
</motion.div>
))}
</AnimatePresence>
{/* Show loading indicator when processing, even if there are messages */}
{isLoading && enhancedMessages.length > 0 && (
<div className="flex items-center gap-2 p-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">
{isCancelling ? "Cancelling..." : "Processing..."}
</span>
</div>
)}
</div> </div>
{/* Floating Prompt Input - Always visible */} {/* Floating Prompt Input - Always visible */}
@@ -824,6 +897,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<FloatingPromptInput <FloatingPromptInput
ref={floatingPromptRef} ref={floatingPromptRef}
onSend={handleSendPrompt} onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading} isLoading={isLoading}
disabled={!projectPath} disabled={!projectPath}
projectPath={projectPath} projectPath={projectPath}
@@ -844,14 +918,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
)} )}
</div> </div>
{/* Preview Prompt Dialog */}
<PreviewPromptDialog
isOpen={showPreviewPrompt}
url={detectedUrl}
onConfirm={handleOpenPreview}
onCancel={() => setShowPreviewPrompt(false)}
/>
{/* Fork Dialog */} {/* Fork Dialog */}
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}> <Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
<DialogContent> <DialogContent>
@@ -912,4 +978,4 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
)} )}
</div> </div>
); );
}; };

View File

@@ -1,12 +1,13 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
Send, Send,
Maximize2, Maximize2,
Minimize2, Minimize2,
ChevronUp, ChevronUp,
Sparkles, Sparkles,
Zap Zap,
Square
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -42,6 +43,10 @@ interface FloatingPromptInputProps {
* Optional className for styling * Optional className for styling
*/ */
className?: string; className?: string;
/**
* Callback when cancel is clicked (only during loading)
*/
onCancel?: () => void;
} }
export interface FloatingPromptInputRef { export interface FloatingPromptInputRef {
@@ -81,14 +86,18 @@ const MODELS: Model[] = [
* isLoading={false} * isLoading={false}
* /> * />
*/ */
export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, FloatingPromptInputProps>(({ const FloatingPromptInputInner = (
onSend, {
isLoading = false, onSend,
disabled = false, isLoading = false,
defaultModel = "sonnet", disabled = false,
projectPath, defaultModel = "sonnet",
className, projectPath,
}, ref) => { className,
onCancel,
}: FloatingPromptInputProps,
ref: React.Ref<FloatingPromptInputRef>,
) => {
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel); const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@@ -98,11 +107,11 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
const [cursorPosition, setCursorPosition] = useState(0); const [cursorPosition, setCursorPosition] = useState(0);
const [embeddedImages, setEmbeddedImages] = useState<string[]>([]); const [embeddedImages, setEmbeddedImages] = useState<string[]>([]);
const [dragActive, setDragActive] = useState(false); const [dragActive, setDragActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const expandedTextareaRef = useRef<HTMLTextAreaElement>(null); const expandedTextareaRef = useRef<HTMLTextAreaElement>(null);
const unlistenDragDropRef = useRef<(() => void) | null>(null); const unlistenDragDropRef = useRef<(() => void) | null>(null);
// Expose a method to add images programmatically // Expose a method to add images programmatically
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
@@ -113,17 +122,17 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
if (existingPaths.includes(imagePath)) { if (existingPaths.includes(imagePath)) {
return currentPrompt; // Image already added return currentPrompt; // Image already added
} }
const mention = `@${imagePath}`; const mention = `@${imagePath}`;
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' ';
// Focus the textarea // Focus the textarea
setTimeout(() => { setTimeout(() => {
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
target?.focus(); target?.focus();
target?.setSelectionRange(newPrompt.length, newPrompt.length); target?.setSelectionRange(newPrompt.length, newPrompt.length);
}, 0); }, 0);
return newPrompt; return newPrompt;
}); });
} }
@@ -144,7 +153,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
const matches = Array.from(text.matchAll(regex)); const matches = Array.from(text.matchAll(regex));
console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0])); console.log('[extractImagePaths] Regex matches:', matches.map(m => m[0]));
const pathsSet = new Set<string>(); // Use Set to ensure uniqueness const pathsSet = new Set<string>(); // Use Set to ensure uniqueness
for (const match of matches) { for (const match of matches) {
const path = match[1]; const path = match[1];
console.log('[extractImagePaths] Processing path:', path); console.log('[extractImagePaths] Processing path:', path);
@@ -155,7 +164,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
pathsSet.add(fullPath); // Add to Set (automatically handles duplicates) pathsSet.add(fullPath); // Add to Set (automatically handles duplicates)
} }
} }
const uniquePaths = Array.from(pathsSet); // Convert Set back to Array const uniquePaths = Array.from(pathsSet); // Convert Set back to Array
console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths); console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths);
return uniquePaths; return uniquePaths;
@@ -212,7 +221,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
const mentionsToAdd = newPaths.map(p => `@${p}`).join(' '); const mentionsToAdd = newPaths.map(p => `@${p}`).join(' ');
const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' '; const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' ';
setTimeout(() => { setTimeout(() => {
const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; const target = isExpanded ? expandedTextareaRef.current : textareaRef.current;
target?.focus(); target?.focus();
@@ -260,7 +269,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
const newCursorPosition = e.target.selectionStart || 0; const newCursorPosition = e.target.selectionStart || 0;
// Check if @ was just typed // Check if @ was just typed
if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') { if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') {
console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath); console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath);
@@ -268,7 +277,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
setFilePickerQuery(""); setFilePickerQuery("");
setCursorPosition(newCursorPosition); setCursorPosition(newCursorPosition);
} }
// Check if we're typing after @ (for search query) // Check if we're typing after @ (for search query)
if (showFilePicker && newCursorPosition >= cursorPosition) { if (showFilePicker && newCursorPosition >= cursorPosition) {
// Find the @ position before cursor // Find the @ position before cursor
@@ -283,7 +292,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
break; break;
} }
} }
if (atPosition !== -1) { if (atPosition !== -1) {
const query = newValue.substring(atPosition + 1, newCursorPosition); const query = newValue.substring(atPosition + 1, newCursorPosition);
setFilePickerQuery(query); setFilePickerQuery(query);
@@ -293,7 +302,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
setFilePickerQuery(""); setFilePickerQuery("");
} }
} }
setPrompt(newValue); setPrompt(newValue);
setCursorPosition(newCursorPosition); setCursorPosition(newCursorPosition);
}; };
@@ -304,15 +313,15 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
const textarea = textareaRef.current; const textarea = textareaRef.current;
const beforeAt = prompt.substring(0, cursorPosition - 1); const beforeAt = prompt.substring(0, cursorPosition - 1);
const afterCursor = prompt.substring(cursorPosition + filePickerQuery.length); const afterCursor = prompt.substring(cursorPosition + filePickerQuery.length);
const relativePath = entry.path.startsWith(projectPath || '') const relativePath = entry.path.startsWith(projectPath || '')
? entry.path.slice((projectPath || '').length + 1) ? entry.path.slice((projectPath || '').length + 1)
: entry.path; : entry.path;
const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`; const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`;
setPrompt(newPrompt); setPrompt(newPrompt);
setShowFilePicker(false); setShowFilePicker(false);
setFilePickerQuery(""); setFilePickerQuery("");
// Focus back on textarea and set cursor position after the inserted path // Focus back on textarea and set cursor position after the inserted path
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
@@ -321,7 +330,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
}, 0); }, 0);
} }
}; };
const handleFilePickerClose = () => { const handleFilePickerClose = () => {
setShowFilePicker(false); setShowFilePicker(false);
setFilePickerQuery(""); setFilePickerQuery("");
@@ -338,7 +347,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
setFilePickerQuery(""); setFilePickerQuery("");
return; return;
} }
if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker) { if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
@@ -366,12 +375,12 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'), new RegExp(`@${imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'),
new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g') new RegExp(`@${imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g')
]; ];
let newPrompt = prompt; let newPrompt = prompt;
for (const pattern of patterns) { for (const pattern of patterns) {
newPrompt = newPrompt.replace(pattern, ''); newPrompt = newPrompt.replace(pattern, '');
} }
setPrompt(newPrompt.trim()); setPrompt(newPrompt.trim());
}; };
@@ -407,7 +416,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
<Minimize2 className="h-4 w-4" /> <Minimize2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* Image previews in expanded mode */} {/* Image previews in expanded mode */}
{embeddedImages.length > 0 && ( {embeddedImages.length > 0 && (
<ImagePreview <ImagePreview
@@ -416,7 +425,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
className="border-t border-border pt-2" className="border-t border-border pt-2"
/> />
)} )}
<Textarea <Textarea
ref={expandedTextareaRef} ref={expandedTextareaRef}
value={prompt} value={prompt}
@@ -429,7 +438,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
onDragOver={handleDrag} onDragOver={handleDrag}
onDrop={handleDrop} onDrop={handleDrop}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Model:</span> <span className="text-xs text-muted-foreground">Model:</span>
@@ -443,22 +452,19 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
{selectedModelData.name} {selectedModelData.name}
</Button> </Button>
</div> </div>
{isLoading ? ( <Button
<div className="flex items-center justify-center min-w-[60px] h-10"> onClick={handleSend}
<div className="rotating-symbol text-primary text-2xl"></div> disabled={!prompt.trim() || isLoading || disabled}
</div> size="default"
) : ( className="min-w-[60px]"
<Button >
onClick={handleSend} {isLoading ? (
disabled={!prompt.trim() || disabled} <div className="rotating-symbol text-primary-foreground" />
size="sm" ) : (
className="min-w-[80px]" <Send className="h-4 w-4" />
> )}
<Send className="mr-2 h-4 w-4" /> </Button>
Send
</Button>
)}
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -466,7 +472,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
</AnimatePresence> </AnimatePresence>
{/* Fixed Position Input Bar */} {/* Fixed Position Input Bar */}
<div <div
className={cn( className={cn(
"fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border", "fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border",
dragActive && "ring-2 ring-primary ring-offset-2", dragActive && "ring-2 ring-primary ring-offset-2",
@@ -486,7 +492,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
className="border-b border-border" className="border-b border-border"
/> />
)} )}
<div className="p-4"> <div className="p-4">
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
{/* Model Picker */} {/* Model Picker */}
@@ -534,7 +540,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
align="start" align="start"
side="top" side="top"
/> />
{/* Prompt Input */} {/* Prompt Input */}
<div className="flex-1 relative"> <div className="flex-1 relative">
<Textarea <Textarea
@@ -550,7 +556,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
)} )}
rows={1} rows={1}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -560,7 +566,7 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
> >
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
</Button> </Button>
{/* File Picker */} {/* File Picker */}
<AnimatePresence> <AnimatePresence>
{showFilePicker && projectPath && projectPath.trim() && ( {showFilePicker && projectPath && projectPath.trim() && (
@@ -573,24 +579,26 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Send Button */} {/* Send/Stop Button */}
{isLoading ? ( <Button
<div className="flex items-center justify-center min-w-[60px] h-10"> onClick={isLoading ? onCancel : handleSend}
<div className="rotating-symbol text-primary text-2xl"></div> disabled={isLoading ? false : (!prompt.trim() || disabled)}
</div> variant={isLoading ? "destructive" : "default"}
) : ( size="default"
<Button className="min-w-[60px]"
onClick={handleSend} >
disabled={!prompt.trim() || disabled} {isLoading ? (
size="default" <>
className="min-w-[60px]" <Square className="h-4 w-4 mr-1" />
> Stop
</>
) : (
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> )}
)} </Button>
</div> </div>
<div className="mt-2 text-xs text-muted-foreground"> <div className="mt-2 text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop images"} Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files, drag & drop images"}
</div> </div>
@@ -599,6 +607,11 @@ export const FloatingPromptInput = React.forwardRef<FloatingPromptInputRef, Floa
</div> </div>
</> </>
); );
}); };
FloatingPromptInput.displayName = 'FloatingPromptInput'; export const FloatingPromptInput = React.forwardRef<
FloatingPromptInputRef,
FloatingPromptInputProps
>(FloatingPromptInputInner);
FloatingPromptInput.displayName = 'FloatingPromptInput';

View File

@@ -918,6 +918,13 @@ export const api = {
return invoke("resume_claude_code", { projectPath, sessionId, prompt, model }); return invoke("resume_claude_code", { projectPath, sessionId, prompt, model });
}, },
/**
* Cancels the currently running Claude Code execution
*/
async cancelClaudeExecution(): Promise<void> {
return invoke("cancel_claude_execution");
},
/** /**
* Lists files and directories in a given path * Lists files and directories in a given path
*/ */