Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src-tauri/src/chat/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,101 @@ pub async fn archive_session(
Ok(new_active)
}

/// Move a session from one worktree to another, optionally migrating uncommitted changes
#[tauri::command]
pub async fn move_session_to_worktree(
app: AppHandle,
session_id: String,
from_worktree_id: String,
to_worktree_id: String,
migrate_changes: bool,
from_worktree_path: String,
to_worktree_path: String,
) -> Result<(), String> {
log::trace!(
"Moving session {session_id} from {from_worktree_id} to {to_worktree_id} (migrate_changes: {migrate_changes})"
);

// Stash changes from source worktree if requested
let stashed = if migrate_changes {
match crate::projects::git::git_stash(&from_worktree_path) {
Ok(msg) => {
// "No local changes to save" means nothing was stashed
!msg.contains("No local changes to save")
}
Err(e) => {
log::warn!("Failed to stash changes: {e}");
return Err(format!("Failed to stash changes from source worktree: {e}"));
}
}
} else {
false
};

// Build a conversation transcript from run logs before the move,
// so the new CLI session has context of the prior conversation.
// The claude_session_id is cleared during move, so without this transcript
// Claude CLI would start a completely fresh conversation.
if let Ok(messages) = super::run_log::load_session_messages(&app, &session_id) {
if !messages.is_empty() {
let mut transcript = String::from(
"# Previous Conversation (migrated session)\n\n\
This session was moved from another worktree. Below is the conversation history.\n\n"
);
for msg in &messages {
match msg.role {
super::types::MessageRole::User => {
transcript.push_str("## User\n\n");
transcript.push_str(&msg.content);
transcript.push_str("\n\n");
}
super::types::MessageRole::Assistant => {
transcript.push_str("## Assistant\n\n");
// Include text content only (tool calls are noise)
if !msg.content.is_empty() {
transcript.push_str(&msg.content);
} else {
transcript.push_str("*(tool use only)*");
}
transcript.push_str("\n\n");
}
}
}

// Save as a per-session context file that gets auto-loaded
if let Ok(contexts_dir) = super::storage::get_saved_contexts_dir(&app) {
let context_filename = format!("{session_id}-context-migration-transcript.md");
let context_path = contexts_dir.join(&context_filename);
if let Err(e) = std::fs::write(&context_path, &transcript) {
log::warn!("Failed to write migration transcript: {e}");
} else {
log::info!("Saved migration transcript for session {session_id} ({} messages)", messages.len());
}
}
}
}

// Move the session in storage
super::storage::move_session_to_worktree(&app, &session_id, &from_worktree_id, &to_worktree_id)?;

// Apply stashed changes to target worktree
if stashed {
match crate::projects::git::git_stash_pop(&to_worktree_path) {
Ok(_) => {
log::trace!("Successfully applied stashed changes to target worktree");
}
Err(e) => {
// Don't fail the move — changes are safely in the stash
log::warn!("Failed to apply stashed changes to target worktree: {e}. Changes remain in git stash.");
// We still emit success but the frontend will show a warning
}
}
}

emit_sessions_cache_invalidation(&app);
Ok(())
}

