From 2477ea33083ec89a79fc27dfc9e052eee0b08b36 Mon Sep 17 00:00:00 2001 From: liuweitao Date: Thu, 16 Apr 2026 18:26:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=BB=93=E5=BA=93=E6=97=B6=20Agent=20=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E4=B8=8D=E8=B7=9F=E9=9A=8F=E5=88=87=E6=8D=A2=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: activeIds 使用 cwd 作为唯一 key,不同仓库切换时 key 被覆盖, 导致切回旧仓库时 MainContent 的 activeSessionId 计算找到错误的 session。 修复: - 使用复合 key repoPath::cwd 实现跨仓库隔离 - setActiveId 签名改为 (repoPath, cwd, sessionId) - useActiveSessionId 提供可选 repoPath 参数保持向后兼容 - 更新 AgentPanel/MainContent/useFocusSession/RunningProjectsPopover 调用点 --- src/renderer/App/hooks/useFocusSession.ts | 2 +- src/renderer/components/chat/AgentPanel.tsx | 26 +++++---- .../components/layout/MainContent.tsx | 2 +- .../layout/RunningProjectsPopover.tsx | 2 +- src/renderer/stores/agentSessions.ts | 55 ++++++++++++++----- 5 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/renderer/App/hooks/useFocusSession.ts b/src/renderer/App/hooks/useFocusSession.ts index bc6a517a..64705758 100644 --- a/src/renderer/App/hooks/useFocusSession.ts +++ b/src/renderer/App/hooks/useFocusSession.ts @@ -22,7 +22,7 @@ export function useFocusSession({ onSwitchWorktree, onSwitchTab }: UseFocusSessi if (session) { // Switch to the session's worktree first, then set active session (same as RunningProjectsPopover) onSwitchWorktree(session.cwd); - useAgentSessionsStore.getState().setActiveId(session.cwd, sessionId); + useAgentSessionsStore.getState().setActiveId(session.repoPath, session.cwd, sessionId); onSwitchTab('chat'); } }); diff --git a/src/renderer/components/chat/AgentPanel.tsx b/src/renderer/components/chat/AgentPanel.tsx index 8effccc8..1a1b5774 100644 --- a/src/renderer/components/chat/AgentPanel.tsx +++ b/src/renderer/components/chat/AgentPanel.tsx @@ -452,9 +452,8 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } useEffect(() => { if (!cwd || groups.length === 0) return; - const normalizedCwd = normalizePath(cwd); const unsubscribe = useAgentSessionsStore.subscribe( - (state) => state.activeIds[normalizedCwd], + (state) => state.activeIds[`${normalizePath(repoPath)}::${normalizePath(cwd)}`], (storeActiveId) => { if (!storeActiveId) return; @@ -476,7 +475,7 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } ); return unsubscribe; - }, [cwd, groups, updateCurrentGroupState]); + }, [repoPath, cwd, groups, updateCurrentGroupState]); // Empty state agent menu const [showAgentMenu, setShowAgentMenu] = useState(false); @@ -607,7 +606,7 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } }; }); - setActiveId(cwd, newSession.id); + setActiveId(repoPath, cwd, newSession.id); clearContinueRequest(); } }, [ @@ -791,7 +790,7 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } // Handle session selection const handleSelectSession = useCallback( (id: string, groupId?: string) => { - setActiveId(cwd, id); + setActiveId(repoPath, cwd, id); updateCurrentGroupState((state) => { const targetGroupId = groupId || state.groups.find((g) => g.sessionIds.includes(id))?.id; @@ -806,7 +805,7 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } }; }); }, - [cwd, setActiveId, updateCurrentGroupState] + [cwd, repoPath, setActiveId, updateCurrentGroupState] ); // Notification payload may carry either UI session id or Claude sessionId. @@ -942,14 +941,14 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } const nextIndex = (currentIndex + 1) % activeGroup.sessionIds.length; const nextSessionId = activeGroup.sessionIds[nextIndex]; - setActiveId(cwd, nextSessionId); + setActiveId(repoPath, cwd, nextSessionId); updateCurrentGroupState((state) => ({ ...state, groups: state.groups.map((g) => g.id === activeGroupId ? { ...g, activeSessionId: nextSessionId } : g ), })); - }, [groups, activeGroupId, cwd, setActiveId, updateCurrentGroupState]); + }, [groups, activeGroupId, cwd, repoPath, setActiveId, updateCurrentGroupState]); const handlePrevSession = useCallback(() => { const activeGroup = groups.find((g) => g.id === activeGroupId); @@ -959,14 +958,14 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } const prevIndex = currentIndex <= 0 ? activeGroup.sessionIds.length - 1 : currentIndex - 1; const prevSessionId = activeGroup.sessionIds[prevIndex]; - setActiveId(cwd, prevSessionId); + setActiveId(repoPath, cwd, prevSessionId); updateCurrentGroupState((state) => ({ ...state, groups: state.groups.map((g) => g.id === activeGroupId ? { ...g, activeSessionId: prevSessionId } : g ), })); - }, [groups, activeGroupId, cwd, setActiveId, updateCurrentGroupState]); + }, [groups, activeGroupId, cwd, repoPath, setActiveId, updateCurrentGroupState]); const handleInitialized = useCallback( (id: string) => { @@ -1340,7 +1339,10 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } const normalizedCwd = normalizePath(cwd); const currentState = worktreeGroupStates[normalizedCwd]; - const storeActiveId = useAgentSessionsStore.getState().activeIds[normalizedCwd]; + const storeActiveId = + useAgentSessionsStore.getState().activeIds[ + `${normalizePath(repoPath)}::${normalizePath(cwd)}` + ]; // If no groups exist but sessions do, create a group with all sessions if (!currentState || currentState.groups.length === 0) { @@ -1386,7 +1388,7 @@ export function AgentPanel({ repoPath, cwd, isActive = false, onSwitchWorktree } } } } - }, [cwd, currentWorktreeSessions, worktreeGroupStates, setGroupState]); + }, [repoPath, cwd, currentWorktreeSessions, worktreeGroupStates, setGroupState]); // Maintain global session IDs - include ALL sessions across all repos // This ensures terminals stay mounted when switching between repos diff --git a/src/renderer/components/layout/MainContent.tsx b/src/renderer/components/layout/MainContent.tsx index 974e89a0..314f5f1f 100644 --- a/src/renderer/components/layout/MainContent.tsx +++ b/src/renderer/components/layout/MainContent.tsx @@ -112,7 +112,7 @@ export function MainContent({ const activeIds = useAgentSessionsStore((s) => s.activeIds); const activeSessionId = useMemo(() => { if (!repoPath || !worktreePath) return null; - const key = normalizePath(worktreePath); + const key = `${normalizePath(repoPath)}::${normalizePath(worktreePath)}`; const activeId = activeIds[key]; if (activeId) { const session = sessions.find((s) => s.id === activeId); diff --git a/src/renderer/components/layout/RunningProjectsPopover.tsx b/src/renderer/components/layout/RunningProjectsPopover.tsx index 29be0337..f90e9a07 100644 --- a/src/renderer/components/layout/RunningProjectsPopover.tsx +++ b/src/renderer/components/layout/RunningProjectsPopover.tsx @@ -204,7 +204,7 @@ export function RunningProjectsPopover({ break; case 'agent': await onSelectWorktreeByPath(item.session.cwd); - setAgentActiveId(item.session.cwd, item.session.id); + setAgentActiveId(item.session.repoPath, item.session.cwd, item.session.id); onSwitchTab?.('chat'); break; case 'terminal': diff --git a/src/renderer/stores/agentSessions.ts b/src/renderer/stores/agentSessions.ts index f60743fc..73b5a33a 100644 --- a/src/renderer/stores/agentSessions.ts +++ b/src/renderer/stores/agentSessions.ts @@ -44,12 +44,21 @@ function isResumableAgent(agentCommand: string): boolean { return agentCommand?.startsWith('claude') ?? false; } +/** + * Composite key for activeIds: uniquely identifies a repo+worktree pair. + * Prevents cross-repo session pollution when different repos have worktrees + * with the same path name. + */ +function makeActiveKey(repoPath: string, cwd: string): string { + return `${normalizePath(repoPath)}::${normalizePath(cwd)}`; +} + // Group states indexed by normalized worktree path type WorktreeGroupStates = Record; interface AgentSessionsState { sessions: Session[]; - activeIds: Record; // key = cwd (worktree path) + activeIds: Record; // key = makeActiveKey(repoPath, cwd) groupStates: WorktreeGroupStates; // Group states per worktree (not persisted) runtimeStates: Record; // Runtime output states (not persisted) enhancedInputStates: Record; // Enhanced input states per session (not persisted) @@ -58,7 +67,7 @@ interface AgentSessionsState { addSession: (session: Session) => void; removeSession: (id: string) => void; updateSession: (id: string, updates: Partial) => void; - setActiveId: (cwd: string, sessionId: string | null) => void; + setActiveId: (repoPath: string, cwd: string, sessionId: string | null) => void; reorderSessions: (repoPath: string, cwd: string, fromIndex: number, toIndex: number) => void; getSessions: (repoPath: string, cwd: string) => Session[]; getActiveSessionId: (repoPath: string, cwd: string) => string | null; @@ -191,7 +200,10 @@ export const useAgentSessionsStore = create()( const newSession = { ...session, displayOrder: maxOrder + 1 }; return { sessions: [...state.sessions, newSession], - activeIds: { ...state.activeIds, [normalizePath(session.cwd)]: session.id }, + activeIds: { + ...state.activeIds, + [makeActiveKey(session.repoPath, session.cwd)]: session.id, + }, // Initialize enhanced input state for new session to ensure auto-popup works enhancedInputStates: { ...state.enhancedInputStates, @@ -241,9 +253,9 @@ export const useAgentSessionsStore = create()( sessions: state.sessions.map((s) => (s.id === id ? { ...s, ...updates } : s)), })), - setActiveId: (cwd, sessionId) => + setActiveId: (repoPath, cwd, sessionId) => set((state) => ({ - activeIds: { ...state.activeIds, [normalizePath(cwd)]: sessionId }, + activeIds: { ...state.activeIds, [makeActiveKey(repoPath, cwd)]: sessionId }, })), reorderSessions: (repoPath, cwd, fromIndex, toIndex) => @@ -289,15 +301,14 @@ export const useAgentSessionsStore = create()( getActiveSessionId: (repoPath, cwd) => { const state = get(); - const activeId = state.activeIds[normalizePath(cwd)]; + const key = makeActiveKey(repoPath, cwd); + const activeId = state.activeIds[key]; if (activeId) { - // Verify the session exists and matches repoPath const session = state.sessions.find((s) => s.id === activeId); - if (session && session.repoPath === repoPath) { + if (session) { return activeId; } } - // Fallback to first session for this repo+cwd const firstSession = state.sessions.find( (s) => s.repoPath === repoPath && pathsEqual(s.cwd, cwd) ); @@ -530,19 +541,37 @@ export const useAgentSessionsStore = create()( ); /** - * Selector hook: get active session ID for a given worktree path. - * Falls back to the first session under that cwd. + * Selector hook: get active session ID for a given repo+worktree pair. + * Falls back to the first session under that repo+cwd. + * + * @param cwd - The worktree path (required). + * @param repoPath - Optional repo path for cross-repo isolation. If omitted, falls back to matching by cwd only. */ -export function useActiveSessionId(cwd: string | undefined | null): string | null { +export function useActiveSessionId( + cwd: string | undefined | null, + repoPath?: string | undefined | null +): string | null { return useAgentSessionsStore((state) => { if (!cwd) return null; + if (repoPath) { + const key = makeActiveKey(repoPath, cwd); + const activeId = state.activeIds[key]; + if (activeId) { + const session = state.sessions.find((s) => s.id === activeId); + if (session) return activeId; + } + const first = state.sessions.find( + (s) => s.repoPath === repoPath && normalizePath(s.cwd) === normalizePath(cwd) + ); + return first?.id ?? null; + } + // Backward compatibility: match by cwd only const key = normalizePath(cwd); const activeId = state.activeIds[key]; if (activeId) { const session = state.sessions.find((s) => s.id === activeId); if (session) return activeId; } - // Fallback to first session for this cwd const first = state.sessions.find((s) => normalizePath(s.cwd) === key); return first?.id ?? null; });