Skip to content

Commit 3dc741f

Browse files
committed
feat: add ability to stop Claude execution mid-way using loading icon as cancel button
1 parent abe0891 commit 3dc741f

5 files changed

Lines changed: 257 additions & 97 deletions

File tree

src-tauri/src/commands/claude.rs

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,25 @@ use std::time::SystemTime;
66
use std::io::{BufRead, BufReader};
77
use std::process::Stdio;
88
use tauri::{AppHandle, Emitter, Manager};
9-
use tokio::process::Command;
9+
use tokio::process::{Command, Child};
10+
use tokio::sync::Mutex;
11+
use std::sync::Arc;
1012
use crate::process::ProcessHandle;
1113
use crate::checkpoint::{CheckpointResult, CheckpointDiff, SessionTimeline, Checkpoint};
1214

15+
/// Global state to track current Claude process
16+
pub struct ClaudeProcessState {
17+
pub current_process: Arc<Mutex<Option<Child>>>,
18+
}
19+
20+
impl Default for ClaudeProcessState {
21+
fn default() -> Self {
22+
Self {
23+
current_process: Arc::new(Mutex::new(None)),
24+
}
25+
}
26+
}
27+
1328
/// Represents a project in the ~/.claude/projects directory
1429
#[derive(Debug, Clone, Serialize, Deserialize)]
1530
pub struct Project {
@@ -925,6 +940,41 @@ pub async fn resume_claude_code(
925940
spawn_claude_process(app, cmd).await
926941
}
927942

943+
/// Cancel the currently running Claude Code execution
944+
#[tauri::command]
945+
pub async fn cancel_claude_execution(app: AppHandle) -> Result<(), String> {
946+
log::info!("Cancelling Claude Code execution");
947+
948+
let claude_state = app.state::<ClaudeProcessState>();
949+
let mut current_process = claude_state.current_process.lock().await;
950+
951+
if let Some(mut child) = current_process.take() {
952+
// Try to get the PID before killing
953+
let pid = child.id();
954+
log::info!("Attempting to kill Claude process with PID: {:?}", pid);
955+
956+
// Kill the process
957+
match child.kill().await {
958+
Ok(_) => {
959+
log::info!("Successfully killed Claude process");
960+
// Emit cancellation event
961+
let _ = app.emit("claude-cancelled", true);
962+
// Also emit complete with false to indicate failure
963+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
964+
let _ = app.emit("claude-complete", false);
965+
Ok(())
966+
}
967+
Err(e) => {
968+
log::error!("Failed to kill Claude process: {}", e);
969+
Err(format!("Failed to kill Claude process: {}", e))
970+
}
971+
}
972+
} else {
973+
log::warn!("No active Claude process to cancel");
974+
Ok(())
975+
}
976+
}
977+
928978
/// Helper function to check if sandboxing should be used based on settings
929979
fn should_use_sandbox(app: &AppHandle) -> Result<bool, String> {
930980
// 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
10971147
let stdout = child.stdout.take().ok_or("Failed to get stdout")?;
10981148
let stderr = child.stderr.take().ok_or("Failed to get stderr")?;
10991149

1150+
// Get the child PID for logging
1151+
let pid = child.id();
1152+
log::info!("Spawned Claude process with PID: {:?}", pid);
1153+
11001154
// Create readers
11011155
let stdout_reader = BufReader::new(stdout);
11021156
let stderr_reader = BufReader::new(stderr);
11031157

1158+
// Store the child process in the global state
1159+
let claude_state = app.state::<ClaudeProcessState>();
1160+
{
1161+
let mut current_process = claude_state.current_process.lock().await;
1162+
*current_process = Some(child);
1163+
}
1164+
11041165
// Spawn tasks to read stdout and stderr
11051166
let app_handle = app.clone();
11061167
let stdout_task = tokio::spawn(async move {
@@ -1123,24 +1184,33 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command) -> Result<(), St
11231184
});
11241185

11251186
// Wait for the process to complete
1187+
let app_handle_wait = app.clone();
1188+
let claude_state_wait = claude_state.current_process.clone();
11261189
tokio::spawn(async move {
11271190
let _ = stdout_task.await;
11281191
let _ = stderr_task.await;
11291192

1130-
match child.wait().await {
1131-
Ok(status) => {
1132-
log::info!("Claude process exited with status: {}", status);
1133-
// Add a small delay to ensure all messages are processed
1134-
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1135-
let _ = app.emit("claude-complete", status.success());
1136-
}
1137-
Err(e) => {
1138-
log::error!("Failed to wait for Claude process: {}", e);
1139-
// Add a small delay to ensure all messages are processed
1140-
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1141-
let _ = app.emit("claude-complete", false);
1193+
// Get the child from the state to wait on it
1194+
let mut current_process = claude_state_wait.lock().await;
1195+
if let Some(mut child) = current_process.take() {
1196+
match child.wait().await {
1197+
Ok(status) => {
1198+
log::info!("Claude process exited with status: {}", status);
1199+
// Add a small delay to ensure all messages are processed
1200+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1201+
let _ = app_handle_wait.emit("claude-complete", status.success());
1202+
}
1203+
Err(e) => {
1204+
log::error!("Failed to wait for Claude process: {}", e);
1205+
// Add a small delay to ensure all messages are processed
1206+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1207+
let _ = app_handle_wait.emit("claude-complete", false);
1208+
}
11421209
}
11431210
}
1211+
1212+
// Clear the process from state
1213+
*current_process = None;
11441214
});
11451215

11461216
Ok(())

src-tauri/src/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use commands::claude::{
1717
get_session_timeline, update_checkpoint_settings, get_checkpoint_diff,
1818
track_checkpoint_message, track_session_messages, check_auto_checkpoint, cleanup_old_checkpoints,
1919
get_checkpoint_settings, clear_checkpoint_manager, get_checkpoint_state_stats,
20-
get_recently_modified_files,
20+
get_recently_modified_files, cancel_claude_execution, ClaudeProcessState,
2121
};
2222
use commands::agents::{
2323
init_database, list_agents, create_agent, update_agent, delete_agent,
@@ -93,6 +93,9 @@ fn main() {
9393
// Initialize process registry
9494
app.manage(ProcessRegistryState::default());
9595

96+
// Initialize Claude process state
97+
app.manage(ClaudeProcessState::default());
98+
9699
Ok(())
97100
})
98101
.invoke_handler(tauri::generate_handler![
@@ -111,6 +114,7 @@ fn main() {
111114
execute_claude_code,
112115
continue_claude_code,
113116
resume_claude_code,
117+
cancel_claude_execution,
114118
list_directory_contents,
115119
search_files,
116120
create_checkpoint,

src/components/ClaudeCodeSession.tsx

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
ChevronDown,
1010
GitBranch,
1111
Settings,
12-
Globe
12+
Globe,
13+
Square
1314
} from "lucide-react";
1415
import { Button } from "@/components/ui/button";
1516
import { Input } from "@/components/ui/input";
@@ -84,6 +85,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
8485
const [showForkDialog, setShowForkDialog] = useState(false);
8586
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
8687
const [forkSessionName, setForkSessionName] = useState("");
88+
const [isCancelling, setIsCancelling] = useState(false);
8789

8890
// New state for preview feature
8991
const [showPreview, setShowPreview] = useState(false);
@@ -278,6 +280,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
278280
const completeUnlisten = await listen<boolean>("claude-complete", async (event) => {
279281
console.log('[ClaudeCodeSession] Received claude-complete:', event.payload);
280282
setIsLoading(false);
283+
setIsCancelling(false);
281284
hasActiveSessionRef.current = false;
282285
if (!event.payload) {
283286
setError("Claude execution failed");
@@ -437,6 +440,40 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
437440
setTimelineVersion((v) => v + 1);
438441
};
439442

443+
const handleCancelExecution = async () => {
444+
if (!isLoading || isCancelling) return;
445+
446+
try {
447+
setIsCancelling(true);
448+
449+
// Cancel the Claude execution
450+
await api.cancelClaudeExecution();
451+
452+
// Clean up listeners
453+
unlistenRefs.current.forEach(unlisten => unlisten());
454+
unlistenRefs.current = [];
455+
456+
// Add a system message indicating cancellation
457+
const cancelMessage: ClaudeStreamMessage = {
458+
type: "system",
459+
subtype: "cancelled",
460+
result: "Execution cancelled by user",
461+
timestamp: new Date().toISOString()
462+
};
463+
setMessages(prev => [...prev, cancelMessage]);
464+
465+
// Reset states
466+
setIsLoading(false);
467+
hasActiveSessionRef.current = false;
468+
setError(null);
469+
} catch (err) {
470+
console.error("Failed to cancel execution:", err);
471+
setError("Failed to cancel execution");
472+
} finally {
473+
setIsCancelling(false);
474+
}
475+
};
476+
440477
const handleFork = (checkpointId: string) => {
441478
setForkCheckpointId(checkpointId);
442479
setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`);
@@ -817,13 +854,50 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
817854
{messagesList}
818855
</div>
819856
)}
857+
858+
{isLoading && enhancedMessages.length === 0 && (
859+
<div className="flex items-center justify-center h-full">
860+
<div className="flex items-center gap-3">
861+
<Loader2 className="h-6 w-6 animate-spin" />
862+
<span className="text-sm text-muted-foreground">
863+
{session ? "Loading session history..." : "Initializing Claude Code..."}
864+
</span>
865+
</div>
866+
</div>
867+
)}
868+
869+
<AnimatePresence>
870+
{enhancedMessages.map((message, index) => (
871+
<motion.div
872+
key={index}
873+
initial={{ opacity: 0, y: 10 }}
874+
animate={{ opacity: 1, y: 0 }}
875+
transition={{ duration: 0.2 }}
876+
>
877+
<ErrorBoundary>
878+
<StreamMessage message={message} streamMessages={enhancedMessages} />
879+
</ErrorBoundary>
880+
</motion.div>
881+
))}
882+
</AnimatePresence>
883+
884+
{/* Show loading indicator when processing, even if there are messages */}
885+
{isLoading && enhancedMessages.length > 0 && (
886+
<div className="flex items-center gap-2 p-4">
887+
<Loader2 className="h-4 w-4 animate-spin" />
888+
<span className="text-sm text-muted-foreground">
889+
{isCancelling ? "Cancelling..." : "Processing..."}
890+
</span>
891+
</div>
892+
)}
820893
</div>
821894

822895
{/* Floating Prompt Input - Always visible */}
823896
<ErrorBoundary>
824897
<FloatingPromptInput
825898
ref={floatingPromptRef}
826899
onSend={handleSendPrompt}
900+
onCancel={handleCancelExecution}
827901
isLoading={isLoading}
828902
disabled={!projectPath}
829903
projectPath={projectPath}
@@ -844,14 +918,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
844918
)}
845919
</div>
846920

847-
{/* Preview Prompt Dialog */}
848-
<PreviewPromptDialog
849-
isOpen={showPreviewPrompt}
850-
url={detectedUrl}
851-
onConfirm={handleOpenPreview}
852-
onCancel={() => setShowPreviewPrompt(false)}
853-
/>
854-
855921
{/* Fork Dialog */}
856922
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
857923
<DialogContent>
@@ -912,4 +978,4 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
912978
)}
913979
</div>
914980
);
915-
};
981+
};

0 commit comments

Comments
 (0)