/// Unarchive a session (restore it to the session list)
#[tauri::command]
pub async fn unarchive_session(
Expand Down
77 changes: 77 additions & 0 deletions src-tauri/src/chat/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,83 @@ pub fn restore_base_sessions(
Ok(Some(index))
}

// ============================================================================
// Move Session Between Worktrees
// ============================================================================

/// Move a session from one worktree to another.
/// Updates both worktree indices and the session metadata atomically.
pub fn move_session_to_worktree(
app: &AppHandle,
session_id: &str,
from_worktree_id: &str,
to_worktree_id: &str,
) -> Result<(), String> {
if from_worktree_id == to_worktree_id {
return Ok(());
}

// Lock both indices in alphabetical order to prevent deadlocks
let (first_id, second_id) = if from_worktree_id < to_worktree_id {
(from_worktree_id, to_worktree_id)
} else {
(to_worktree_id, from_worktree_id)
};

let first_lock = get_index_lock(first_id);
let _first_guard = first_lock.lock().unwrap();
let second_lock = get_index_lock(second_id);
let _second_guard = second_lock.lock().unwrap();

// Load both indices
let mut from_index = load_index_internal(app, from_worktree_id)?;
let mut to_index = load_index_internal(app, to_worktree_id)?;

// Find and remove the session entry from source
let entry_pos = from_index
.sessions
.iter()
.position(|e| e.id == session_id)
.ok_or_else(|| format!("Session {session_id} not found in worktree {from_worktree_id}"))?;
let mut entry = from_index.sessions.remove(entry_pos);

// Set order to append at end of target
let max_order = to_index.sessions.iter().map(|e| e.order).max().unwrap_or(0);
entry.order = max_order + 1;

// Add to target index
to_index.sessions.push(entry);

// If the moved session was the active one in source, pick next available
if from_index.active_session_id.as_deref() == Some(session_id) {
from_index.active_session_id = from_index.sessions.first().map(|e| e.id.clone());
}

// Save both indices
save_index_internal(app, &from_index)?;
save_index_internal(app, &to_index)?;

// Update session metadata: worktree_id + clear CLI session IDs
// CLI session IDs are tied to the original worktree's working directory
// and cannot be resumed from a different worktree, so we must clear them.
// The message history is preserved in the NDJSON run logs.
let metadata_lock = get_metadata_lock(session_id);
let _metadata_guard = metadata_lock.lock().unwrap();
if let Some(mut metadata) = load_metadata_internal(app, session_id)? {
metadata.worktree_id = to_worktree_id.to_string();
metadata.claude_session_id = None;
metadata.codex_thread_id = None;
metadata.opencode_session_id = None;
save_metadata_internal(app, &metadata)?;
}

log::info!(
"Moved session {session_id} from worktree {from_worktree_id} to {to_worktree_id}"
);

Ok(())
}

// ============================================================================
// Saved Contexts (unchanged from original)
// ============================================================================
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/http_server/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,27 @@ pub async fn dispatch_command(
.await?;
to_value(result)
}
"move_session_to_worktree" => {
let session_id: String = field(&args, "sessionId", "session_id")?;
let from_worktree_id: String = field(&args, "fromWorktreeId", "from_worktree_id")?;
let to_worktree_id: String = field(&args, "toWorktreeId", "to_worktree_id")?;
let migrate_changes: bool = from_field(&args, "migrateChanges").unwrap_or(false);
let from_worktree_path: String =
field(&args, "fromWorktreePath", "from_worktree_path")?;
let to_worktree_path: String = field(&args, "toWorktreePath", "to_worktree_path")?;
crate::chat::move_session_to_worktree(
app.clone(),
session_id,
from_worktree_id,
to_worktree_id,
migrate_changes,
from_worktree_path,
to_worktree_path,
)
.await?;
emit_cache_invalidation(app, &["sessions"]);
Ok(Value::Null)
}
"unarchive_session" => {
let worktree_id: String = field(&args, "worktreeId", "worktree_id")?;
let worktree_path: String = field(&args, "worktreePath", "worktree_path")?;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,7 @@ pub fn run() {
chat::update_session_state,
chat::close_session,
chat::archive_session,
chat::move_session_to_worktree,
chat::unarchive_session,
chat::restore_session_with_base,
chat::delete_archived_session,
Expand Down
65 changes: 65 additions & 0 deletions src/components/chat/MoveSessionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'

interface MoveSessionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
sourceWorktreeName: string
targetWorktreeName: string
onMoveWithChanges: () => void
onMoveWithoutChanges: () => void
}

export function MoveSessionDialog({
open,
onOpenChange,
sourceWorktreeName,
targetWorktreeName,
onMoveWithChanges,
onMoveWithoutChanges,
}: MoveSessionDialogProps) {
if (!open) return null

return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent onEscapeKeyDown={e => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Migrate uncommitted changes?</AlertDialogTitle>
<AlertDialogDescription>
<strong>{sourceWorktreeName}</strong> has uncommitted changes. Do
you want to move them to <strong>{targetWorktreeName}</strong>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
variant="outline"
onClick={() => {
onMoveWithoutChanges()
onOpenChange(false)
}}
>
Move without changes
</Button>
<Button
autoFocus
onClick={() => {
onMoveWithChanges()
onOpenChange(false)
}}
>
Move with changes
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
Loading