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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatHistorySummary, String> {
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<GatewayController>>,
) -> Result<ChatHistorySummary, String> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatHistorySummary, String> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)且
Expand Down
1 change: 1 addition & 0 deletions crates/agent-gui/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ChevronRight,
CirclePlus,
Edit3,
Folder,
FolderClosed,
FolderOpen,
FolderTree,
Expand All @@ -52,6 +53,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Input } from "../ui/input";
Expand Down Expand Up @@ -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<void>;
canShareConversations: boolean;
sharedConversationCount: number;
onShareConversation: (item: SidebarConversation) => void;
Expand Down Expand Up @@ -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;
Expand All @@ -208,6 +216,8 @@ const HistoryRow = memo(function HistoryRow(props: {
onCommitRename,
onCancelRename,
onSetPinned,
onMoveToWorkspace,
moveWorkspaces,
onShareConversation,
onDeleteConversation,
onSetPendingDelete,
Expand Down Expand Up @@ -478,6 +488,28 @@ const HistoryRow = memo(function HistoryRow(props: {
<Edit3 className="h-3.5 w-3.5" />
{t("chat.conversationRename")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={isRunning || isBusy || moveWorkspaces.length === 0}
className="gap-2"
>
<Folder className="h-3.5 w-3.5" />
{t("chat.conversationMoveToWorkspace")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="sidebar-context-menu max-h-[18rem] min-w-[12rem] overflow-y-auto rounded-xl border-border/60 bg-background/95 backdrop-blur-xl">
{moveWorkspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
disabled={isRunning || isBusy || workspace.path === item.cwd}
onSelect={() => onMoveToWorkspace(item.id, workspace.path)}
className="gap-2"
>
<FolderClosed className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{workspace.path}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
disabled={isDeleteDisabled || isBusy}
onSelect={handleRequestDelete}
Expand Down Expand Up @@ -1032,6 +1064,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
onCommitRename,
onCancelRename,
onSetPinned,
onMoveToWorkspace,
onMoveConversationsToWorkspace,
canShareConversations,
sharedConversationCount,
onShareConversation,
Expand All @@ -1053,6 +1087,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
() => new Set(),
);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isBulkMoving, setIsBulkMoving] = useState(false);
const [bulkMoveMenuOpen, setBulkMoveMenuOpen] = useState(false);
const [pendingProjectRemoveId, setPendingProjectRemoveId] = useState<string | null>(null);
const [showAllProjects, setShowAllProjects] = useState(false);
const [projectSectionHeight, setProjectSectionHeight] = useState<number | null>(null);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}
Expand All @@ -1639,6 +1694,8 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
handleRenameDraftChange,
handleSelectConversation,
handleSetPinned,
onMoveToWorkspace,
projects,
handleShareConversation,
handleStartRenaming,
isBulkDeleting,
Expand Down Expand Up @@ -1978,6 +2035,42 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
<div className="flex items-center gap-1.5">
{selectionMode ? (
<>
<DropdownMenu open={bulkMoveMenuOpen} onOpenChange={setBulkMoveMenuOpen}>
<DropdownMenuTrigger
type="button"
disabled={selectedConversationIds.size === 0 || isBulkMoving}
className={cn(
PROJECT_ICON_BUTTON_CLASS,
"inline-flex items-center justify-center",
"disabled:pointer-events-none disabled:opacity-50",
)}
title={t("chat.conversationMoveToWorkspace")}
aria-label={t("chat.conversationMoveToWorkspace")}
>
{isBulkMoving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Folder className="h-3.5 w-3.5" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="sidebar-context-menu max-h-[18rem] min-w-[12rem] overflow-y-auto rounded-xl border-border/60 bg-background/95 backdrop-blur-xl"
>
{activeProjects.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
disabled={workspace.path === null}
onSelect={() => void handleBulkMove(workspace.path)}
className="gap-2"
>
<FolderClosed className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{workspace.path}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-gui/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.conversationPin": "置顶对话",
"chat.conversationUnpin": "取消置顶",
"chat.conversationRename": "修改标题",
"chat.conversationMoveToWorkspace": "移动到工作空间",
"chat.conversationShare": "分享",
"chat.conversationDelete": "删除对话",
"chat.conversationDeleteConfirm": "删除「{title}」?",
Expand Down Expand Up @@ -2375,6 +2376,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"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}"?',
Expand Down
6 changes: 6 additions & 0 deletions crates/agent-gui/src/lib/chat/history/chatHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,12 @@ export async function setChatHistoryModel(id: string, selectedModelJson: string)
);
}

export async function setChatHistoryCwd(id: string, cwd: string) {
return withConversationWriteLock(id, () =>
invoke<ChatHistorySummary>("chat_history_set_cwd", { id, cwd }),
);
}

export async function getChatHistoryShare(id: string) {
return invoke<ChatHistoryShareStatus>("chat_history_share_get", { id });
}
Expand Down
21 changes: 21 additions & 0 deletions crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from "react";
import { ChatHistorySidebar } from "../../../components/chat/ChatHistorySidebar";
import { useLocale } from "../../../i18n";
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";
import type { SidebarBatchDeleteOptions } from "../../../lib/sidebar/batchDelete";
Expand Down Expand Up @@ -144,6 +145,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);
Expand Down Expand Up @@ -244,6 +263,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}
Expand Down
Loading