diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs index cd4355afc..94a1a5949 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs @@ -410,6 +410,31 @@ pub async fn chat_history_set_pinned( Ok(summary) } +pub(crate) async fn chat_history_set_cwd_inner( + id: String, + cwd: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + set_chat_history_cwd_sync(&conn, &id, &cwd) + }) + .await + .map_err(|e| format!("chat_history_set_cwd join 失败:{e}"))? +} + +#[tauri::command] +pub async fn chat_history_set_cwd( + id: String, + cwd: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + let summary = chat_history_set_cwd_inner(id, cwd).await?; + gateway_controller + .publish_history_sync(build_history_sync_upsert(&summary)) + .await; + Ok(summary) +} + pub(crate) async fn chat_history_set_model_inner( id: String, selected_model_json: String, diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs index f2ead519c..63460fcb6 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/segments.rs @@ -526,6 +526,39 @@ fn set_chat_history_pinned_sync( get_summary_by_id(conn, chat_id) } + +fn set_chat_history_cwd_sync( + conn: &Connection, + id: &str, + cwd: &str, +) -> Result { + let chat_id = id.trim(); + if chat_id.is_empty() { + return Err("历史对话 id 不能为空".to_string()); + } + + let target = cwd.trim(); + if target.is_empty() { + return Err("目标工作空间不能为空".to_string()); + } + + let affected = conn + .execute( + " + UPDATE chatHistory + SET cwd = ?1 + WHERE id = ?2 + ", + params![target, chat_id], + ) + .map_err(|e| format!("更新历史对话工作空间失败:{e}"))?; + + if affected == 0 { + return Err("未找到对应的历史对话".to_string()); + } + + get_summary_by_id(conn, chat_id) +} fn rename_chat_history_sync( conn: &Connection, id: &str, diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs index 3833f93f4..9d1fc26c0 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs @@ -526,6 +526,43 @@ mod tests { assert_eq!(summary.title, "Updated Conversation"); } + #[test] + fn set_cwd_moves_conversation_between_workspaces() { + let conn = open_test_db().expect("open test db"); + let mut conversation = sample_conversation(); + conversation.cwd = Some("/tmp/project-a".to_string()); + upsert_chat_history_header(&conn, &conversation).expect("upsert header"); + + let summary = set_chat_history_cwd_sync(&conn, "conv-1", "/tmp/project-b") + .expect("move conversation to another workspace"); + assert_eq!(summary.cwd.as_deref(), Some("/tmp/project-b")); + + let reloaded = get_summary_by_id(&conn, "conv-1").expect("reload summary"); + assert_eq!(reloaded.cwd.as_deref(), Some("/tmp/project-b")); + + // Deletes the conversation from the workdirs grouping of the old cwd. + let workdirs = list_chat_history_workdirs_sync(&conn).expect("list workdirs"); + assert_eq!(workdirs.workdirs.len(), 1); + assert_eq!(workdirs.workdirs[0].path, "/tmp/project-b"); + } + + #[test] + fn set_cwd_rejects_empty_target_or_missing_conversation() { + let conn = open_test_db().expect("open test db"); + let mut conversation = sample_conversation(); + conversation.cwd = Some("/tmp/project-a".to_string()); + upsert_chat_history_header(&conn, &conversation).expect("upsert header"); + + let empty_target = + set_chat_history_cwd_sync(&conn, "conv-1", " ").expect_err("reject empty target"); + assert!(empty_target.contains("工作空间")); + + let missing = + set_chat_history_cwd_sync(&conn, "does-not-exist", "/tmp/project-b") + .expect_err("reject missing conversation"); + assert!(missing.contains("未找到")); + } + #[test] fn v1_database_gains_selected_model_column_via_v2_migration() { // 复现存量库场景:完整的 v1 schema(无 selected_model_json)且 diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index ee7b77a52..4be1fe007 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ macro_rules! app_invoke_handler { commands::chat_history::chat_history_replace_from_message, commands::chat_history::chat_history_set_pinned, commands::chat_history::chat_history_set_model, + commands::chat_history::chat_history_set_cwd, commands::chat_history::chat_history_share_get, commands::chat_history::chat_history_share_set, commands::chat_history::chat_history_delete, diff --git a/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx index 4d9f91787..52e2418c6 100644 --- a/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx @@ -30,6 +30,7 @@ import { ChevronRight, CirclePlus, Edit3, + Folder, FolderClosed, FolderOpen, FolderTree, @@ -52,6 +53,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { Input } from "../ui/input"; @@ -114,6 +118,8 @@ type ChatHistorySidebarProps = { onCommitRename: () => void; onCancelRename: () => void; onSetPinned: (id: string, isPinned: boolean) => void; + onMoveToWorkspace: (id: string, cwd: string) => void; + onMoveConversationsToWorkspace: (ids: readonly string[], cwd: string) => Promise; canShareConversations: boolean; sharedConversationCount: number; onShareConversation: (item: SidebarConversation) => void; @@ -183,6 +189,8 @@ const HistoryRow = memo(function HistoryRow(props: { onCommitRename: () => void; onCancelRename: () => void; onSetPinned: (id: string, isPinned: boolean) => void; + onMoveToWorkspace: (id: string, cwd: string) => void; + moveWorkspaces: readonly WorkspaceProject[]; onShareConversation: (item: SidebarConversation) => void; onDeleteConversation: (id: string) => void; onSetPendingDelete: (id: string | null) => void; @@ -208,6 +216,8 @@ const HistoryRow = memo(function HistoryRow(props: { onCommitRename, onCancelRename, onSetPinned, + onMoveToWorkspace, + moveWorkspaces, onShareConversation, onDeleteConversation, onSetPendingDelete, @@ -478,6 +488,28 @@ const HistoryRow = memo(function HistoryRow(props: { {t("chat.conversationRename")} + + + + {t("chat.conversationMoveToWorkspace")} + + + {moveWorkspaces.map((workspace) => ( + onMoveToWorkspace(item.id, workspace.path)} + className="gap-2" + > + + {workspace.path} + + ))} + + new Set(), ); const [isBulkDeleting, setIsBulkDeleting] = useState(false); + const [isBulkMoving, setIsBulkMoving] = useState(false); + const [bulkMoveMenuOpen, setBulkMoveMenuOpen] = useState(false); const [pendingProjectRemoveId, setPendingProjectRemoveId] = useState(null); const [showAllProjects, setShowAllProjects] = useState(false); const [projectSectionHeight, setProjectSectionHeight] = useState(null); @@ -1235,6 +1271,23 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi } } }); + const handleBulkMove = useStableEvent(async (cwd: string) => { + const ids = orderedConversationIds.filter( + (id) => selectedConversationIds.has(id) && selectableConversationIds.has(id), + ); + if (ids.length === 0 || isBulkMoving) { + return; + } + setIsBulkMoving(true); + try { + await onMoveConversationsToWorkspace(ids, cwd); + setSelectionMode(false); + setSelectedConversationIds(new Set()); + selectionAnchorRef.current = null; + } finally { + setIsBulkMoving(false); + } + }); // Archived rows are split into their own collapsed group at the list end; // the render cap only applies to the active rows. const activeProjects = useMemo( @@ -1622,6 +1675,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi onCommitRename={handleCommitRename} onCancelRename={handleCancelRename} onSetPinned={handleSetPinned} + onMoveToWorkspace={onMoveToWorkspace} + moveWorkspaces={projects} onShareConversation={handleShareConversation} onDeleteConversation={handleDeleteConversation} onSetPendingDelete={setPendingDeleteId} @@ -1639,6 +1694,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi handleRenameDraftChange, handleSelectConversation, handleSetPinned, + onMoveToWorkspace, + projects, handleShareConversation, handleStartRenaming, isBulkDeleting, @@ -1978,6 +2035,42 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
{selectionMode ? ( <> + + + {isBulkMoving ? ( + + ) : ( + + )} + + + {activeProjects.map((workspace) => ( + void handleBulkMove(workspace.path)} + className="gap-2" + > + + {workspace.path} + + ))} + +