diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 90641c2acb..45cb59a996 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -4,8 +4,9 @@ use std::collections::{HashMap, HashSet}; use tauri::Manager; use crate::app_error::AppCommandError; -use crate::db::entities::conversation; use crate::db::entities::folder::FolderKind; +use crate::db::entities::{conversation, folder, opened_tab}; +use crate::db::error::DbError; use crate::db::service::{conversation_service, folder_service, import_service, tab_service}; #[cfg(feature = "tauri-runtime")] use crate::db::AppDatabase; @@ -2201,6 +2202,221 @@ pub async fn update_conversation_title_core( .map_err(AppCommandError::from) } +#[derive(Debug)] +pub(crate) struct MoveConversationOutcome { + pub summary: DbConversationSummary, + pub tab_change: Option<(i64, Vec)>, +} + +fn validate_conversation_move_models( + conversation: &conversation::Model, + target: &folder::Model, +) -> Result<(), AppCommandError> { + if conversation.kind != conversation::ConversationKind::Regular + || conversation.parent_id.is_some() + { + return Err(AppCommandError::invalid_input( + "Only top-level workspace conversations can be moved", + )); + } + if target.kind == FolderKind::Chat { + return Err(AppCommandError::invalid_input( + "A conversation cannot be moved into a chat scratch folder", + )); + } + Ok(()) +} + +/// Read-only validation performed before an idle ACP connection is detached. +/// The transaction repeats these checks so a concurrent delete cannot turn the +/// subsequent write into a partial move. +async fn validate_conversation_move_request( + conn: &sea_orm::DatabaseConnection, + conversation_id: i32, + target_folder_id: i32, +) -> Result<(conversation::Model, folder::Model), AppCommandError> { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let conversation = conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await + .map_err(DbError::from)? + .ok_or_else(|| AppCommandError::not_found("Conversation not found"))?; + let target = folder::Entity::find_by_id(target_folder_id) + .filter(folder::Column::DeletedAt.is_null()) + .one(conn) + .await + .map_err(DbError::from)? + .ok_or_else(|| AppCommandError::not_found("Target folder not found"))?; + + validate_conversation_move_models(&conversation, &target)?; + if conversation.folder_id != target_folder_id { + let metadata = tokio::fs::metadata(&target.path).await.map_err(|error| { + AppCommandError::invalid_input("Target folder is not accessible") + .with_detail(error.to_string()) + })?; + if !metadata.is_dir() { + return Err(AppCommandError::invalid_input( + "Target folder path is not a directory", + )); + } + } + Ok((conversation, target)) +} + +/// Atomically rebind one historical conversation and its persisted open tab to +/// another registered workspace folder. +/// +/// The native transcript stays where the agent wrote it. `origin_cwd` keeps the +/// first real working directory for path-keyed agent fallbacks, while the new +/// folder becomes the cwd passed to the next ACP resume. Moving back to that +/// original directory clears the marker. The tab version advances in the same +/// SQLite transaction, preventing a stale client save from restoring the old +/// folder id after the move. +pub(crate) async fn move_conversation_core( + conn: &sea_orm::DatabaseConnection, + conversation_id: i32, + target_folder_id: i32, +) -> Result { + use chrono::Utc; + use sea_orm::sea_query::Expr; + use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, + TransactionTrait, + }; + + let (preflight, _) = + validate_conversation_move_request(conn, conversation_id, target_folder_id).await?; + if preflight.folder_id == target_folder_id { + return Ok(MoveConversationOutcome { + summary: conversation_service::get_by_id(conn, conversation_id) + .await + .map_err(AppCommandError::from)?, + tab_change: None, + }); + } + + let _tab_guard = tab_service::lock_version_mutations().await; + let txn = conn.begin().await.map_err(DbError::from)?; + + let conversation = conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(&txn) + .await + .map_err(DbError::from)? + .ok_or_else(|| AppCommandError::not_found("Conversation not found"))?; + let target = folder::Entity::find_by_id(target_folder_id) + .filter(folder::Column::DeletedAt.is_null()) + .one(&txn) + .await + .map_err(DbError::from)? + .ok_or_else(|| AppCommandError::not_found("Target folder not found"))?; + validate_conversation_move_models(&conversation, &target)?; + + let source_path = folder::Entity::find_by_id(conversation.folder_id) + .one(&txn) + .await + .map_err(DbError::from)? + .map(|source| source.path); + let original_cwd = conversation.origin_cwd.clone().or(source_path); + let next_origin_cwd = original_cwd.filter(|path| !path_eq_for_matching(path, &target.path)); + + let mut active = conversation.into_active_model(); + active.folder_id = Set(target_folder_id); + active.origin_cwd = Set(next_origin_cwd); + active.update(&txn).await.map_err(DbError::from)?; + + opened_tab::Entity::update_many() + .col_expr(opened_tab::Column::FolderId, Expr::value(target_folder_id)) + .col_expr(opened_tab::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(opened_tab::Column::ConversationId.eq(conversation_id)) + .exec(&txn) + .await + .map_err(DbError::from)?; + let tabs_version = tab_service::bump_version_locked(&txn) + .await + .map_err(AppCommandError::from)?; + let tabs = tab_service::list_all_tabs(&txn) + .await + .map_err(AppCommandError::from)?; + txn.commit().await.map_err(DbError::from)?; + + let summary = conversation_service::get_by_id(conn, conversation_id) + .await + .map_err(AppCommandError::from)?; + Ok(MoveConversationOutcome { + summary, + tab_change: Some((tabs_version, tabs)), + }) +} + +/// User-facing move orchestration: reject an in-flight turn, detach an idle +/// ACP process whose cwd is immutable for its lifetime, then commit and +/// broadcast the new conversation/tab location. +pub async fn move_conversation_with_runtime_core( + emitter: &EventEmitter, + conn: &sea_orm::DatabaseConnection, + manager: &crate::acp::manager::ConnectionManager, + conversation_id: i32, + target_folder_id: i32, +) -> Result { + let (preflight, _) = + validate_conversation_move_request(conn, conversation_id, target_folder_id).await?; + if preflight.folder_id == target_folder_id { + return conversation_service::get_by_id(conn, conversation_id) + .await + .map_err(AppCommandError::from); + } + if manager + .pending_user_message_for_conversation(conversation_id) + .await + .is_some() + { + return Err(AppCommandError::new( + crate::app_error::AppErrorCode::TurnInProgress, + "Wait for the current turn to finish before moving this conversation", + )); + } + if let Some(connection_id) = manager + .find_connection_by_conversation_id(conversation_id) + .await + { + manager.disconnect(&connection_id).await.map_err(|error| { + AppCommandError::task_execution_failed( + "Failed to detach the conversation before moving it", + ) + .with_detail(error.to_string()) + })?; + } + + let outcome = move_conversation_core(conn, conversation_id, target_folder_id).await?; + if let Some((version, tabs)) = outcome.tab_change { + emit_conversation_upsert(emitter, conn, conversation_id).await; + emit_tabs_changed(emitter, version, tabs, "server".to_string()); + } + Ok(outcome.summary) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn move_conversation( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + manager: tauri::State<'_, crate::acp::manager::ConnectionManager>, + conversation_id: i32, + target_folder_id: i32, +) -> Result { + move_conversation_with_runtime_core( + &EventEmitter::Tauri(app), + &db.conn, + &manager, + conversation_id, + target_folder_id, + ) + .await +} + /// Re-read the persisted conversation title and best-effort sync it to any /// bound chat-channel threads (e.g. Telegram forum topics). Lives in /// `commands/` so web handlers route through a `_core` helper instead of @@ -3968,6 +4184,651 @@ mod tests { } } + async fn conversation_row( + db: &crate::db::AppDatabase, + conversation_id: i32, + ) -> conversation::Model { + use sea_orm::EntityTrait; + + conversation::Entity::find_by_id(conversation_id) + .one(&db.conn) + .await + .expect("query conversation") + .expect("conversation row") + } + + #[tokio::test] + async fn move_conversation_core_rebinds_existing_source_and_open_tab_atomically() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let unrelated = tempfile::tempdir().expect("unrelated tempdir"); + let source_path = source.path().to_string_lossy().to_string(); + let target_path = target.path().to_string_lossy().to_string(); + let source_id = seed_folder(&db, &source_path).await; + let target_id = seed_folder(&db, &target_path).await; + let unrelated_id = seed_folder(&db, &unrelated.path().to_string_lossy()).await; + let moved_id = create_conversation_core( + &db.conn, + source_id, + AgentType::Codex, + Some("move me".into()), + ) + .await + .expect("moved conversation"); + let unrelated_conversation = create_conversation_core( + &db.conn, + unrelated_id, + AgentType::ClaudeCode, + Some("leave me".into()), + ) + .await + .expect("unrelated conversation"); + let before_updated_at = conversation_row(&db, moved_id).await.updated_at; + + let saved = save_opened_tabs_core( + &db.conn, + &EventEmitter::Noop, + vec![ + conv_tab(source_id, moved_id, AgentType::Codex), + conv_tab(unrelated_id, unrelated_conversation, AgentType::ClaudeCode), + ], + 0, + "test".into(), + ) + .await + .expect("seed tabs"); + assert_eq!(saved.version, 1); + + let outcome = move_conversation_core(&db.conn, moved_id, target_id) + .await + .expect("move conversation"); + assert_eq!(outcome.summary.folder_id, target_id); + assert_eq!( + outcome.summary.origin_cwd.as_deref(), + Some(source_path.as_str()) + ); + assert_eq!(outcome.summary.updated_at, before_updated_at); + let (version, tabs) = outcome.tab_change.expect("move changes tabs"); + assert_eq!(version, 2); + assert_eq!(tabs.len(), 2); + assert_eq!( + tabs.iter() + .find(|tab| tab.conversation_id == Some(moved_id)) + .expect("moved tab") + .folder_id, + target_id + ); + assert_eq!( + tabs.iter() + .find(|tab| tab.conversation_id == Some(unrelated_conversation)) + .expect("unrelated tab") + .folder_id, + unrelated_id, + "moving one conversation must not rewrite another tab" + ); + + let persisted = list_opened_tabs_core(&db.conn) + .await + .expect("persisted tabs"); + assert_eq!(persisted.version, 2); + assert_eq!(persisted.items.len(), tabs.len()); + assert_eq!( + persisted + .items + .iter() + .map(|tab| (tab.conversation_id, tab.folder_id)) + .collect::>(), + tabs.iter() + .map(|tab| (tab.conversation_id, tab.folder_id)) + .collect::>() + ); + + // A client save prepared before the move cannot restore the old folder. + let stale = save_opened_tabs_core( + &db.conn, + &EventEmitter::Noop, + vec![ + conv_tab(source_id, moved_id, AgentType::Codex), + conv_tab(unrelated_id, unrelated_conversation, AgentType::ClaudeCode), + ], + 1, + "stale-client".into(), + ) + .await + .expect("stale save returns current truth"); + assert!(!stale.accepted); + assert_eq!(stale.version, 2); + assert_eq!( + stale + .tabs + .iter() + .find(|tab| tab.conversation_id == Some(moved_id)) + .expect("moved tab survives") + .folder_id, + target_id + ); + } + + #[tokio::test] + async fn move_conversation_core_allows_a_missing_source_directory() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let source_path = source.path().to_string_lossy().to_string(); + let source_id = seed_folder(&db, &source_path).await; + let target = tempfile::tempdir().expect("target tempdir"); + let target_id = seed_folder(&db, &target.path().to_string_lossy()).await; + let conversation_id = create_conversation_core( + &db.conn, + source_id, + AgentType::Gemini, + Some("orphaned workspace".into()), + ) + .await + .expect("conversation"); + drop(source); + assert!(!std::path::Path::new(&source_path).exists()); + + let outcome = move_conversation_core(&db.conn, conversation_id, target_id) + .await + .expect("a missing old directory must not block migration"); + assert_eq!(outcome.summary.folder_id, target_id); + assert_eq!( + outcome.summary.origin_cwd.as_deref(), + Some(source_path.as_str()) + ); + assert_eq!( + outcome.tab_change.expect("version barrier").0, + 1, + "a move without an open tab still advances the tab CAS barrier" + ); + } + + #[tokio::test] + async fn move_conversation_core_preserves_first_origin_and_clears_it_on_return() { + let db = fresh_in_memory_db().await; + let original = tempfile::tempdir().expect("original tempdir"); + let second = tempfile::tempdir().expect("second tempdir"); + let third = tempfile::tempdir().expect("third tempdir"); + let original_path = original.path().to_string_lossy().to_string(); + let original_id = seed_folder(&db, &original_path).await; + let second_id = seed_folder(&db, &second.path().to_string_lossy()).await; + let third_id = seed_folder(&db, &third.path().to_string_lossy()).await; + let conversation_id = create_conversation_core( + &db.conn, + original_id, + AgentType::ClaudeCode, + Some("round trip".into()), + ) + .await + .expect("conversation"); + + let second_move = move_conversation_core(&db.conn, conversation_id, second_id) + .await + .expect("first move"); + assert_eq!( + second_move.summary.origin_cwd.as_deref(), + Some(original_path.as_str()) + ); + + let third_move = move_conversation_core(&db.conn, conversation_id, third_id) + .await + .expect("second move"); + assert_eq!(third_move.summary.folder_id, third_id); + assert_eq!( + third_move.summary.origin_cwd.as_deref(), + Some(original_path.as_str()), + "later moves must retain the transcript's first working directory" + ); + + let returned = move_conversation_core(&db.conn, conversation_id, original_id) + .await + .expect("move back to origin"); + assert_eq!(returned.summary.folder_id, original_id); + assert_eq!(returned.summary.origin_cwd, None); + } + + #[tokio::test] + async fn move_conversation_core_same_folder_is_a_true_noop_even_if_path_is_missing() { + let db = fresh_in_memory_db().await; + let source_id = seed_folder(&db, "/definitely/missing/codeg-move-noop").await; + let conversation_id = + create_conversation_core(&db.conn, source_id, AgentType::Codex, Some("stay".into())) + .await + .expect("conversation"); + save_opened_tabs_core( + &db.conn, + &EventEmitter::Noop, + vec![conv_tab(source_id, conversation_id, AgentType::Codex)], + 0, + "test".into(), + ) + .await + .expect("seed tab"); + + let outcome = move_conversation_core(&db.conn, conversation_id, source_id) + .await + .expect("same-folder move"); + assert_eq!(outcome.summary.folder_id, source_id); + assert_eq!(outcome.summary.origin_cwd, None); + assert!(outcome.tab_change.is_none()); + assert_eq!( + list_opened_tabs_core(&db.conn).await.expect("tabs").version, + 1, + "a no-op must not invalidate every client's tab version" + ); + } + + #[tokio::test] + async fn move_conversation_core_rejects_missing_file_and_deleted_targets_without_writes() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let source_id = seed_folder(&db, &source.path().to_string_lossy()).await; + let conversation_id = create_conversation_core( + &db.conn, + source_id, + AgentType::Codex, + Some("do not move".into()), + ) + .await + .expect("conversation"); + + let missing_path = source.path().join("missing-target"); + let missing_id = seed_folder(&db, &missing_path.to_string_lossy()).await; + let missing_error = move_conversation_core(&db.conn, conversation_id, missing_id) + .await + .expect_err("missing target path"); + assert!(matches!( + missing_error.code, + crate::app_error::AppErrorCode::InvalidInput + )); + + let file_path = source.path().join("plain-file"); + std::fs::write(&file_path, "not a directory").expect("write target file"); + let file_id = seed_folder(&db, &file_path.to_string_lossy()).await; + let file_error = move_conversation_core(&db.conn, conversation_id, file_id) + .await + .expect_err("file target"); + assert!(matches!( + file_error.code, + crate::app_error::AppErrorCode::InvalidInput + )); + assert!(file_error.message.contains("not a directory")); + + let deleted = tempfile::tempdir().expect("deleted target tempdir"); + let deleted_id = seed_folder(&db, &deleted.path().to_string_lossy()).await; + folder_service::soft_delete_folder(&db.conn, deleted_id) + .await + .expect("soft-delete target"); + let deleted_error = move_conversation_core(&db.conn, conversation_id, deleted_id) + .await + .expect_err("deleted target"); + assert!(matches!( + deleted_error.code, + crate::app_error::AppErrorCode::NotFound + )); + + let after = conversation_service::get_by_id(&db.conn, conversation_id) + .await + .expect("conversation unchanged"); + assert_eq!(after.folder_id, source_id); + assert_eq!(after.origin_cwd, None); + assert_eq!( + list_opened_tabs_core(&db.conn).await.expect("tabs").version, + 0 + ); + } + + #[tokio::test] + async fn move_conversation_core_rejects_chat_loop_delegate_and_chat_target() { + use crate::acp::delegation::spawner::DelegationLink; + use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set}; + + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let chat_target = tempfile::tempdir().expect("chat target tempdir"); + let source_id = seed_folder(&db, &source.path().to_string_lossy()).await; + let target_id = seed_folder(&db, &target.path().to_string_lossy()).await; + let chat_target_id = + folder_service::add_chat_folder(&db.conn, &chat_target.path().to_string_lossy()) + .await + .expect("chat folder") + .id; + + let chat = conversation_service::create_chat( + &db.conn, + source_id, + AgentType::Codex, + Some("chat".into()), + None, + ) + .await + .expect("chat conversation"); + let parent = conversation_service::create( + &db.conn, + source_id, + AgentType::ClaudeCode, + Some("parent".into()), + None, + ) + .await + .expect("parent"); + let delegate = conversation_service::create_with_delegation( + &db.conn, + source_id, + AgentType::Codex, + Some("delegate".into()), + None, + Some(DelegationLink { + parent_conversation_id: parent.id, + parent_tool_use_id: "tool-1".into(), + delegation_call_id: "call-1".into(), + }), + ) + .await + .expect("delegate"); + let loop_conversation = conversation_service::create( + &db.conn, + source_id, + AgentType::Gemini, + Some("loop".into()), + None, + ) + .await + .expect("loop seed"); + let mut loop_active = conversation::Entity::find_by_id(loop_conversation.id) + .one(&db.conn) + .await + .expect("query loop") + .expect("loop row") + .into_active_model(); + loop_active.kind = Set(conversation::ConversationKind::Loop); + loop_active.update(&db.conn).await.expect("mark loop"); + + for conversation_id in [chat.id, delegate.id, loop_conversation.id] { + let error = move_conversation_core(&db.conn, conversation_id, target_id) + .await + .expect_err("non-workspace conversation must be rejected"); + assert!(matches!( + error.code, + crate::app_error::AppErrorCode::InvalidInput + )); + assert_eq!( + conversation_row(&db, conversation_id).await.folder_id, + source_id + ); + } + + let target_error = move_conversation_core(&db.conn, parent.id, chat_target_id) + .await + .expect_err("chat scratch target must be rejected"); + assert!(matches!( + target_error.code, + crate::app_error::AppErrorCode::InvalidInput + )); + assert_eq!(conversation_row(&db, parent.id).await.folder_id, source_id); + } + + #[tokio::test] + async fn move_conversation_core_rejects_missing_or_deleted_conversation() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let source_id = seed_folder(&db, &source.path().to_string_lossy()).await; + let target_id = seed_folder(&db, &target.path().to_string_lossy()).await; + + let missing = move_conversation_core(&db.conn, 999_999, target_id) + .await + .expect_err("missing conversation"); + assert!(matches!( + missing.code, + crate::app_error::AppErrorCode::NotFound + )); + + let deleted_id = create_conversation_core( + &db.conn, + source_id, + AgentType::Codex, + Some("deleted".into()), + ) + .await + .expect("conversation"); + delete_conversation_core(&db.conn, deleted_id) + .await + .expect("soft delete conversation"); + let deleted = move_conversation_core(&db.conn, deleted_id, target_id) + .await + .expect_err("deleted conversation"); + assert!(matches!( + deleted.code, + crate::app_error::AppErrorCode::NotFound + )); + } + + #[tokio::test] + async fn move_conversation_runtime_emits_authoritative_conversation_then_tabs() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let source_path = source.path().to_string_lossy().to_string(); + let source_id = seed_folder(&db, &source_path).await; + let target_id = seed_folder(&db, &target.path().to_string_lossy()).await; + let conversation_id = create_conversation_core( + &db.conn, + source_id, + AgentType::Codex, + Some("broadcast move".into()), + ) + .await + .expect("conversation"); + save_opened_tabs_core( + &db.conn, + &EventEmitter::Noop, + vec![conv_tab(source_id, conversation_id, AgentType::Codex)], + 0, + "test".into(), + ) + .await + .expect("seed tab"); + let manager = crate::acp::manager::ConnectionManager::new(); + let (broadcaster, emitter) = sync_test_emitter(); + let mut events = broadcaster.subscribe(); + + let summary = move_conversation_with_runtime_core( + &emitter, + &db.conn, + &manager, + conversation_id, + target_id, + ) + .await + .expect("runtime move"); + assert_eq!(summary.folder_id, target_id); + + let conversation_event = events.try_recv().expect("conversation event"); + assert_eq!(conversation_event.channel, CONVERSATION_CHANGED_EVENT); + assert_eq!(conversation_event.payload["kind"], "upsert"); + assert_eq!( + conversation_event.payload["summary"]["folder_id"], + target_id + ); + assert_eq!( + conversation_event.payload["summary"]["origin_cwd"], + source_path + ); + + let tabs_event = events.try_recv().expect("tabs event"); + assert_eq!(tabs_event.channel, TABS_CHANGED_EVENT); + assert_eq!(tabs_event.payload["version"], 2); + assert_eq!(tabs_event.payload["origin"], "server"); + assert_eq!(tabs_event.payload["tabs"][0]["folder_id"], target_id); + assert!(events.try_recv().is_err(), "move emits exactly two events"); + } + + #[tokio::test] + async fn move_conversation_runtime_disconnects_an_idle_bound_connection() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let source_id = seed_folder(&db, &source.path().to_string_lossy()).await; + let target_id = seed_folder(&db, &target.path().to_string_lossy()).await; + let conversation_id = + create_conversation_core(&db.conn, source_id, AgentType::Codex, Some("idle".into())) + .await + .expect("conversation"); + let manager = crate::acp::manager::ConnectionManager::new(); + let mut commands = manager + .insert_test_connection_live( + "move-idle", + AgentType::Codex, + Some(source.path().to_path_buf()), + EventEmitter::Noop, + ) + .await; + { + let state = manager.get_state("move-idle").await.expect("state"); + state.write().await.conversation_id = Some(conversation_id); + } + + move_conversation_with_runtime_core( + &EventEmitter::Noop, + &db.conn, + &manager, + conversation_id, + target_id, + ) + .await + .expect("move idle conversation"); + assert!( + manager + .find_connection_by_conversation_id(conversation_id) + .await + .is_none(), + "the old-cwd ACP process must be detached" + ); + let command = tokio::time::timeout(std::time::Duration::from_secs(1), commands.recv()) + .await + .expect("disconnect command timeout") + .expect("disconnect command"); + assert!(matches!( + command, + crate::acp::connection::ConnectionCommand::Disconnect + )); + } + + #[tokio::test] + async fn move_conversation_runtime_rejects_an_in_flight_turn_without_side_effects() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let source_id = seed_folder(&db, &source.path().to_string_lossy()).await; + let target_id = seed_folder(&db, &target.path().to_string_lossy()).await; + let conversation_id = + create_conversation_core(&db.conn, source_id, AgentType::Codex, Some("busy".into())) + .await + .expect("conversation"); + let manager = crate::acp::manager::ConnectionManager::new(); + let mut commands = manager + .insert_test_connection_live( + "move-busy", + AgentType::Codex, + Some(source.path().to_path_buf()), + EventEmitter::Noop, + ) + .await; + { + let state = manager.get_state("move-busy").await.expect("state"); + let mut state = state.write().await; + state.conversation_id = Some(conversation_id); + state.pending_user_message = Some(pending_text("message-1", "working")); + state.pending_user_message_started_at = Some(chrono::Utc::now()); + } + let (broadcaster, emitter) = sync_test_emitter(); + let mut events = broadcaster.subscribe(); + + let error = move_conversation_with_runtime_core( + &emitter, + &db.conn, + &manager, + conversation_id, + target_id, + ) + .await + .expect_err("an in-flight turn must block migration"); + assert!(matches!( + error.code, + crate::app_error::AppErrorCode::TurnInProgress + )); + assert_eq!( + conversation_row(&db, conversation_id).await.folder_id, + source_id + ); + assert_eq!( + list_opened_tabs_core(&db.conn).await.expect("tabs").version, + 0 + ); + assert_eq!( + manager + .find_connection_by_conversation_id(conversation_id) + .await + .as_deref(), + Some("move-busy"), + "a rejected move must leave the live ACP connection intact" + ); + assert!(commands.try_recv().is_err()); + assert!(events.try_recv().is_err()); + } + + #[tokio::test] + async fn move_conversation_runtime_same_folder_does_not_disconnect_a_busy_connection() { + let db = fresh_in_memory_db().await; + let source = tempfile::tempdir().expect("source tempdir"); + let source_id = seed_folder(&db, &source.path().to_string_lossy()).await; + let conversation_id = create_conversation_core( + &db.conn, + source_id, + AgentType::Codex, + Some("same folder".into()), + ) + .await + .expect("conversation"); + let manager = crate::acp::manager::ConnectionManager::new(); + let mut commands = manager + .insert_test_connection_live( + "move-noop", + AgentType::Codex, + Some(source.path().to_path_buf()), + EventEmitter::Noop, + ) + .await; + { + let state = manager.get_state("move-noop").await.expect("state"); + let mut state = state.write().await; + state.conversation_id = Some(conversation_id); + state.pending_user_message = Some(pending_text("message-1", "working")); + } + + let summary = move_conversation_with_runtime_core( + &EventEmitter::Noop, + &db.conn, + &manager, + conversation_id, + source_id, + ) + .await + .expect("same-folder move is a no-op"); + assert_eq!(summary.folder_id, source_id); + assert_eq!( + manager + .find_connection_by_conversation_id(conversation_id) + .await + .as_deref(), + Some("move-noop") + ); + assert!(commands.try_recv().is_err()); + } + #[tokio::test] async fn save_opened_tabs_core_persists_only_conversation_tabs_and_bumps_version() { let db = fresh_in_memory_db().await; diff --git a/src-tauri/src/db/entities/conversation.rs b/src-tauri/src/db/entities/conversation.rs index ac4c883669..585f49d159 100644 --- a/src-tauri/src/db/entities/conversation.rs +++ b/src-tauri/src/db/entities/conversation.rs @@ -66,11 +66,11 @@ pub struct Model { /// the sidebar's "Pinned" section (sorted by this timestamp descending). /// Pinning never bumps `updated_at` — it is a view preference, not activity. pub pinned_at: Option, - /// The working directory this conversation actually ran in, when that - /// differs from its (current) folder's path — written when a deleted task - /// worktree's conversations are re-parented to the project folder. The - /// Gemini/Cline/OpenClaw stale-external-id fallback matches on - /// `origin_cwd ?? folder.path`. Always NULL for ordinary conversations. + /// The first working directory associated with this conversation's native + /// transcript when it differs from the current folder. Written by an + /// explicit conversation move or when a deleted task worktree is + /// re-parented. The Gemini/Cline/OpenClaw stale-external-id fallback matches + /// on `origin_cwd ?? folder.path`; moving back to the origin clears it. pub origin_cwd: Option, } diff --git a/src-tauri/src/db/migration/m20260801_000001_work_task.rs b/src-tauri/src/db/migration/m20260801_000001_work_task.rs index 2cecae7a30..bf304149f1 100644 --- a/src-tauri/src/db/migration/m20260801_000001_work_task.rs +++ b/src-tauri/src/db/migration/m20260801_000001_work_task.rs @@ -216,11 +216,11 @@ impl MigrationTrait for Migration { ) .await?; - // conversation.origin_cwd: the working directory a conversation actually - // ran in when that differs from its (current) folder's path — written when - // a deleted task worktree's conversations are re-parented to the project - // folder. The Gemini/Cline/OpenClaw stale-external-id fallback matches on - // `origin_cwd ?? folder.path`. Always NULL for ordinary conversations. + // conversation.origin_cwd: the native transcript's first working + // directory when that differs from the current folder — written by an + // explicit conversation move or a deleted-worktree re-parent. The + // Gemini/Cline/OpenClaw stale-external-id fallback matches on + // `origin_cwd ?? folder.path`; moving back to the origin clears it. manager .alter_table( Table::alter() diff --git a/src-tauri/src/db/service/tab_service.rs b/src-tauri/src/db/service/tab_service.rs index 875ab5ffed..ba6e7c8099 100644 --- a/src-tauri/src/db/service/tab_service.rs +++ b/src-tauri/src/db/service/tab_service.rs @@ -24,6 +24,25 @@ fn version_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +/// Hold the same process-wide guard used by every tab-version mutation while a +/// higher-level operation changes both a conversation and its persisted tab in +/// one database transaction. Keeping this accessor here prevents callers from +/// accidentally introducing a second lock that would not serialize against +/// [`save_all_tabs_cas`]. +pub(crate) async fn lock_version_mutations() -> tokio::sync::MutexGuard<'static, ()> { + version_lock().lock().await +} + +/// Advance the tab-set clock inside a caller-owned transaction while +/// [`lock_version_mutations`] is held. This is split from the public mutation +/// helpers for compound operations that must commit another table change and +/// the corresponding tab rewrite atomically. +pub(crate) async fn bump_version_locked(conn: &C) -> Result { + let next = get_tabs_version(conn).await? + 1; + app_metadata_service::upsert_value(conn, OPENED_TABS_VERSION_KEY, &next.to_string()).await?; + Ok(next) +} + /// Workspace-global logical clock for the open-tab set, stored in the /// `app_metadata` KV table (survives restart, stays monotonic). Bumped on every /// accepted mutation; used for compare-and-set (lost-update prevention) and for diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 039a5cf7aa..da73f0224a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1035,6 +1035,7 @@ mod tauri_app { conversations::create_chat_dir, conversations::update_conversation_status, conversations::update_conversation_title, + conversations::move_conversation, conversations::update_conversation_pinned, conversations::delete_conversation, folders::load_folder_history, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index ac1201496f..0e53bf2347 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -63,9 +63,9 @@ pub struct DbConversationSummary { pub parent_tool_use_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub delegation_call_id: Option, - /// Mirror of `conversation.origin_cwd`: the working directory this - /// conversation actually ran in when it differs from its current folder's - /// path (set when a removed task worktree's conversations were re-parented). + /// Mirror of `conversation.origin_cwd`: the native transcript's first + /// working directory when it differs from the conversation's current + /// folder (explicit move or removed-worktree re-parent). #[serde(skip_serializing_if = "Option::is_none")] pub origin_cwd: Option, } diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index a46a20fc1e..0b5b3dc2c0 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -357,6 +357,28 @@ pub async fn update_conversation_title( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveConversationParams { + pub conversation_id: i32, + pub target_folder_id: i32, +} + +pub async fn move_conversation( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let summary = conv_commands::move_conversation_with_runtime_core( + &state.emitter, + &state.db.conn, + &state.connection_manager, + params.conversation_id, + params.target_folder_id, + ) + .await?; + Ok(Json(summary)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateConversationPinnedParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index ab618baef5..59b789ad81 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -155,6 +155,10 @@ pub fn build_router( "/update_conversation_title", post(handlers::conversations::update_conversation_title), ) + .route( + "/move_conversation", + post(handlers::conversations::move_conversation), + ) .route( "/update_conversation_pinned", post(handlers::conversations::update_conversation_pinned), diff --git a/src-tauri/tests/api_integration.rs b/src-tauri/tests/api_integration.rs index 1956ce7b1a..e7eeccec31 100644 --- a/src-tauri/tests/api_integration.rs +++ b/src-tauri/tests/api_integration.rs @@ -152,6 +152,48 @@ async fn open_folder_then_list_open_folders_shows_it() { ); } +#[tokio::test] +async fn move_conversation_route_accepts_camel_case_and_returns_the_new_folder() { + let (server, state, _data, _static) = build_test_server_with_state().await; + let source = tempfile::tempdir().expect("source tempdir"); + let target = tempfile::tempdir().expect("target tempdir"); + let source_path = source.path().to_string_lossy().to_string(); + let source_folder = + codeg_lib::db::service::folder_service::add_folder(&state.db.conn, &source_path) + .await + .expect("source folder"); + let target_folder = codeg_lib::db::service::folder_service::add_folder( + &state.db.conn, + &target.path().to_string_lossy(), + ) + .await + .expect("target folder"); + let conversation = codeg_lib::db::service::conversation_service::create( + &state.db.conn, + source_folder.id, + codeg_lib::models::AgentType::Codex, + Some("move over HTTP".into()), + None, + ) + .await + .expect("conversation"); + + let response = server + .post("/api/move_conversation") + .add_header("authorization", format!("Bearer {TEST_TOKEN}")) + .json(&json!({ + "conversationId": conversation.id, + "targetFolderId": target_folder.id, + })) + .await; + + assert_eq!(response.status_code(), 200, "body: {}", response.text()); + let body: Value = response.json(); + assert_eq!(body["id"], conversation.id); + assert_eq!(body["folder_id"], target_folder.id); + assert_eq!(body["origin_cwd"], source_path); +} + #[tokio::test] async fn acp_find_connection_for_conversation_returns_null_when_none_live() { // No live ACP connection is bound to any conversation on a fresh server, so diff --git a/src/components/conversations/conversation-detail-header.test.tsx b/src/components/conversations/conversation-detail-header.test.tsx index 64a66bebf1..329d1410c3 100644 --- a/src/components/conversations/conversation-detail-header.test.tsx +++ b/src/components/conversations/conversation-detail-header.test.tsx @@ -39,7 +39,10 @@ vi.mock("@/stores/app-workspace-store", () => { const state = { updateConversationLocal: h.updateConversationLocal, refreshConversations: h.refreshConversations, - conversations: [] as unknown[], + conversations: [ + { id: 1, kind: "regular", parent_id: null, pinned_at: null }, + { id: 2, kind: "regular", parent_id: null, pinned_at: null }, + ], } const useStore = (selector: (s: typeof state) => unknown) => selector(state) useStore.getState = () => state @@ -51,6 +54,17 @@ vi.mock("@/stores/conversation-runtime-store", () => ({ vi.mock("./session-details-dialog", () => ({ SessionDetailsDialog: () => null, })) +vi.mock("./conversation-move-dialog", () => ({ + ConversationMoveDialog: ({ + target, + }: { + target: { conversationId: number; folderPath?: string } + }) => ( + + {target.conversationId}:{target.folderPath} + + ), +})) // The header now embeds the folder picker (self-contained, store-driven); stub // it so these tests exercise only the header's own menu/dialog logic. vi.mock("@/components/chat/conversation-context-bar", () => ({ @@ -135,4 +149,17 @@ describe("ConversationDetailHeader dialog target snapshot", () => { }) expect(h.updateConversationTitle).not.toHaveBeenCalledWith(2, "renamed") }) + + it("keeps the move target snapshotted when the active tab switches", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + const { rerender, getByLabelText, getByRole, getByTestId } = render( + withIntl() + ) + + await user.click(getByLabelText("More actions")) + await user.click(getByRole("menuitem", { name: "Move to folder" })) + rerender(withIntl()) + + expect(getByTestId("move-target")).toHaveTextContent("1:/a") + }) }) diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index 10c8f5182a..b869d32ae5 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -5,6 +5,7 @@ import { ChevronRight, Circle, EllipsisVertical, + FolderInput, Info, Pencil, Pin, @@ -62,6 +63,10 @@ import { type ActiveSessionDetails, } from "./active-session-details" import { SessionDetailsDialog } from "./session-details-dialog" +import { + ConversationMoveDialog, + type ConversationMoveTarget, +} from "./conversation-move-dialog" interface ConversationDetailHeaderProps { tabId: string @@ -126,6 +131,11 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ (s.conversations.find((c) => c.id === conversationId)?.pinned_at ?? null) != null ) + const moveEligible = useAppWorkspaceStore((s) => { + if (conversationId == null) return false + const summary = s.conversations.find((c) => c.id === conversationId) + return summary?.kind === "regular" && summary.parent_id == null + }) const [details, setDetails] = useState(null) // Snapshot the action target when a dialog OPENS. The header is a SINGLE @@ -143,6 +153,9 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ tabId: string title: string } | null>(null) + const [moveTarget, setMoveTarget] = useState( + null + ) const persisted = conversationId != null const displayTitle = @@ -205,6 +218,16 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ setDeleteTarget({ id: conversationId, tabId, title: displayTitle }) }, [conversationId, tabId, displayTitle]) + const handleMoveOpen = useCallback(() => { + if (conversationId == null || !moveEligible) return + setMoveTarget({ + conversationId, + folderId, + folderPath, + title: displayTitle, + }) + }, [conversationId, displayTitle, folderId, folderPath, moveEligible]) + const handleDeleteConfirm = useCallback(async () => { if (deleteTarget == null) return try { @@ -299,6 +322,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ {tDetails("menuLabel")} + + + {t("moveConversation")} + @@ -394,6 +424,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ model={details.model} /> )} + + {moveTarget ? ( + setMoveTarget(null)} + /> + ) : null} ) }) diff --git a/src/components/conversations/conversation-move-dialog.test.tsx b/src/components/conversations/conversation-move-dialog.test.tsx new file mode 100644 index 0000000000..cfa966c492 --- /dev/null +++ b/src/components/conversations/conversation-move-dialog.test.tsx @@ -0,0 +1,294 @@ +import { type ReactElement } from "react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { + resetAppWorkspaceStore, + useAppWorkspaceStore, +} from "@/stores/app-workspace-store" +import type { DbConversationSummary, FolderDetail } from "@/lib/types" + +const h = vi.hoisted(() => ({ + moveConversation: vi.fn(), + openFolder: vi.fn(), + moveConversationTab: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock("@/lib/api", () => ({ + moveConversation: h.moveConversation, + openFolder: h.openFolder, + listAllConversations: vi.fn(async () => []), + listAllFolderDetails: vi.fn(async () => []), + listOpenFolderDetails: vi.fn(async () => []), + openFolderById: vi.fn(), + openWorktreeFolder: vi.fn(), + removeFolderFromWorkspace: vi.fn(), + reorderFolders: vi.fn(), + getFolder: vi.fn(), +})) + +vi.mock("@/stores/tab-store", () => { + const state = { moveConversationTab: h.moveConversationTab } + const useStore = (selector: (value: typeof state) => unknown) => + selector(state) + useStore.getState = () => state + return { useTabStore: useStore } +}) + +vi.mock("sonner", () => ({ + toast: { + success: h.toastSuccess, + error: h.toastError, + }, +})) + +// Keep this suite on the move workflow. The shared controls have their own +// interaction tests; these light adapters expose their inputs deterministically. +vi.mock("@/components/shared/folder-select", () => ({ + FolderSelect: ({ + folders, + value, + onChange, + }: { + folders: FolderDetail[] + value: number | null + onChange: (id: number) => void + }) => ( +
+ {value ?? "none"} + {folders.map((folder) => ( + + ))} +
+ ), +})) + +vi.mock("@/components/shared/directory-path-input", () => ({ + DirectoryPathInput: ({ + value, + onValueChange, + disabled, + }: { + value: string + onValueChange: (value: string) => void + disabled?: boolean + }) => ( + onValueChange(event.target.value)} + /> + ), +})) + +import { + ConversationMoveDialog, + type ConversationMoveTarget, +} from "./conversation-move-dialog" + +const folder = ( + id: number, + name: string, + path: string, + kind: FolderDetail["kind"] = "regular" +): FolderDetail => ({ + id, + name, + path, + git_branch: null, + default_agent_type: null, + last_opened_at: "2026-08-28T00:00:00Z", + sort_order: id, + color: "inherit", + parent_id: null, + kind, + alias: null, +}) + +const SOURCE = folder(1, "source", "/repo/source") +const TARGET = folder(2, "target", "/repo/target") +const CHAT = folder(3, "chat", "/data/chat/3", "chat") + +const moveTarget: ConversationMoveTarget = { + conversationId: 7, + folderId: SOURCE.id, + folderPath: SOURCE.path, + title: "Migration test", +} + +function summary(folderId: number): DbConversationSummary { + return { + id: 7, + folder_id: folderId, + title: "Migration test", + title_locked: false, + agent_type: "codex", + status: "in_progress", + kind: "regular", + model: null, + git_branch: null, + external_id: "session-7", + message_count: 3, + child_count: 0, + created_at: "2026-08-28T00:00:00Z", + updated_at: "2026-08-28T00:00:00Z", + pinned_at: null, + parent_id: null, + parent_tool_use_id: null, + delegation_call_id: null, + origin_cwd: SOURCE.path, + } +} + +function withIntl(ui: ReactElement) { + return ( + + {ui} + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + resetAppWorkspaceStore() + useAppWorkspaceStore.setState({ + folders: [SOURCE, TARGET], + allFolders: [SOURCE, TARGET, CHAT], + foldersHydrated: true, + }) + h.moveConversation.mockResolvedValue(summary(TARGET.id)) + h.openFolder.mockResolvedValue(TARGET) +}) + +describe("ConversationMoveDialog", () => { + it("offers only eligible destinations and applies the authoritative move", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + const onClose = vi.fn() + render( + withIntl() + ) + + expect(screen.getByRole("button", { name: "target" })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "source" })).toBeNull() + expect(screen.queryByRole("button", { name: "chat" })).toBeNull() + + await user.click(screen.getByRole("button", { name: "target" })) + await user.click(screen.getByRole("button", { name: "Move to folder" })) + + await waitFor(() => { + expect(h.moveConversation).toHaveBeenCalledWith(7, 2) + }) + expect(h.moveConversationTab).toHaveBeenCalledWith(7, 2, "/repo/target") + expect( + useAppWorkspaceStore + .getState() + .conversations.find((item) => item.id === 7)?.folder_id + ).toBe(2) + expect(h.toastSuccess).toHaveBeenCalledWith("Conversation moved") + expect(onClose).toHaveBeenCalledOnce() + }) + + it("registers an arbitrary directory and selects it as the destination", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + const opened = folder(4, "new-target", "/srv/new-target") + h.openFolder.mockResolvedValue(opened) + h.moveConversation.mockResolvedValue(summary(opened.id)) + render( + withIntl() + ) + + fireEvent.change(screen.getByLabelText("Directory path"), { + target: { value: opened.path }, + }) + await user.click(screen.getByRole("button", { name: "Use folder" })) + + await waitFor(() => { + expect(h.openFolder).toHaveBeenCalledWith(opened.path) + expect(screen.getByTestId("selected-folder")).toHaveTextContent("4") + }) + await user.click(screen.getByRole("button", { name: "Move to folder" })) + await waitFor(() => { + expect(h.moveConversation).toHaveBeenCalledWith(7, 4) + }) + expect(h.moveConversationTab).toHaveBeenCalledWith(7, 4, opened.path) + }) + + it("refuses an arbitrary path that resolves to the source folder", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + h.openFolder.mockResolvedValue(SOURCE) + render( + withIntl() + ) + + fireEvent.change(screen.getByLabelText("Directory path"), { + target: { value: SOURCE.path }, + }) + await user.click(screen.getByRole("button", { name: "Use folder" })) + + await waitFor(() => { + expect(h.toastError).toHaveBeenCalledWith( + "Choose a different folder from the current one" + ) + }) + expect(screen.getByTestId("selected-folder")).toHaveTextContent("none") + expect(h.moveConversation).not.toHaveBeenCalled() + }) + + it("keeps the dialog open and explains an in-flight-turn rejection", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + const onClose = vi.fn() + h.moveConversation.mockRejectedValue({ + code: "turn_in_progress", + message: "busy", + }) + render( + withIntl() + ) + + await user.click(screen.getByRole("button", { name: "target" })) + await user.click(screen.getByRole("button", { name: "Move to folder" })) + + await waitFor(() => { + expect(h.toastError).toHaveBeenCalledWith("Could not move conversation", { + description: + "Wait for the current response to finish, then try moving again.", + }) + }) + expect(onClose).not.toHaveBeenCalled() + expect( + screen.getByRole("heading", { name: "Move conversation" }) + ).toBeInTheDocument() + }) + + it("surfaces an arbitrary-directory registration failure without selecting it", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + h.openFolder.mockRejectedValue(new Error("permission denied")) + render( + withIntl() + ) + + fireEvent.change(screen.getByLabelText("Directory path"), { + target: { value: "/forbidden" }, + }) + await user.click(screen.getByRole("button", { name: "Use folder" })) + + await waitFor(() => { + expect(h.toastError).toHaveBeenCalledWith("Could not open folder", { + description: "permission denied", + }) + }) + expect(screen.getByTestId("selected-folder")).toHaveTextContent("none") + }) +}) diff --git a/src/components/conversations/conversation-move-dialog.tsx b/src/components/conversations/conversation-move-dialog.tsx new file mode 100644 index 0000000000..67ca34d50b --- /dev/null +++ b/src/components/conversations/conversation-move-dialog.tsx @@ -0,0 +1,228 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { ArrowRightLeft, Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { DirectoryPathInput } from "@/components/shared/directory-path-input" +import { FolderSelect } from "@/components/shared/folder-select" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" +import { moveConversation } from "@/lib/api" +import { useAppWorkspaceStore } from "@/stores/app-workspace-store" +import { useTabStore } from "@/stores/tab-store" + +export interface ConversationMoveTarget { + conversationId: number + folderId: number + folderPath?: string + title: string +} + +interface ConversationMoveDialogProps { + target: ConversationMoveTarget + onClose: () => void +} + +/** + * Rebind a persisted top-level conversation to another working folder. + * + * The target is a snapshot captured by the menu that opened the dialog. This + * matters in the detail header, whose single instance can switch from tab A to + * tab B while the modal remains open. The backend owns the authoritative + * transaction and idle-connection detach; this surface only registers an + * arbitrary directory when needed and immediately converges local stores from + * the returned summary. + */ +export function ConversationMoveDialog({ + target, + onClose, +}: ConversationMoveDialogProps) { + const t = useTranslations("Folder.conversationCard") + const allFolders = useAppWorkspaceStore((state) => state.allFolders) + const openFolder = useAppWorkspaceStore((state) => state.openFolder) + const [targetFolderId, setTargetFolderId] = useState(null) + const [directoryPath, setDirectoryPath] = useState("") + const [openingDirectory, setOpeningDirectory] = useState(false) + const [moving, setMoving] = useState(false) + const busy = openingDirectory || moving + + const sourceFolder = allFolders.find( + (folder) => folder.id === target.folderId + ) + const sourcePath = + target.folderPath ?? sourceFolder?.path ?? `#${target.folderId}` + const targetFolders = useMemo( + () => + allFolders.filter( + (folder) => folder.kind !== "chat" && folder.id !== target.folderId + ), + [allFolders, target.folderId] + ) + + const handleOpenDirectory = useCallback(async () => { + const path = directoryPath.trim() + if (!path || busy) return + setOpeningDirectory(true) + try { + const folder = await openFolder(path) + if (folder.id === target.folderId) { + toast.error(t("moveSameFolder")) + return + } + setTargetFolderId(folder.id) + } catch (error) { + toast.error(t("moveOpenFolderFailed"), { + description: toErrorMessage(error), + }) + } finally { + setOpeningDirectory(false) + } + }, [busy, directoryPath, openFolder, t, target.folderId]) + + const handleMove = useCallback(async () => { + if (targetFolderId == null || busy) return + const destination = useAppWorkspaceStore + .getState() + .allFolders.find((folder) => folder.id === targetFolderId) + if (!destination || destination.kind === "chat") { + toast.error(t("moveDestinationUnavailable")) + return + } + + setMoving(true) + try { + const summary = await moveConversation( + target.conversationId, + destination.id + ) + useAppWorkspaceStore.getState().applyConversationUpsert(summary) + useTabStore + .getState() + .moveConversationTab(summary.id, destination.id, destination.path) + toast.success(t("moveSuccess")) + onClose() + } catch (error) { + const commandError = extractAppCommandError(error) + toast.error(t("moveFailed"), { + description: + commandError?.code === "turn_in_progress" + ? t("moveTurnInProgress") + : toErrorMessage(error), + }) + } finally { + setMoving(false) + } + }, [busy, onClose, t, target.conversationId, targetFolderId]) + + return ( + { + if (!open && !busy) onClose() + }} + > + + + {t("moveConversationTitle")} + + {t("moveConversationDescription", { title: target.title })} + + + +
+
+ + {t("moveCurrentFolder")} + +
+ {sourcePath} +
+
+ +
+ + {t("moveDestinationFolder")} + + +
+ +
+ + {t("moveOtherDirectory")} + +
+ + +
+
+
+ + + + + +
+
+ ) +} diff --git a/src/components/conversations/sidebar-conversation-card.test.tsx b/src/components/conversations/sidebar-conversation-card.test.tsx index 9408decaf5..9e3214ea62 100644 --- a/src/components/conversations/sidebar-conversation-card.test.tsx +++ b/src/components/conversations/sidebar-conversation-card.test.tsx @@ -29,6 +29,14 @@ vi.mock("@/components/agent-icon", () => ({ }, })) +vi.mock("./conversation-move-dialog", () => ({ + ConversationMoveDialog: ({ + target, + }: { + target: { conversationId: number; folderId: number; title: string } + }) => {JSON.stringify(target)}, +})) + const MINUTE = 60_000 const NOW = 1_700_000_000_000 @@ -210,6 +218,45 @@ describe("SidebarConversationCard pin action", () => { }) }) +describe("SidebarConversationCard move action", () => { + function renderCard(c: DbConversationSummary) { + return renderWithIntl( + + ) + } + + it("snapshots a top-level workspace conversation for the move dialog", () => { + const { getByText, getByTestId } = renderCard(conv(17)) + fireEvent.contextMenu(getByText("conv-17")) + fireEvent.click(getByText("Move to folder")) + + expect(JSON.parse(getByTestId("move-target").textContent ?? "{}")).toEqual({ + conversationId: 17, + folderId: 1, + title: "conv-17", + }) + }) + + it.each([ + { label: "delegation child", summary: { ...conv(18), parent_id: 1 } }, + { label: "chat", summary: { ...conv(19), kind: "chat" as const } }, + { label: "loop", summary: { ...conv(20), kind: "loop" as const } }, + ])("does not offer moving a $label", ({ summary }) => { + const { getByText, queryByText } = renderCard(summary) + fireEvent.contextMenu(getByText(summary.title ?? "")) + expect(queryByText("Move to folder")).toBeNull() + }) +}) + // The hover-reveal icon buttons live in the row's right slot as siblings of the // clickable row button (never nested). They carry only an aria-label (icon, no // text), so getByLabelText addresses them unambiguously — distinct from the diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 3dc7fc8332..faf3420527 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -18,7 +18,8 @@ import { Pin, PinOff, CheckCircle2, - FolderX, + FolderInput, + History, Info, ChevronRight, } from "lucide-react" @@ -68,6 +69,10 @@ import { ConversationStatusDot } from "./conversation-status-dot" import { SessionDetailsDialog } from "./session-details-dialog" import { SidebarConversationHoverDetails } from "./sidebar-conversation-hover-details" import { AgentIcon } from "@/components/agent-icon" +import { + ConversationMoveDialog, + type ConversationMoveTarget, +} from "./conversation-move-dialog" /** * Horizontal indent added per delegation-nesting level. Chosen so a child's @@ -220,6 +225,9 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ const [renameValue, setRenameValue] = useState("") const [attachTabId, setAttachTabId] = useState(null) const [hoverOpen, setHoverOpen] = useState(false) + const [moveTarget, setMoveTarget] = useState( + null + ) const handleClick = useCallback(() => { onSelect(conversation.id, conversation.agent_type, conversation.folder_id) @@ -430,20 +438,24 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ {formatConversationTitle(conversation.title) || t("untitledConversation")} - {/* Re-parented out of a removed worktree: history loads fine, - but "continue" may need a fresh session (the agent's files - were keyed to the old path). */} + {/* The transcript originated in another working directory. + History still loads from there while the next resume uses + the conversation's current folder. */} {conversation.origin_cwd ? ( - - {tSidebar("worktreeRemovedBadge")} + {tSidebar("originalWorkingDirectoryBadge", { + path: conversation.origin_cwd, + })} ) : null} @@ -659,6 +671,23 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ {tDetails("menuLabel")} + {conversation.kind === "regular" && + conversation.parent_id == null ? ( + + setMoveTarget({ + conversationId: conversation.id, + folderId: conversation.folder_id, + title: + formatConversationTitle(conversation.title) || + t("untitledConversation"), + }) + } + > + + {t("moveConversation")} + + ) : null} {/* Mirrors the file tree's "add to session": inserts an `@`-style mention of THIS conversation into the active session's composer. Disabled only when no conversation tab is open — there is no @@ -771,6 +800,13 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ summary={conversation} /> )} + + {moveTarget ? ( + setMoveTarget(null)} + /> + ) : null} ) }) diff --git a/src/components/conversations/sidebar-conversation-hover-details.test.tsx b/src/components/conversations/sidebar-conversation-hover-details.test.tsx index 58082f8c68..44be1b7f49 100644 --- a/src/components/conversations/sidebar-conversation-hover-details.test.tsx +++ b/src/components/conversations/sidebar-conversation-hover-details.test.tsx @@ -225,7 +225,7 @@ describe("SidebarConversationHoverDetails", () => { seed() renderBubble(conv({ origin_cwd: "/Users/dev/projects/codeg-feature-x" })) - expect(fieldValue("Source worktree removed")).toBe( + expect(fieldValue("Original working directory")).toBe( "/Users/dev/projects/codeg-feature-x" ) }) @@ -234,6 +234,6 @@ describe("SidebarConversationHoverDetails", () => { seed() renderBubble(conv()) - expect(screen.queryByText("Source worktree removed")).toBeNull() + expect(screen.queryByText("Original working directory")).toBeNull() }) }) diff --git a/src/components/conversations/sidebar-conversation-hover-details.tsx b/src/components/conversations/sidebar-conversation-hover-details.tsx index 242893bcd8..5d4461e689 100644 --- a/src/components/conversations/sidebar-conversation-hover-details.tsx +++ b/src/components/conversations/sidebar-conversation-hover-details.tsx @@ -185,7 +185,7 @@ export function SidebarConversationHoverDetails({ conversation lives NOW, so surface where it originally ran. */} {conversation.origin_cwd && ( {conversation.origin_cwd} diff --git a/src/contexts/app-workspace-context.test.tsx b/src/contexts/app-workspace-context.test.tsx index a0c76d4c29..dad7c55573 100644 --- a/src/contexts/app-workspace-context.test.tsx +++ b/src/contexts/app-workspace-context.test.tsx @@ -31,6 +31,7 @@ const h = vi.hoisted(() => ({ listOpenFolders: vi.fn(async () => [] as unknown[]), listAllFolders: vi.fn(async () => [] as unknown[]), closeTabsByFolder: vi.fn(), + moveConversationTab: vi.fn(), })) // The folder-delete branch reaches into the tab store imperatively; stub it so @@ -38,7 +39,10 @@ const h = vi.hoisted(() => ({ // sidebar's own remove-folder path). vi.mock("@/stores/tab-store", () => ({ useTabStore: { - getState: () => ({ closeTabsByFolder: h.closeTabsByFolder }), + getState: () => ({ + closeTabsByFolder: h.closeTabsByFolder, + moveConversationTab: h.moveConversationTab, + }), }, })) @@ -214,6 +218,7 @@ beforeEach(() => { h.listAllFolders.mockClear() h.listAllFolders.mockResolvedValue([]) h.closeTabsByFolder.mockClear() + h.moveConversationTab.mockClear() // The store is a module-level singleton: restore pristine state (including // the delete tombstones) so state can't leak between tests. resetAppWorkspaceStore() @@ -250,6 +255,25 @@ describe("AppWorkspaceProvider conversation://changed sync", () => { ) }) + it("retargets an open tab when an upsert moves its conversation to another folder", async () => { + await mountProvider() + const source = makeFolder({ id: 1, path: "/repo/source" }) + const target = makeFolder({ id: 2, path: "/repo/target" }) + act(() => { + useAppWorkspaceStore.setState({ + folders: [source, target], + allFolders: [source, target], + }) + }) + emit({ kind: "upsert", summary: makeSummary({ id: 7, folder_id: 1 }) }) + h.moveConversationTab.mockClear() + + emit({ kind: "upsert", summary: makeSummary({ id: 7, folder_id: 2 }) }) + + expect(h.moveConversationTab).toHaveBeenCalledOnce() + expect(h.moveConversationTab).toHaveBeenCalledWith(7, 2, "/repo/target") + }) + it("ignores delegation children (parent_id set) — not sidebar rows", async () => { await mountProvider() emit({ kind: "upsert", summary: makeSummary({ id: 1 }) }) diff --git a/src/contexts/app-workspace-context.tsx b/src/contexts/app-workspace-context.tsx index ca3beb9453..70105aa22a 100644 --- a/src/contexts/app-workspace-context.tsx +++ b/src/contexts/app-workspace-context.tsx @@ -63,6 +63,24 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) { const store = useAppWorkspaceStore.getState() if (change.kind === "upsert") { store.applyConversationUpsert(change.summary) + // A workspace migration changes the persisted conversation and its + // open-tab row in one backend transaction. Retarget this window's + // device-local tab too (notably its workingDir, which is absent from + // `tabs://changed`) while keeping the stable tab/runtime id. The + // action is equality-guarded, so ordinary title/status upserts are + // allocation-free no-ops. + const targetFolder = store.allFolders.find( + (folder) => folder.id === change.summary.folder_id + ) + if (targetFolder) { + useTabStore + .getState() + .moveConversationTab( + change.summary.id, + targetFolder.id, + targetFolder.path + ) + } // This side-channel keeps the sidebar in sync but does NOT touch an // open conversation's detail. If THIS client is only viewing that // conversation (another client owns the live agent), a turn that diff --git a/src/contexts/tab-context.test.tsx b/src/contexts/tab-context.test.tsx index 6bab472c26..c7c5044681 100644 --- a/src/contexts/tab-context.test.tsx +++ b/src/contexts/tab-context.test.tsx @@ -703,6 +703,44 @@ describe("TabProvider tab state transitions", () => { expect(screen.getByTestId("active")).toHaveTextContent("conv-1-codex-2") expect(replacedTabIds).toEqual([]) }) + + it("retargets a moved conversation while preserving its stable tab and runtime ids", () => { + renderTabs() + openConversationTab(1, 1, "First") + act(() => { + latestContext?.setTabRuntimeConversationId("conv-1-codex-1", -77) + latestContext?.moveConversationTab(1, 2, "/other") + }) + + const moved = useTabStore + .getState() + .rawTabs.find((tab) => tab.conversationId === 1) + expect(moved).toMatchObject({ + id: "conv-1-codex-1", + conversationId: 1, + runtimeConversationId: -77, + folderId: 2, + workingDir: "/other", + }) + expect(screen.getByTestId("active")).toHaveTextContent("conv-1-codex-1") + expect(screen.getByTestId("active-folder")).toHaveTextContent("2") + }) + + it("does not allocate tab state for an idempotent move or an unopened conversation", () => { + renderTabs() + openConversationTab(1, 1, "First") + act(() => { + latestContext?.moveConversationTab(1, 2, "/other") + }) + const afterMove = useTabStore.getState().rawTabs + + act(() => { + latestContext?.moveConversationTab(1, 2, "/other") + latestContext?.moveConversationTab(999, 2, "/other") + }) + + expect(useTabStore.getState().rawTabs).toBe(afterMove) + }) }) function tabItem( @@ -773,6 +811,35 @@ describe("TabProvider cross-client sync", () => { expect(screen.getByTestId("tabs")).toHaveTextContent("conv-1-codex-1") }) + it("retargets a migrated remote tab without remounting its local runtime", async () => { + listOpenedTabsMock.mockResolvedValue({ + items: [tabItem(1, 1, true)], + version: 1, + }) + await renderHydrated() + act(() => { + latestContext?.setTabRuntimeConversationId("conv-1-codex-1", -19) + }) + + act(() => { + tabsChangedHandler?.({ + version: 2, + origin: "server", + tabs: [tabItem(2, 1, true)], + }) + }) + + const moved = useTabStore.getState().rawTabs[0] + expect(moved).toMatchObject({ + id: "conv-1-codex-1", + conversationId: 1, + runtimeConversationId: -19, + folderId: 2, + workingDir: "/other", + }) + expect(screen.getByTestId("active")).toHaveTextContent("conv-1-codex-1") + }) + it("preserves an active chat-mode draft across an inbound remote snapshot", async () => { await renderHydrated() expect(tabsChangedHandler).not.toBeNull() diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index b66f2af15f..db50d5e648 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -228,6 +228,11 @@ export interface TabContextValue { closeOtherTabs: (tabId: string) => void closeAllTabs: () => void closeTabsByFolder: (folderId: number) => void + moveConversationTab: ( + conversationId: number, + targetFolderId: number, + workingDir: string + ) => void switchTab: (tabId: string) => void pinTab: (tabId: string) => void toggleGroupTile: (groupId: string) => void @@ -290,6 +295,7 @@ export function useTabContext(): TabContextValue { closeOtherTabs: s.closeOtherTabs, closeAllTabs: s.closeAllTabs, closeTabsByFolder: s.closeTabsByFolder, + moveConversationTab: s.moveConversationTab, switchTab: s.switchTab, pinTab: s.pinTab, toggleGroupTile: s.toggleGroupTile, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index f4d082c807..eb466230ba 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "قيد التشغيل", "runningCountBadge": "{count} جلسة قيد التشغيل", "statusCancelledBadge": "ملغى", - "worktreeRemovedBadge": "تمت إزالة شجرة العمل الأصلية", + "originalWorkingDirectoryLabel": "دليل العمل الأصلي", + "originalWorkingDirectoryBadge": "دليل العمل الأصلي: {path}", "conversationCountUnit": "{count} محادثة", "emptyFolderHint": "لا توجد محادثات", "noMatchingConversations": "لا توجد محادثات مطابقة", @@ -1844,6 +1845,24 @@ "newConversation": "محادثة جديدة", "attachToCurrentSession": "إضافة إلى الجلسة", "rename": "إعادة تسمية", + "moveConversation": "نقل إلى مجلد", + "moveConversationTitle": "نقل المحادثة", + "moveConversationDescription": "انقل «{title}» إلى مجلد عمل آخر. يبقى السجل المحفوظ كما هو، وستُنفّذ الرسالة التالية في المجلد الجديد.", + "moveCurrentFolder": "المجلد الحالي", + "moveDestinationFolder": "المجلد الوجهة", + "moveSelectFolder": "اختر مجلدًا", + "moveOtherDirectory": "أو افتح دليلاً آخر", + "moveDirectoryPlaceholder": "أدخل مسار الدليل", + "moveBrowseDirectory": "استعراض الأدلة", + "moveUseFolder": "استخدام المجلد", + "moveOpeningFolder": "جارٍ الفتح...", + "movingConversation": "جارٍ النقل...", + "moveSuccess": "تم نقل المحادثة", + "moveFailed": "تعذر نقل المحادثة", + "moveTurnInProgress": "انتظر حتى يكتمل الرد الحالي، ثم حاول النقل مرة أخرى.", + "moveSameFolder": "اختر مجلدًا مختلفًا عن المجلد الحالي", + "moveOpenFolderFailed": "تعذر فتح المجلد", + "moveDestinationUnavailable": "لم يعد المجلد المحدد متاحًا", "status": "الحالة", "delete": "حذف", "importLocalSessions": "استيراد الجلسات المحلية", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9501547529..24689885b8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "Läuft", "runningCountBadge": "{count, plural, one {# laufende Sitzung} other {# laufende Sitzungen}}", "statusCancelledBadge": "Abgebrochen", - "worktreeRemovedBadge": "Quell-Worktree entfernt", + "originalWorkingDirectoryLabel": "Ursprüngliches Arbeitsverzeichnis", + "originalWorkingDirectoryBadge": "Ursprüngliches Arbeitsverzeichnis: {path}", "conversationCountUnit": "{count, plural, one {# Konversation} other {# Konversationen}}", "emptyFolderHint": "Keine Konversationen", "noMatchingConversations": "Keine passenden Konversationen", @@ -1844,6 +1845,24 @@ "newConversation": "Neue Konversation", "attachToCurrentSession": "Zur Sitzung hinzufügen", "rename": "Umbenennen", + "moveConversation": "In Ordner verschieben", + "moveConversationTitle": "Unterhaltung verschieben", + "moveConversationDescription": "„{title}“ in einen anderen Arbeitsordner verschieben. Der gespeicherte Verlauf bleibt erhalten; die nächste Nachricht wird im neuen Ordner ausgeführt.", + "moveCurrentFolder": "Aktueller Ordner", + "moveDestinationFolder": "Zielordner", + "moveSelectFolder": "Ordner auswählen", + "moveOtherDirectory": "Oder ein anderes Verzeichnis öffnen", + "moveDirectoryPlaceholder": "Verzeichnispfad eingeben", + "moveBrowseDirectory": "Verzeichnisse durchsuchen", + "moveUseFolder": "Ordner verwenden", + "moveOpeningFolder": "Wird geöffnet...", + "movingConversation": "Wird verschoben...", + "moveSuccess": "Unterhaltung verschoben", + "moveFailed": "Unterhaltung konnte nicht verschoben werden", + "moveTurnInProgress": "Warten Sie, bis die aktuelle Antwort beendet ist, und versuchen Sie es erneut.", + "moveSameFolder": "Wählen Sie einen anderen als den aktuellen Ordner", + "moveOpenFolderFailed": "Ordner konnte nicht geöffnet werden", + "moveDestinationUnavailable": "Der ausgewählte Ordner ist nicht mehr verfügbar", "status": "Zustand", "delete": "Löschen", "importLocalSessions": "Lokale Sitzungen importieren", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b707982aaf..828c31954a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "Running", "runningCountBadge": "{count, plural, one {# session running} other {# sessions running}}", "statusCancelledBadge": "Cancelled", - "worktreeRemovedBadge": "Source worktree removed", + "originalWorkingDirectoryLabel": "Original working directory", + "originalWorkingDirectoryBadge": "Original working directory: {path}", "conversationCountUnit": "{count, plural, one {# conversation} other {# conversations}}", "emptyFolderHint": "No conversations", "noMatchingConversations": "No matching conversations", @@ -1844,6 +1845,24 @@ "newConversation": "New Conversation", "attachToCurrentSession": "Add to session", "rename": "Rename", + "moveConversation": "Move to folder", + "moveConversationTitle": "Move conversation", + "moveConversationDescription": "Move \"{title}\" to another working folder. Its saved transcript stays intact, and the next message runs in the new folder.", + "moveCurrentFolder": "Current folder", + "moveDestinationFolder": "Destination folder", + "moveSelectFolder": "Select a folder", + "moveOtherDirectory": "Or open another directory", + "moveDirectoryPlaceholder": "Enter a directory path", + "moveBrowseDirectory": "Browse directories", + "moveUseFolder": "Use folder", + "moveOpeningFolder": "Opening...", + "movingConversation": "Moving...", + "moveSuccess": "Conversation moved", + "moveFailed": "Could not move conversation", + "moveTurnInProgress": "Wait for the current response to finish, then try moving again.", + "moveSameFolder": "Choose a different folder from the current one", + "moveOpenFolderFailed": "Could not open folder", + "moveDestinationUnavailable": "The selected folder is no longer available", "status": "Status", "delete": "Delete", "importLocalSessions": "Import local sessions", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5ec92aedbd..1a8c2cad72 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "Ejecutando", "runningCountBadge": "{count, plural, one {# sesión en ejecución} other {# sesiones en ejecución}}", "statusCancelledBadge": "Cancelado", - "worktreeRemovedBadge": "Worktree de origen eliminado", + "originalWorkingDirectoryLabel": "Directorio de trabajo original", + "originalWorkingDirectoryBadge": "Directorio de trabajo original: {path}", "conversationCountUnit": "{count, plural, one {# conversación} other {# conversaciones}}", "emptyFolderHint": "Sin conversaciones", "noMatchingConversations": "No hay conversaciones coincidentes", @@ -1844,6 +1845,24 @@ "newConversation": "Nueva conversación", "attachToCurrentSession": "Agregar a la sesión", "rename": "Renombrar", + "moveConversation": "Mover a una carpeta", + "moveConversationTitle": "Mover conversación", + "moveConversationDescription": "Mueve «{title}» a otra carpeta de trabajo. El historial guardado se conserva y el siguiente mensaje se ejecutará en la nueva carpeta.", + "moveCurrentFolder": "Carpeta actual", + "moveDestinationFolder": "Carpeta de destino", + "moveSelectFolder": "Seleccionar una carpeta", + "moveOtherDirectory": "O abrir otro directorio", + "moveDirectoryPlaceholder": "Introduce una ruta de directorio", + "moveBrowseDirectory": "Explorar directorios", + "moveUseFolder": "Usar carpeta", + "moveOpeningFolder": "Abriendo...", + "movingConversation": "Moviendo...", + "moveSuccess": "Conversación movida", + "moveFailed": "No se pudo mover la conversación", + "moveTurnInProgress": "Espera a que termine la respuesta actual y vuelve a intentarlo.", + "moveSameFolder": "Elige una carpeta diferente de la actual", + "moveOpenFolderFailed": "No se pudo abrir la carpeta", + "moveDestinationUnavailable": "La carpeta seleccionada ya no está disponible", "status": "Estado", "delete": "Eliminar", "importLocalSessions": "Importar sesiones locales", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 85781a8bf0..ff45175b18 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "En cours", "runningCountBadge": "{count, plural, one {# session en cours} other {# sessions en cours}}", "statusCancelledBadge": "Annulé", - "worktreeRemovedBadge": "Worktree d'origine supprimé", + "originalWorkingDirectoryLabel": "Répertoire de travail d’origine", + "originalWorkingDirectoryBadge": "Répertoire de travail d’origine : {path}", "conversationCountUnit": "{count, plural, one {# conversation} other {# conversations}}", "emptyFolderHint": "Aucune conversation", "noMatchingConversations": "Aucune conversation correspondante", @@ -1844,6 +1845,24 @@ "newConversation": "Nouvelle conversation", "attachToCurrentSession": "Ajouter à la session", "rename": "Renommer", + "moveConversation": "Déplacer vers un dossier", + "moveConversationTitle": "Déplacer la conversation", + "moveConversationDescription": "Déplacez « {title} » vers un autre dossier de travail. L’historique enregistré reste intact et le prochain message s’exécutera dans le nouveau dossier.", + "moveCurrentFolder": "Dossier actuel", + "moveDestinationFolder": "Dossier de destination", + "moveSelectFolder": "Sélectionner un dossier", + "moveOtherDirectory": "Ou ouvrir un autre répertoire", + "moveDirectoryPlaceholder": "Saisir un chemin de répertoire", + "moveBrowseDirectory": "Parcourir les répertoires", + "moveUseFolder": "Utiliser ce dossier", + "moveOpeningFolder": "Ouverture...", + "movingConversation": "Déplacement...", + "moveSuccess": "Conversation déplacée", + "moveFailed": "Impossible de déplacer la conversation", + "moveTurnInProgress": "Attendez la fin de la réponse actuelle, puis réessayez.", + "moveSameFolder": "Choisissez un dossier différent du dossier actuel", + "moveOpenFolderFailed": "Impossible d’ouvrir le dossier", + "moveDestinationUnavailable": "Le dossier sélectionné n’est plus disponible", "status": "Statut", "delete": "Supprimer", "importLocalSessions": "Importer les sessions locales", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 55be21f779..e75cd87493 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "実行中", "runningCountBadge": "{count} 件の会話が実行中", "statusCancelledBadge": "キャンセル済み", - "worktreeRemovedBadge": "元の worktree は削除済み", + "originalWorkingDirectoryLabel": "元の作業ディレクトリ", + "originalWorkingDirectoryBadge": "元の作業ディレクトリ: {path}", "conversationCountUnit": "{count} 件", "emptyFolderHint": "会話がありません", "noMatchingConversations": "一致する会話がありません", @@ -1844,6 +1845,24 @@ "newConversation": "新しい会話", "attachToCurrentSession": "セッションに追加", "rename": "名前を変更", + "moveConversation": "フォルダーへ移動", + "moveConversationTitle": "会話を移動", + "moveConversationDescription": "「{title}」を別の作業フォルダーへ移動します。保存済みの履歴は維持され、次のメッセージは新しいフォルダーで実行されます。", + "moveCurrentFolder": "現在のフォルダー", + "moveDestinationFolder": "移動先フォルダー", + "moveSelectFolder": "フォルダーを選択", + "moveOtherDirectory": "または別のディレクトリを開く", + "moveDirectoryPlaceholder": "ディレクトリパスを入力", + "moveBrowseDirectory": "ディレクトリを参照", + "moveUseFolder": "このフォルダーを使用", + "moveOpeningFolder": "開いています...", + "movingConversation": "移動しています...", + "moveSuccess": "会話を移動しました", + "moveFailed": "会話を移動できませんでした", + "moveTurnInProgress": "現在の応答が完了してから、もう一度移動してください。", + "moveSameFolder": "現在とは別のフォルダーを選択してください", + "moveOpenFolderFailed": "フォルダーを開けませんでした", + "moveDestinationUnavailable": "選択したフォルダーは利用できません", "status": "ステータス", "delete": "削除", "importLocalSessions": "ローカルセッションをインポート", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ed59bc9bd9..92b27b565e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "실행 중", "runningCountBadge": "{count}개 세션 실행 중", "statusCancelledBadge": "취소됨", - "worktreeRemovedBadge": "원본 worktree가 삭제됨", + "originalWorkingDirectoryLabel": "원래 작업 디렉터리", + "originalWorkingDirectoryBadge": "원래 작업 디렉터리: {path}", "conversationCountUnit": "{count}개", "emptyFolderHint": "대화 없음", "noMatchingConversations": "일치하는 대화가 없습니다", @@ -1844,6 +1845,24 @@ "newConversation": "새 대화", "attachToCurrentSession": "세션에 추가", "rename": "이름 변경", + "moveConversation": "폴더로 이동", + "moveConversationTitle": "대화 이동", + "moveConversationDescription": "\"{title}\"을(를) 다른 작업 폴더로 이동합니다. 저장된 대화 기록은 유지되며 다음 메시지는 새 폴더에서 실행됩니다.", + "moveCurrentFolder": "현재 폴더", + "moveDestinationFolder": "대상 폴더", + "moveSelectFolder": "폴더 선택", + "moveOtherDirectory": "또는 다른 디렉터리 열기", + "moveDirectoryPlaceholder": "디렉터리 경로 입력", + "moveBrowseDirectory": "디렉터리 찾아보기", + "moveUseFolder": "이 폴더 사용", + "moveOpeningFolder": "여는 중...", + "movingConversation": "이동 중...", + "moveSuccess": "대화를 이동했습니다", + "moveFailed": "대화를 이동할 수 없습니다", + "moveTurnInProgress": "현재 응답이 끝난 후 다시 이동하세요.", + "moveSameFolder": "현재 폴더와 다른 폴더를 선택하세요", + "moveOpenFolderFailed": "폴더를 열 수 없습니다", + "moveDestinationUnavailable": "선택한 폴더를 더 이상 사용할 수 없습니다", "status": "상태", "delete": "삭제", "importLocalSessions": "로컬 세션 가져오기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f957a119c9..887523a9a8 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "Executando", "runningCountBadge": "{count, plural, one {# sessão em execução} other {# sessões em execução}}", "statusCancelledBadge": "Cancelado", - "worktreeRemovedBadge": "Worktree de origem removida", + "originalWorkingDirectoryLabel": "Diretório de trabalho original", + "originalWorkingDirectoryBadge": "Diretório de trabalho original: {path}", "conversationCountUnit": "{count, plural, one {# conversa} other {# conversas}}", "emptyFolderHint": "Sem conversas", "noMatchingConversations": "Nenhuma conversa correspondente", @@ -1844,6 +1845,24 @@ "newConversation": "Nova conversa", "attachToCurrentSession": "Adicionar à sessão", "rename": "Renomear", + "moveConversation": "Mover para pasta", + "moveConversationTitle": "Mover conversa", + "moveConversationDescription": "Mova “{title}” para outra pasta de trabalho. O histórico salvo permanece intacto e a próxima mensagem será executada na nova pasta.", + "moveCurrentFolder": "Pasta atual", + "moveDestinationFolder": "Pasta de destino", + "moveSelectFolder": "Selecionar uma pasta", + "moveOtherDirectory": "Ou abrir outro diretório", + "moveDirectoryPlaceholder": "Digite o caminho do diretório", + "moveBrowseDirectory": "Procurar diretórios", + "moveUseFolder": "Usar pasta", + "moveOpeningFolder": "Abrindo...", + "movingConversation": "Movendo...", + "moveSuccess": "Conversa movida", + "moveFailed": "Não foi possível mover a conversa", + "moveTurnInProgress": "Aguarde a resposta atual terminar e tente novamente.", + "moveSameFolder": "Escolha uma pasta diferente da atual", + "moveOpenFolderFailed": "Não foi possível abrir a pasta", + "moveDestinationUnavailable": "A pasta selecionada não está mais disponível", "status": "Status", "delete": "Excluir", "importLocalSessions": "Importar sessões locais", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 933fbdd0e2..c10f3d9d5a 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "运行中", "runningCountBadge": "{count} 个会话进行中", "statusCancelledBadge": "已取消", - "worktreeRemovedBadge": "源 worktree 已删除", + "originalWorkingDirectoryLabel": "原工作目录", + "originalWorkingDirectoryBadge": "原工作目录:{path}", "conversationCountUnit": "{count} 条", "emptyFolderHint": "暂无会话", "noMatchingConversations": "未找到匹配的会话", @@ -1844,6 +1845,24 @@ "newConversation": "新建会话", "attachToCurrentSession": "添加到会话", "rename": "重命名", + "moveConversation": "迁移到文件夹", + "moveConversationTitle": "迁移会话", + "moveConversationDescription": "将“{title}”迁移到另一个工作文件夹。已保存的会话记录不会移动,下一条消息将在新文件夹中运行。", + "moveCurrentFolder": "当前文件夹", + "moveDestinationFolder": "目标文件夹", + "moveSelectFolder": "选择文件夹", + "moveOtherDirectory": "或打开其他目录", + "moveDirectoryPlaceholder": "输入目录路径", + "moveBrowseDirectory": "浏览目录", + "moveUseFolder": "使用此文件夹", + "moveOpeningFolder": "正在打开...", + "movingConversation": "正在迁移...", + "moveSuccess": "会话已迁移", + "moveFailed": "无法迁移会话", + "moveTurnInProgress": "请等待当前回复结束后再迁移。", + "moveSameFolder": "请选择与当前文件夹不同的目标", + "moveOpenFolderFailed": "无法打开文件夹", + "moveDestinationUnavailable": "所选文件夹已不可用", "status": "状态", "delete": "删除", "importLocalSessions": "导入本地会话", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 0b38589a41..76964cf86e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1702,7 +1702,8 @@ "statusRunningBadge": "運行中", "runningCountBadge": "{count} 個會話進行中", "statusCancelledBadge": "已取消", - "worktreeRemovedBadge": "來源 worktree 已刪除", + "originalWorkingDirectoryLabel": "原始工作目錄", + "originalWorkingDirectoryBadge": "原始工作目錄:{path}", "conversationCountUnit": "{count} 條", "emptyFolderHint": "暫無對話", "noMatchingConversations": "找不到符合的對話", @@ -1844,6 +1845,24 @@ "newConversation": "新增會話", "attachToCurrentSession": "添加到會話", "rename": "重新命名", + "moveConversation": "移至資料夾", + "moveConversationTitle": "移動會話", + "moveConversationDescription": "將「{title}」移至另一個工作資料夾。已儲存的會話記錄會保留,下一則訊息將在新資料夾中執行。", + "moveCurrentFolder": "目前資料夾", + "moveDestinationFolder": "目標資料夾", + "moveSelectFolder": "選擇資料夾", + "moveOtherDirectory": "或開啟其他目錄", + "moveDirectoryPlaceholder": "輸入目錄路徑", + "moveBrowseDirectory": "瀏覽目錄", + "moveUseFolder": "使用此資料夾", + "moveOpeningFolder": "正在開啟...", + "movingConversation": "正在移動...", + "moveSuccess": "會話已移動", + "moveFailed": "無法移動會話", + "moveTurnInProgress": "請等待目前回覆完成後再移動。", + "moveSameFolder": "請選擇不同於目前資料夾的目標", + "moveOpenFolderFailed": "無法開啟資料夾", + "moveDestinationUnavailable": "所選資料夾已無法使用", "status": "狀態", "delete": "刪除", "importLocalSessions": "匯入本地會話", diff --git a/src/lib/api-conversation-move.test.ts b/src/lib/api-conversation-move.test.ts new file mode 100644 index 0000000000..b41e431645 --- /dev/null +++ b/src/lib/api-conversation-move.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const h = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call: h.call }), + getShellTransport: () => ({ call: vi.fn() }), + isDesktop: () => false, + isRemoteDesktopMode: () => false, + getActiveRemoteConnectionId: () => null, + notifyRemoteDesktopUnauthorized: vi.fn(), +})) + +import { moveConversation } from "@/lib/api" + +describe("moveConversation API", () => { + beforeEach(() => h.call.mockReset()) + + it("uses the shared command and camelCase transport payload", async () => { + const result = { id: 41, folder_id: 9 } + h.call.mockResolvedValue(result) + + await expect(moveConversation(41, 9)).resolves.toBe(result) + expect(h.call).toHaveBeenCalledWith("move_conversation", { + conversationId: 41, + targetFolderId: 9, + }) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 57fa6f9e21..afcf75906c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3011,6 +3011,16 @@ export async function updateConversationTitle( }) } +export async function moveConversation( + conversationId: number, + targetFolderId: number +): Promise { + return getTransport().call("move_conversation", { + conversationId, + targetFolderId, + }) +} + export async function updateConversationPinned( conversationId: number, pinned: boolean diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index cfb02e5331..192024dff3 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -1062,6 +1062,13 @@ export async function updateConversationTitle( return invoke("update_conversation_title", { conversationId, title }) } +export async function moveConversation( + conversationId: number, + targetFolderId: number +): Promise { + return invoke("move_conversation", { conversationId, targetFolderId }) +} + export async function deleteConversation( conversationId: number ): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 390454c217..b5c7901a6a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -423,9 +423,9 @@ export interface DbConversationSummary { parent_id?: number | null parent_tool_use_id?: string | null delegation_call_id?: string | null - /** Set when the conversation was re-parented out of a removed worktree: the - * worktree path it originally ran in. Drives the "source worktree removed" - * badge. */ + /** The native transcript's first working directory when it differs from the + * conversation's current folder (explicit move or removed-worktree + * re-parent). */ origin_cwd?: string | null } diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index 0966227afb..4c95e0151f 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -207,6 +207,14 @@ export interface TabStoreState { closeOtherTabs: (tabId: string) => void closeAllTabs: () => void closeTabsByFolder: (folderId: number) => void + /** Retarget an already-bound conversation without changing its stable local + * tab id/runtime key. Used by the explicit workspace-move API and by its + * cross-client upsert echo. */ + moveConversationTab: ( + conversationId: number, + targetFolderId: number, + workingDir: string + ) => void switchTab: (tabId: string) => void pinTab: (tabId: string) => void toggleGroupTile: (groupId: string) => void @@ -1306,6 +1314,34 @@ export const useTabStore = create()((set, get) => ({ recomputeTabs() }, + moveConversationTab: (conversationId, targetFolderId, workingDir) => { + const prevState = get() + const index = prevState.rawTabs.findIndex( + (tab) => tab.conversationId === conversationId + ) + if (index < 0) return + const current = prevState.rawTabs[index] + if ( + current.folderId === targetFolderId && + current.workingDir === workingDir + ) { + return + } + + const next = [...prevState.rawTabs] + next[index] = { + ...current, + folderId: targetFolderId, + workingDir, + } + // Keep `id` deliberately: a draft-origin tab may still carry its `new-*` + // runtime identity, and even a canonical tab is keyed throughout the live + // React tree. Re-keying either one would remount the panel and discard the + // connection/session state we are about to resume in the new cwd. + set({ rawTabs: next }) + recomputeTabs() + }, + switchTab: (tabId) => { if (!get().rawTabs.some((t) => t.id === tabId)) return focusTab(tabId) @@ -2602,6 +2638,7 @@ export function useTabActions() { closeOtherTabs: s.closeOtherTabs, closeAllTabs: s.closeAllTabs, closeTabsByFolder: s.closeTabsByFolder, + moveConversationTab: s.moveConversationTab, switchTab: s.switchTab, pinTab: s.pinTab, toggleGroupTile: s.toggleGroupTile, @@ -2752,9 +2789,13 @@ function applyRemoteSnapshot(change: TabsChanged) { prev.rawTabs.find( (tb) => tb.conversationId === it.conversation_id && - tb.folderId === it.folder_id && tb.agentType === it.agent_type ) + const targetWorkingDir = + useAppWorkspaceStore + .getState() + .allFolders.find((folder) => folder.id === it.folder_id)?.path ?? + existing?.workingDir return { id: existing?.id ?? canonicalId, kind: "conversation", @@ -2767,8 +2808,13 @@ function applyRemoteSnapshot(change: TabsChanged) { status: existing?.status, // Device-local per-tab fields the payload doesn't carry. Rebuilding the // tab from the snapshot must not blank them (a bound chat tab would lose - // the scratch working dir it is connected in). - workingDir: existing?.workingDir, + // the scratch working dir it is connected in). A server-authoritative + // folder change is the exception: retarget the cwd to that folder while + // preserving the tab's stable local id/runtime identity. + workingDir: + existing && existing.folderId !== it.folder_id + ? targetWorkingDir + : existing?.workingDir, isChat: existing?.isChat, } })