From c0fd05d913633355fbe381166c8d0d1f92983dc1 Mon Sep 17 00:00:00 2001 From: Troy Mitchell Date: Fri, 28 Aug 2026 14:41:24 +0800 Subject: [PATCH 1/2] feat(conversations): add workspace migration Rebind the conversation and any open tab atomically while preserving the native transcript cwd for parser lookup. Reject active turns and detach idle ACP sessions before the next resume uses the destination folder. --- src-tauri/src/commands/conversations.rs | 863 +++++++++++++++++- src-tauri/src/db/entities/conversation.rs | 10 +- .../migration/m20260801_000001_work_task.rs | 10 +- src-tauri/src/db/service/tab_service.rs | 19 + src-tauri/src/lib.rs | 1 + src-tauri/src/models/conversation.rs | 6 +- src-tauri/src/web/handlers/conversations.rs | 22 + src-tauri/src/web/router.rs | 4 + src-tauri/tests/api_integration.rs | 42 + src/lib/api-conversation-move.test.ts | 29 + src/lib/api.ts | 10 + src/lib/tauri.ts | 7 + src/lib/types.ts | 6 +- 13 files changed, 1012 insertions(+), 17 deletions(-) create mode 100644 src/lib/api-conversation-move.test.ts 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/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 } From ea32642ad20c2268218996ef93af9e0d7628ec46 Mon Sep 17 00:00:00 2001 From: Troy Mitchell Date: Fri, 28 Aug 2026 14:41:44 +0800 Subject: [PATCH 2/2] feat(conversations): add move workflow Expose registered and arbitrary folder destinations while keeping open-tab runtime identities stable across local and remote moves. Add translations and regression coverage. --- .../conversation-detail-header.test.tsx | 29 +- .../conversation-detail-header.tsx | 37 +++ .../conversation-move-dialog.test.tsx | 294 ++++++++++++++++++ .../conversation-move-dialog.tsx | 228 ++++++++++++++ .../sidebar-conversation-card.test.tsx | 47 +++ .../sidebar-conversation-card.tsx | 50 ++- ...idebar-conversation-hover-details.test.tsx | 4 +- .../sidebar-conversation-hover-details.tsx | 2 +- src/contexts/app-workspace-context.test.tsx | 26 +- src/contexts/app-workspace-context.tsx | 18 ++ src/contexts/tab-context.test.tsx | 67 ++++ src/contexts/tab-context.tsx | 6 + src/i18n/messages/ar.json | 21 +- src/i18n/messages/de.json | 21 +- src/i18n/messages/en.json | 21 +- src/i18n/messages/es.json | 21 +- src/i18n/messages/fr.json | 21 +- src/i18n/messages/ja.json | 21 +- src/i18n/messages/ko.json | 21 +- src/i18n/messages/pt.json | 21 +- src/i18n/messages/zh-CN.json | 21 +- src/i18n/messages/zh-TW.json | 21 +- src/stores/tab-store.ts | 52 +++- 23 files changed, 1045 insertions(+), 25 deletions(-) create mode 100644 src/components/conversations/conversation-move-dialog.test.tsx create mode 100644 src/components/conversations/conversation-move-dialog.tsx 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/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, } })