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
65 changes: 65 additions & 0 deletions apps/electron/src/renderer/atoms/chat-atoms.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentSideChatContext> {
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()
})
})
32 changes: 30 additions & 2 deletions apps/electron/src/renderer/atoms/chat-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,36 @@ export const conversationsAtom = atom<ConversationMeta[]>([])
/** 当前对话 ID */
export const currentConversationIdAtom = atom<string | null>(null)

/** Agent 会话右侧 Chat 面板,key 为 Agent sessionId,value 为 Chat conversationId */
export const agentSideChatMapAtom = atom<Map<string, string>>(new Map())
/** Agent 右侧 Chat 的来源上下文。 */
export interface AgentSideChatContext {
conversationId: string
sourceWorkspaceId?: string
}

/** Agent 会话右侧 Chat 面板,key 为 Agent sessionId。 */
export const agentSideChatMapAtom = atom<Map<string, AgentSideChatContext>>(new Map())

/**
* 为 Chat 迁移选择目标 Agent 工作区。
* 优先保留右侧问答创建时记录的来源工作区,其次使用当前工作区。
*/
export function resolveChatMigrationWorkspaceId(
conversationId: string,
sideChats: ReadonlyMap<string, AgentSideChatContext>,
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<ChatMessage[]>([])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -264,6 +268,7 @@ export function AgentHistorySelectionLayer({
openChatPendingRef.current = false
}
}, [
agentSessions,
clearSelection,
selectedChatModel,
selection,
Expand Down
4 changes: 2 additions & 2 deletions apps/electron/src/renderer/components/agent/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -449,7 +449,7 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt
{effectiveActiveTab === 'chat' ? (
sideChatConversationId ? (
<div className="min-h-0 flex-1 overflow-hidden">
<ChatView conversationId={sideChatConversationId} />
<ChatView conversationId={sideChatConversationId} isSideChat />
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">暂无问答会话</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
37 changes: 25 additions & 12 deletions apps/electron/src/renderer/components/chat/AgentRecommendBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
*
* 迁移流程:
* 1. 清除推荐状态(先清再切换,避免 ChatView 副作用)
* 2. 创建 Agent 会话(绑定默认工作区
* 2. 创建 Agent 会话(绑定来源或当前工作区
* 3. 将 Chat 对话历史复制到新 Agent 会话
* 4. 切换到默认工作区 + Agent 模式
* 4. 切换到目标工作区 + Agent 模式
* 5. 在 Agent 输入区显示建议提示(prompt suggestion)
*/

Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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,
)

Expand All @@ -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)
}

Expand Down
9 changes: 6 additions & 3 deletions apps/electron/src/renderer/components/chat/ChatMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ function ScrollTopLoader({ hasMore, loading, onLoadMore }: ScrollTopLoaderProps)
interface ChatMessagesProps {
/** 当前对话 ID */
conversationId: string
/** 空状态是否展示全局 Chat/Agent 模式切换。 */
showModeSwitcher: boolean
/** 消息列表 */
messages: ChatMessage[]
/** 消息是否已完成首次 IPC 加载 */
Expand Down Expand Up @@ -160,12 +162,13 @@ interface ChatMessagesProps {
}

/** 空状态引导 — 使用 WelcomeEmptyState */
function EmptyState(): React.ReactElement {
return <WelcomeEmptyState />
function EmptyState({ showModeSwitcher }: { showModeSwitcher: boolean }): React.ReactElement {
return <WelcomeEmptyState showModeSwitcher={showModeSwitcher} />
}

export function ChatMessages({
conversationId,
showModeSwitcher,
messages,
messagesLoaded,
streaming,
Expand Down Expand Up @@ -371,7 +374,7 @@ export function ChatMessages({
/>
<ConversationContent>
{messages.length === 0 && !streaming ? (
<EmptyState />
<EmptyState showModeSwitcher={showModeSwitcher} />
) : (
<>
{/* 已有消息 + 分隔线 */}
Expand Down
11 changes: 7 additions & 4 deletions apps/electron/src/renderer/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import type {

interface ChatViewProps {
conversationId: string
/** 由 Agent 右侧面板承载的固定 Chat 对话。 */
isSideChat?: boolean
}

function cleanupPendingAttachments(attachments: PendingAttachment[]): void {
Expand All @@ -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 (
<ConversationProvider conversationId={conversationId}>
<ChatViewInner conversationId={conversationId} />
<ChatViewInner conversationId={conversationId} isSideChat={isSideChat} />
</ConversationProvider>
)
}

function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement {
function ChatViewInner({ conversationId, isSideChat = false }: ChatViewProps): React.ReactElement {
// ===== 本地状态(每个实例独立) =====
const [messages, setMessages] = React.useState<ChatMessage[]>([])
const [contextDividers, setContextDividers] = React.useState<string[]>([])
Expand Down Expand Up @@ -635,6 +637,7 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement {
{/* 中间:消息区域 */}
<ChatMessages
conversationId={conversationId}
showModeSwitcher={!isSideChat}
messages={messages}
messagesLoaded={messagesLoaded}
streaming={isStreaming}
Expand Down Expand Up @@ -678,7 +681,7 @@ function ChatViewInner({ conversationId }: ChatViewProps): React.ReactElement {
)}

{/* Agent 模式推荐横幅 */}
<AgentRecommendBanner />
<AgentRecommendBanner conversationId={conversationId} />

{/* 底部:输入框 */}
<ChatInput
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { tabsAtom, activeTabIdAtom, openTab } from '@/atoms/tab-atoms'
import { activeViewAtom } from '@/atoms/active-view'
import { appModeAtom } from '@/atoms/app-mode'
import { agentSideChatMapAtom, resolveChatMigrationWorkspaceId } from '@/atoms/chat-atoms'

interface MigrateToAgentButtonProps {
/** 当前对话 ID */
Expand All @@ -46,13 +47,18 @@ export function MigrateToAgentButton({ conversationId }: MigrateToAgentButtonPro
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,
)

Expand All @@ -63,11 +69,11 @@ export function MigrateToAgentButton({ conversationId }: MigrateToAgentButtonPro
const sessions = await window.electronAPI.listAgentSessions()
store.set(agentSessionsAtom, sessions)

// 4. 切换到默认工作区
if (defaultWorkspaceId) {
store.set(currentAgentWorkspaceIdAtom, defaultWorkspaceId)
// 4. 切换到目标工作区
if (targetWorkspaceId) {
store.set(currentAgentWorkspaceIdAtom, targetWorkspaceId)
window.electronAPI.updateSettings({
agentWorkspaceId: defaultWorkspaceId,
agentWorkspaceId: targetWorkspaceId,
}).catch(console.error)
}

Expand Down
Loading