From 411fd23d428f9bfb39ba17b28e310017b24ba4d7 Mon Sep 17 00:00:00 2001 From: Han Date: Sat, 25 Jul 2026 20:10:16 +0800 Subject: [PATCH] fix(agent): preserve side chat workspace on migration Made-with: Proma --- .../src/renderer/atoms/chat-atoms.test.ts | 65 +++++++++++++++++++ .../electron/src/renderer/atoms/chat-atoms.ts | 32 ++++++++- .../agent/AgentHistorySelectionLayer.tsx | 9 ++- .../renderer/components/agent/SidePanel.tsx | 4 +- .../components/app-shell/LeftSidebar.tsx | 4 +- .../components/chat/AgentRecommendBanner.tsx | 37 +++++++---- .../renderer/components/chat/ChatMessages.tsx | 9 ++- .../src/renderer/components/chat/ChatView.tsx | 11 ++-- .../components/chat/MigrateToAgentButton.tsx | 18 +++-- .../components/diff/DiffTabContent.tsx | 8 ++- .../components/scratch-pad/ScratchPadView.tsx | 2 +- .../welcome/WelcomeEmptyState.test.tsx | 23 +++++++ .../components/welcome/WelcomeEmptyState.tsx | 14 ++-- .../src/renderer/hooks/useCloseTab.tsx | 4 +- 14 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 apps/electron/src/renderer/atoms/chat-atoms.test.ts create mode 100644 apps/electron/src/renderer/components/welcome/WelcomeEmptyState.test.tsx diff --git a/apps/electron/src/renderer/atoms/chat-atoms.test.ts b/apps/electron/src/renderer/atoms/chat-atoms.test.ts new file mode 100644 index 000000000..982a2fc98 --- /dev/null +++ b/apps/electron/src/renderer/atoms/chat-atoms.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { resolveChatMigrationWorkspaceId, type AgentSideChatContext } from './chat-atoms' + +const workspaces = [{ id: 'workspace-a' }, { id: 'workspace-b' }] + +function sideChats(entries: [string, AgentSideChatContext][]): Map { + return new Map(entries) +} + +describe('右侧问答迁移工作区', () => { + test('Given 右侧问答记录了有效来源工作区 When 解析迁移目标 Then 保留来源工作区', () => { + const workspaceId = resolveChatMigrationWorkspaceId( + 'conversation-side', + sideChats([['session-a', { conversationId: 'conversation-side', sourceWorkspaceId: 'workspace-b' }]]), + 'workspace-a', + workspaces, + ) + + expect(workspaceId).toBe('workspace-b') + }) + + test('Given 来源工作区已失效 When 解析迁移目标 Then 回退到当前有效工作区', () => { + const workspaceId = resolveChatMigrationWorkspaceId( + 'conversation-side', + sideChats([['session-a', { conversationId: 'conversation-side', sourceWorkspaceId: 'workspace-deleted' }]]), + 'workspace-b', + workspaces, + ) + + expect(workspaceId).toBe('workspace-b') + }) + + test('Given 普通 Chat 没有来源工作区 When 解析迁移目标 Then 使用当前有效工作区', () => { + const workspaceId = resolveChatMigrationWorkspaceId( + 'conversation-chat', + sideChats([]), + 'workspace-b', + workspaces, + ) + + expect(workspaceId).toBe('workspace-b') + }) + + test('Given 当前工作区已失效 When 解析迁移目标 Then 回退到列表首项', () => { + const workspaceId = resolveChatMigrationWorkspaceId( + 'conversation-chat', + sideChats([]), + 'workspace-deleted', + workspaces, + ) + + expect(workspaceId).toBe('workspace-a') + }) + + test('Given 没有可用工作区 When 解析迁移目标 Then 返回 undefined', () => { + const workspaceId = resolveChatMigrationWorkspaceId( + 'conversation-chat', + sideChats([]), + null, + [], + ) + + expect(workspaceId).toBeUndefined() + }) +}) diff --git a/apps/electron/src/renderer/atoms/chat-atoms.ts b/apps/electron/src/renderer/atoms/chat-atoms.ts index 012e29c60..82a71ed23 100644 --- a/apps/electron/src/renderer/atoms/chat-atoms.ts +++ b/apps/electron/src/renderer/atoms/chat-atoms.ts @@ -36,8 +36,36 @@ export const conversationsAtom = atom([]) /** 当前对话 ID */ export const currentConversationIdAtom = atom(null) -/** Agent 会话右侧 Chat 面板,key 为 Agent sessionId,value 为 Chat conversationId */ -export const agentSideChatMapAtom = atom>(new Map()) +/** Agent 右侧 Chat 的来源上下文。 */ +export interface AgentSideChatContext { + conversationId: string + sourceWorkspaceId?: string +} + +/** Agent 会话右侧 Chat 面板,key 为 Agent sessionId。 */ +export const agentSideChatMapAtom = atom>(new Map()) + +/** + * 为 Chat 迁移选择目标 Agent 工作区。 + * 优先保留右侧问答创建时记录的来源工作区,其次使用当前工作区。 + */ +export function resolveChatMigrationWorkspaceId( + conversationId: string, + sideChats: ReadonlyMap, + currentWorkspaceId: string | null, + workspaces: readonly { id: string }[], +): string | undefined { + const isAvailable = (workspaceId: string | null | undefined): workspaceId is string => + Boolean(workspaceId && workspaces.some((workspace) => workspace.id === workspaceId)) + + const sourceWorkspaceId = [...sideChats.values()] + .find((sideChat) => sideChat.conversationId === conversationId) + ?.sourceWorkspaceId + + if (isAvailable(sourceWorkspaceId)) return sourceWorkspaceId + if (isAvailable(currentWorkspaceId)) return currentWorkspaceId + return workspaces[0]?.id +} /** 当前对话的消息列表 */ export const currentMessagesAtom = atom([]) diff --git a/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx b/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx index e4bc8281f..8c2993e45 100644 --- a/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx +++ b/apps/electron/src/renderer/components/agent/AgentHistorySelectionLayer.tsx @@ -16,7 +16,7 @@ import { selectedModelAtom, } from '@/atoms/chat-atoms' import { quotedSelectionMapAtom } from '@/atoms/preview-atoms' -import { agentDiffPanelTabAtom, agentSidePanelOpenAtom } from '@/atoms/agent-atoms' +import { agentDiffPanelTabAtom, agentSessionsAtom, agentSidePanelOpenAtom } from '@/atoms/agent-atoms' import { SelectionActionPopover } from '@/components/selection/SelectionActionPopover' import { SELECTION_ACTION_POPOVER_SELECTOR } from '@/lib/quoted-selection' @@ -58,6 +58,7 @@ export function AgentHistorySelectionLayer({ }: AgentHistorySelectionLayerProps): React.ReactElement { const setQuotedSelectionMap = useSetAtom(quotedSelectionMapAtom) const selectedChatModel = useAtomValue(selectedModelAtom) + const agentSessions = useAtomValue(agentSessionsAtom) const setConversations = useSetAtom(conversationsAtom) const setConversationDrafts = useSetAtom(conversationDraftsAtom) const setSideChatMap = useSetAtom(agentSideChatMapAtom) @@ -246,7 +247,10 @@ export function AgentHistorySelectionLayer({ }) setSideChatMap((prev) => { const next = new Map(prev) - next.set(sessionId, conversation.id) + next.set(sessionId, { + conversationId: conversation.id, + sourceWorkspaceId: agentSessions.find((session) => session.id === sessionId)?.workspaceId, + }) return next }) setSidePanelOpen(true) @@ -264,6 +268,7 @@ export function AgentHistorySelectionLayer({ openChatPendingRef.current = false } }, [ + agentSessions, clearSelection, selectedChatModel, selection, diff --git a/apps/electron/src/renderer/components/agent/SidePanel.tsx b/apps/electron/src/renderer/components/agent/SidePanel.tsx index 9c12b50dd..593ca320c 100644 --- a/apps/electron/src/renderer/components/agent/SidePanel.tsx +++ b/apps/electron/src/renderer/components/agent/SidePanel.tsx @@ -401,7 +401,7 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt const isClassic = interfaceVariant === 'classic' const sideChatMap = useAtomValue(agentSideChatMapAtom) const setSideChatMap = useSetAtom(agentSideChatMapAtom) - const sideChatConversationId = sideChatMap.get(sessionId) ?? null + const sideChatConversationId = sideChatMap.get(sessionId)?.conversationId ?? null const effectiveActiveTab: AgentSidePanelTab = activeTab === 'chat' && !sideChatConversationId ? 'session' : activeTab @@ -449,7 +449,7 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt {effectiveActiveTab === 'chat' ? ( sideChatConversationId ? (
- +
) : (
暂无问答会话
diff --git a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx index d63a7e926..9af1c5943 100644 --- a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx +++ b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx @@ -863,8 +863,8 @@ export function LeftSidebar({ width, noTransition }: LeftSidebarProps): React.Re let changed = false const map = new Map(prev) if (map.delete(id)) changed = true - for (const [sessionId, conversationId] of map) { - if (conversationId === id) { + for (const [sessionId, sideChat] of map) { + if (sideChat.conversationId === id) { map.delete(sessionId) changed = true } diff --git a/apps/electron/src/renderer/components/chat/AgentRecommendBanner.tsx b/apps/electron/src/renderer/components/chat/AgentRecommendBanner.tsx index 277966dd8..418f59f36 100644 --- a/apps/electron/src/renderer/components/chat/AgentRecommendBanner.tsx +++ b/apps/electron/src/renderer/components/chat/AgentRecommendBanner.tsx @@ -7,9 +7,9 @@ * * 迁移流程: * 1. 清除推荐状态(先清再切换,避免 ChatView 副作用) - * 2. 创建 Agent 会话(绑定默认工作区) + * 2. 创建 Agent 会话(绑定来源或当前工作区) * 3. 将 Chat 对话历史复制到新 Agent 会话 - * 4. 切换到默认工作区 + Agent 模式 + * 4. 切换到目标工作区 + Agent 模式 * 5. 在 Agent 输入区显示建议提示(prompt suggestion) */ @@ -18,7 +18,11 @@ import { useAtom, useStore } from 'jotai' import { toast } from 'sonner' import { Sparkles, X, ArrowRight } from 'lucide-react' import { Button } from '@/components/ui/button' -import { pendingAgentRecommendationAtom } from '@/atoms/chat-atoms' +import { + agentSideChatMapAtom, + pendingAgentRecommendationAtom, + resolveChatMigrationWorkspaceId, +} from '@/atoms/chat-atoms' import { agentChannelIdAtom, agentModelIdAtom, @@ -32,12 +36,16 @@ import { activeViewAtom } from '@/atoms/active-view' import { appModeAtom } from '@/atoms/app-mode' import { tabsAtom, activeTabIdAtom, openTab } from '@/atoms/tab-atoms' -export function AgentRecommendBanner(): React.ReactElement | null { +interface AgentRecommendBannerProps { + conversationId: string +} + +export function AgentRecommendBanner({ conversationId }: AgentRecommendBannerProps): React.ReactElement | null { const [recommendation, setRecommendation] = useAtom(pendingAgentRecommendationAtom) const store = useStore() const [migrating, setMigrating] = React.useState(false) - if (!recommendation) return null + if (!recommendation || recommendation.conversationId !== conversationId) return null const handleDismiss = (): void => { setRecommendation(null) @@ -53,19 +61,24 @@ export function AgentRecommendBanner(): React.ReactElement | null { } // 保存推荐数据后立即清除,避免模式切换时 ChatView 副作用 - const { conversationId, suggestedPrompt } = recommendation + const { suggestedPrompt } = recommendation setRecommendation(null) setMigrating(true) try { const workspaces = store.get(agentWorkspacesAtom) - const defaultWorkspaceId = workspaces[0]?.id ?? null + const targetWorkspaceId = resolveChatMigrationWorkspaceId( + conversationId, + store.get(agentSideChatMapAtom), + store.get(currentAgentWorkspaceIdAtom), + workspaces, + ) // 1. 创建 Agent 会话 const session = await window.electronAPI.createAgentSession( undefined, agentChannelId, - defaultWorkspaceId ?? undefined, + targetWorkspaceId, store.get(agentModelIdAtom) || undefined, ) @@ -76,11 +89,11 @@ export function AgentRecommendBanner(): React.ReactElement | null { const sessions = await window.electronAPI.listAgentSessions() store.set(agentSessionsAtom, sessions) - // 4. 切换到默认工作区(确保 AgentView 能正确显示新会话) - if (defaultWorkspaceId) { - store.set(currentAgentWorkspaceIdAtom, defaultWorkspaceId) + // 4. 切换到目标工作区(确保 AgentView 能正确显示新会话) + if (targetWorkspaceId) { + store.set(currentAgentWorkspaceIdAtom, targetWorkspaceId) window.electronAPI.updateSettings({ - agentWorkspaceId: defaultWorkspaceId, + agentWorkspaceId: targetWorkspaceId, }).catch(console.error) } diff --git a/apps/electron/src/renderer/components/chat/ChatMessages.tsx b/apps/electron/src/renderer/components/chat/ChatMessages.tsx index 50396554b..b45b68e39 100644 --- a/apps/electron/src/renderer/components/chat/ChatMessages.tsx +++ b/apps/electron/src/renderer/components/chat/ChatMessages.tsx @@ -119,6 +119,8 @@ function ScrollTopLoader({ hasMore, loading, onLoadMore }: ScrollTopLoaderProps) interface ChatMessagesProps { /** 当前对话 ID */ conversationId: string + /** 空状态是否展示全局 Chat/Agent 模式切换。 */ + showModeSwitcher: boolean /** 消息列表 */ messages: ChatMessage[] /** 消息是否已完成首次 IPC 加载 */ @@ -160,12 +162,13 @@ interface ChatMessagesProps { } /** 空状态引导 — 使用 WelcomeEmptyState */ -function EmptyState(): React.ReactElement { - return +function EmptyState({ showModeSwitcher }: { showModeSwitcher: boolean }): React.ReactElement { + return } export function ChatMessages({ conversationId, + showModeSwitcher, messages, messagesLoaded, streaming, @@ -371,7 +374,7 @@ export function ChatMessages({ /> {messages.length === 0 && !streaming ? ( - + ) : ( <> {/* 已有消息 + 分隔线 */} diff --git a/apps/electron/src/renderer/components/chat/ChatView.tsx b/apps/electron/src/renderer/components/chat/ChatView.tsx index 46ee45662..008ccbe9c 100644 --- a/apps/electron/src/renderer/components/chat/ChatView.tsx +++ b/apps/electron/src/renderer/components/chat/ChatView.tsx @@ -57,6 +57,8 @@ import type { interface ChatViewProps { conversationId: string + /** 由 Agent 右侧面板承载的固定 Chat 对话。 */ + isSideChat?: boolean } function cleanupPendingAttachments(attachments: PendingAttachment[]): void { @@ -68,15 +70,15 @@ function cleanupPendingAttachments(attachments: PendingAttachment[]): void { } } -export function ChatView({ conversationId }: ChatViewProps): React.ReactElement { +export function ChatView({ conversationId, isSideChat = false }: ChatViewProps): React.ReactElement { return ( - + ) } -function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement { +function ChatViewInner({ conversationId, isSideChat = false }: ChatViewProps): React.ReactElement { // ===== 本地状态(每个实例独立) ===== const [messages, setMessages] = React.useState([]) const [contextDividers, setContextDividers] = React.useState([]) @@ -635,6 +637,7 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement { {/* 中间:消息区域 */} + {/* 底部:输入框 */} { const next = new Map(prev) - next.set(sessionId, conversation.id) + next.set(sessionId, { + conversationId: conversation.id, + sourceWorkspaceId: agentSessions.find((session) => session.id === sessionId)?.workspaceId, + }) return next }) setSidePanelOpen(true) @@ -1039,6 +1044,7 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO openSelectionChatPendingRef.current = false } }, [ + agentSessions, clearPreviewSelection, previewSelection, selectedChatModel, diff --git a/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx b/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx index 38a6cca67..fa13de85a 100644 --- a/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx +++ b/apps/electron/src/renderer/components/scratch-pad/ScratchPadView.tsx @@ -438,7 +438,7 @@ function ScratchPadEditor({ variant }: ScratchPadEditorProps): React.ReactElemen setAppMode('agent') setAgentSideChatMap((prev) => { const next = new Map(prev) - next.set(sessionId, conversation.id) + next.set(sessionId, { conversationId: conversation.id }) return next }) setAgentSidePanelOpen(true) diff --git a/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.test.tsx b/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.test.tsx new file mode 100644 index 000000000..c5a5a22fd --- /dev/null +++ b/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { WelcomeEmptyState } from './WelcomeEmptyState' + +function renderWelcome(showModeSwitcher?: boolean): string { + return renderToStaticMarkup() +} + +describe('WelcomeEmptyState 模式入口', () => { + test('Given 主界面空状态 When 未指定模式入口 Then 保留 Agent 和 Chat 切换', () => { + const html = renderWelcome() + + expect(html).toContain('>Agent<') + expect(html).toContain('>Chat<') + }) + + test('Given 右侧问答空状态 When 禁用模式入口 Then 不渲染全局 Agent 或 Chat 切换', () => { + const html = renderWelcome(false) + + expect(html).not.toContain('>Agent<') + expect(html).not.toContain('>Chat<') + }) +}) diff --git a/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.tsx b/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.tsx index 47982c1f6..70542d21c 100644 --- a/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.tsx +++ b/apps/electron/src/renderer/components/welcome/WelcomeEmptyState.tsx @@ -31,7 +31,12 @@ const MODE_CONFIG: Record = { scratch: { icon: , label: 'Scratch Pad' }, } -export function WelcomeEmptyState(): React.ReactElement { +interface WelcomeEmptyStateProps { + /** 右侧问答固定为 Chat,不能切换全局模式。 */ + showModeSwitcher?: boolean +} + +export function WelcomeEmptyState({ showModeSwitcher = true }: WelcomeEmptyStateProps): React.ReactElement { const userProfile = useAtomValue(userProfileAtom) const [mode, setMode] = useAtom(appModeAtom) const themeStyle = useAtomValue(themeStyleAtom) @@ -65,8 +70,8 @@ export function WelcomeEmptyState(): React.ReactElement { {tip.text} - {/* 模式切换 Tab */} -
+ {showModeSwitcher && ( +
{/* 滑动背景指示器 */}
) })} -
+
+ )}
) } diff --git a/apps/electron/src/renderer/hooks/useCloseTab.tsx b/apps/electron/src/renderer/hooks/useCloseTab.tsx index 24f19b8e5..4de9e5055 100644 --- a/apps/electron/src/renderer/hooks/useCloseTab.tsx +++ b/apps/electron/src/renderer/hooks/useCloseTab.tsx @@ -107,8 +107,8 @@ export function useCloseTab(): UseCloseTabReturn { setSideChatMap((prev) => { let changed = false const next = new Map(prev) - for (const [ownerSessionId, conversationId] of next) { - if (conversationId === closingTab.sessionId) { + for (const [ownerSessionId, sideChat] of next) { + if (sideChat.conversationId === closingTab.sessionId) { next.delete(ownerSessionId) changed = true }