From d8ba664cc444050a0c82a6a891eaada8e098b024 Mon Sep 17 00:00:00 2001 From: devil233-ui Date: Sun, 2 Aug 2026 03:12:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(chat):=20=E6=94=AF=E6=8C=81=E5=B0=86?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=A7=BB=E5=8A=A8=E5=88=B0=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4=EF=BC=88=E5=8D=95=E4=B8=AA?= =?UTF-8?q?=E4=B8=8E=E6=89=B9=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 chat_history_set_cwd 命令:更新会话的 cwd 归属并广播历史同步 - 前端新增 chat_history_set_cwd IPC 封装与 setChatHistoryCwd API - 侧边栏会话右键菜单新增"移动到工作空间"子菜单,列出可用工作空间 - 多选模式下新增"批量移动到工作空间"按钮,复用现有选择机制 - 新增后端单元测试覆盖移动归属与边界校验 --- .../commands/history/chat_history/commands.rs | 25 ++++ .../commands/history/chat_history/segments.rs | 33 ++++++ .../commands/history/chat_history/tests.rs | 37 ++++++ crates/agent-gui/src-tauri/src/lib.rs | 1 + crates/agent-gui/src/i18n/config.ts | 2 + .../src/lib/chat/history/chatHistory.ts | 6 + .../chat/sidebar/ChatSidebarContainer.tsx | 21 ++++ .../components/chat/ChatHistorySidebar.tsx | 107 ++++++++++++++++++ 8 files changed, 232 insertions(+) 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 24de3b4cb..bc80581c1 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/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 5612feb06..ac8c18112 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -125,6 +125,7 @@ export const translations: Record> = { "chat.conversationPin": "置顶对话", "chat.conversationUnpin": "取消置顶", "chat.conversationRename": "修改标题", + "chat.conversationMoveToWorkspace": "移动到工作空间", "chat.conversationShare": "分享", "chat.conversationDelete": "删除对话", "chat.conversationDeleteConfirm": "删除「{title}」?", @@ -2419,6 +2420,7 @@ export const translations: Record> = { "chat.conversationPin": "Pin conversation", "chat.conversationUnpin": "Unpin", "chat.conversationRename": "Rename", + "chat.conversationMoveToWorkspace": "Move to workspace", "chat.conversationShare": "Share", "chat.conversationDelete": "Delete conversation", "chat.conversationDeleteConfirm": 'Delete "{title}"?', diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 53b37b635..5c5e05863 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -478,6 +478,12 @@ export async function setChatHistoryModel(id: string, selectedModelJson: string) ); } +export async function setChatHistoryCwd(id: string, cwd: string) { + return withConversationWriteLock(id, () => + invoke("chat_history_set_cwd", { id, cwd }), + ); +} + export async function getChatHistoryShare(id: string) { return invoke("chat_history_share_get", { id }); } diff --git a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx index d2ddf2f88..98e22f19c 100644 --- a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx +++ b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx @@ -26,6 +26,7 @@ import { hideDesktopSidebarCloseButton, } from "../../../agent-ui-adapters/sidebarChrome"; import type { AppUpdateController } from "../../../lib/appUpdates"; +import { setChatHistoryCwd } from "../../../lib/chat/history/chatHistory"; import { normalizeConversationTitle } from "../../../lib/chat/page/chatPageHelpers"; import type { WorkspaceProject } from "../../../lib/settings"; @@ -151,6 +152,24 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { [store], ); + const handleMoveToWorkspace = useCallback( + (id: string, cwd: string) => { + void setChatHistoryCwd(id, cwd).then(() => { + void store.refresh(); + void store.refreshWorkdirs("delete"); + }); + }, + [store], + ); + + const handleMoveConversationsToWorkspace = useCallback( + async (ids: readonly string[], cwd: string) => { + await Promise.all(ids.map((id) => setChatHistoryCwd(id, cwd))); + await Promise.all([store.refresh(), store.refreshWorkdirs("delete")]); + }, + [store], + ); + const handleDeleteConversation = useCallback( (id: string) => { store.clearMutationError(id); @@ -251,6 +270,8 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { onCommitRename={handleCommitRename} onCancelRename={handleCancelRename} onSetPinned={handleSetPinned} + onMoveToWorkspace={handleMoveToWorkspace} + onMoveConversationsToWorkspace={handleMoveConversationsToWorkspace} canShareConversations={props.canShareConversations} sharedConversationCount={props.sharedConversationCount} onShareConversation={props.onShareConversation} diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index f95b334dc..941bc2f60 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -9,6 +9,7 @@ import { ChevronRight, CirclePlus, Edit3, + Folder, FolderClosed, FolderOpen, FolderTree, @@ -35,6 +36,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@liveagent/ui/components/ui/dropdown-menu"; import { Input } from "@liveagent/ui/components/ui/input"; @@ -132,6 +136,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; @@ -225,6 +231,8 @@ type HistoryRowProps = { 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; @@ -269,6 +277,8 @@ function areHistoryRowPropsEqual(previous: HistoryRowProps, next: HistoryRowProp previous.onCommitRename === next.onCommitRename && previous.onCancelRename === next.onCancelRename && previous.onSetPinned === next.onSetPinned && + previous.onMoveToWorkspace === next.onMoveToWorkspace && + previous.moveWorkspaces === next.moveWorkspaces && previous.onShareConversation === next.onShareConversation && previous.onDeleteConversation === next.onDeleteConversation && previous.onSetPendingDelete === next.onSetPendingDelete && @@ -300,6 +310,8 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { onCommitRename, onCancelRename, onSetPinned, + onMoveToWorkspace, + moveWorkspaces, onShareConversation, onDeleteConversation, onSetPendingDelete, @@ -860,6 +872,35 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { {t("chat.conversationRename")} + + + + {t("chat.conversationMoveToWorkspace")} + + + {moveWorkspaces.map((workspace) => ( + onMoveToWorkspace(item.id, workspace.path)} + className="gap-2" + > + + {workspace.path} + + ))} + + {canShareConversation && !item.isPending ? ( 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 [openMenuId, setOpenMenuId] = useState(null); @@ -1556,6 +1601,11 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi onSetPinned(id, isPinned); } }); + const handleMoveToWorkspace = useStableEvent((id: string, cwd: string) => { + if (!sectionsDisabled) { + onMoveToWorkspace(id, cwd); + } + }); const handleShareConversation = useStableEvent((item: SidebarConversation) => { if (!sectionsDisabled) { onShareConversation(item); @@ -1776,6 +1826,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 || sectionsDisabled) { + 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( @@ -2263,6 +2330,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi onCommitRename={handleCommitRename} onCancelRename={handleCancelRename} onSetPinned={handleSetPinned} + onMoveToWorkspace={handleMoveToWorkspace} + moveWorkspaces={activeProjects} onShareConversation={handleShareConversation} onDeleteConversation={handleDeleteConversation} onSetPendingDelete={handleSetPendingDelete} @@ -2284,10 +2353,12 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi handleSelectConversation, handleSetPinned, handleSetPendingDelete, + handleMoveToWorkspace, handleShareConversation, handleStartRenaming, busyConversationIds, canShareConversations, + activeProjects, enterSelectionMode, isBulkDeleting, isMobileMenuLayout, @@ -2660,6 +2731,42 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
{selectionMode ? ( <> + + + {isBulkMoving ? ( + + ) : ( + + )} + + + {activeProjects.map((workspace) => ( + void handleBulkMove(workspace.path)} + className="gap-2" + > + + {workspace.path} + + ))} + +