diff --git a/src/components/BottomPanel.tsx b/src/components/BottomPanel.tsx index f1a750fc..5801bb1a 100644 --- a/src/components/BottomPanel.tsx +++ b/src/components/BottomPanel.tsx @@ -102,7 +102,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo return serverStore.onServerChange(() => { void restoreSessions(++restoreRequestIdRef.current) }) - }, [normalizedDirectory]) + }, [normalizedDirectory, serverId]) // 创建新终端 const handleNewTerminal = useCallback(async () => { @@ -120,7 +120,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo } catch (error) { uiErrorHandler('create terminal', error) } - }, [normalizedDirectory]) + }, [normalizedDirectory, serverId]) // 关闭终端 const handleCloseTerminal = useCallback( @@ -131,7 +131,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo // ignore - may already be closed } }, - [normalizedDirectory], + [normalizedDirectory, serverId], ) // 渲染内容 @@ -220,7 +220,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo ) }, - [isRestoring, handleNewTerminal, directory, sessionId, isPanelResizing, t], + [isRestoring, handleNewTerminal, directory, sessionId, isPanelResizing, t, serverId], ) return ( diff --git a/src/components/RightPanel.tsx b/src/components/RightPanel.tsx index bbaeddcf..eba6e63a 100644 --- a/src/components/RightPanel.tsx +++ b/src/components/RightPanel.tsx @@ -165,7 +165,7 @@ export const RightPanel = memo(function RightPanel({ ) }, - [normalizedDirectory, sessionId, isPanelResizing, t], + [normalizedDirectory, sessionId, isPanelResizing, t, serverId], ) if (inline) { diff --git a/src/components/SessionChangesPanel.tsx b/src/components/SessionChangesPanel.tsx index 2b99215e..592b17b1 100644 --- a/src/components/SessionChangesPanel.tsx +++ b/src/components/SessionChangesPanel.tsx @@ -351,7 +351,7 @@ export const SessionChangesPanel = memo(function SessionChangesPanel({ setProjectLoading(false) } } - }, [directory, sessionId, t]) + }, [directory, sessionId, t, serverId]) const loadDiffMode = useCallback( async (mode: ChangeMode, options?: { force?: boolean; project?: ApiProject | null }) => { @@ -396,7 +396,7 @@ export const SessionChangesPanel = memo(function SessionChangesPanel({ } } }, - [directory, loadedModes, project, sessionId, t], + [directory, loadedModes, project, sessionId, t, serverId], ) useEffect(() => { @@ -483,7 +483,7 @@ export const SessionChangesPanel = memo(function SessionChangesPanel({ } finally { setInitializingGit(false) } - }, [directory, loadProjectState, t]) + }, [directory, loadProjectState, t, serverId]) // 选中文件 const handleSelectFile = useCallback((file: string) => { diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index 66883fda..55453a17 100644 --- a/src/components/Terminal.tsx +++ b/src/components/Terminal.tsx @@ -812,13 +812,7 @@ export const Terminal = memo(function Terminal({ ptyId, directory, serverId, isA terminalRef.current = null fitAddonRef.current = null } - }, [ - ptyId, - hasBeenActive, - clearStickyModifiers, - sendTerminalData, - preferTouchUi, - ]) + }, [ptyId, hasBeenActive, clearStickyModifiers, sendTerminalData, preferTouchUi, serverId]) useEffect(() => { const container = containerRef.current diff --git a/src/features/chat/ChatPane.tsx b/src/features/chat/ChatPane.tsx index a74515b3..865b30f5 100644 --- a/src/features/chat/ChatPane.tsx +++ b/src/features/chat/ChatPane.tsx @@ -792,6 +792,8 @@ export const ChatPane = memo(function ChatPane({ const inlineToolRequestCtx = useMemo( () => ({ + // 子 session 请求匹配必须用 pane 绑定的服务器,而不是全局活动服务器(多服务器 / WSL 下两者不同) + serverId: paneServerId, pendingPermissions: pendingPermissionRequests, pendingQuestions: pendingQuestionRequests, onPermissionReply: (requestId, reply) => { @@ -803,6 +805,7 @@ export const ChatPane = memo(function ChatPane({ isReplying, }), [ + paneServerId, pendingPermissionRequests, pendingQuestionRequests, handlePermissionReply, diff --git a/src/features/chat/InlineToolRequestContext.test.tsx b/src/features/chat/InlineToolRequestContext.test.tsx new file mode 100644 index 00000000..9e3fc55f --- /dev/null +++ b/src/features/chat/InlineToolRequestContext.test.tsx @@ -0,0 +1,88 @@ +/** + * InlineToolRequestContext 契约测试 + * + * 核心契约:task 工具匹配子 session 的内嵌请求时,复合 key 必须用「pane 绑定的服务器」合成。 + * 工具 metadata 里的 sessionId 是原始 id,若用 splitSessionKey 猜服务器会回退到全局活动服务器, + * 而 childSessionStore 按真实服务器注册子 session —— 多服务器 / WSL 下孙 session 的请求 + * 永远关联不到 task 工具,内嵌权限 / 提问 UI 不出现。 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { findPermissionRequestForTool, findQuestionRequestForTool } from './InlineToolRequestContext' +import type { ApiPermissionRequest, ApiQuestionRequest } from '../../api' + +const { childSessionStoreMock } = vi.hoisted(() => { + // 仿真 childSessionStore 的存储语义:key 一律是复合 `${serverId}::${sessionId}`,按父子链递归查找 + const parentByKey = new Map() + const isChildOf = (sessionId: string, parentId: string): boolean => { + const parent = parentByKey.get(sessionId) + if (!parent) return false + return parent === parentId || isChildOf(parent, parentId) + } + return { + childSessionStoreMock: { + register(childKey: string, parentKey: string) { + parentByKey.set(childKey, parentKey) + }, + reset() { + parentByKey.clear() + }, + isChildOf, + }, + } +}) + +vi.mock('../../store', () => ({ + childSessionStore: childSessionStoreMock, +})) + +// 全局活动服务器固定为 local:与 pane 绑定的 wsl:Ubuntu 形成错位,逼出「猜服务器」的错误路径 +vi.mock('../../store/serverStore', () => ({ + serverStore: { + getActiveServerId: () => 'local', + }, +})) + +describe('task 工具按 pane 服务器匹配子 session 请求', () => { + const PANE_SERVER = 'wsl:Ubuntu' + + beforeEach(() => { + childSessionStoreMock.reset() + // task 的直接子 session 与孙 session 都注册在 pane 真实所属服务器下 + childSessionStoreMock.register(`${PANE_SERVER}::ses_child`, `${PANE_SERVER}::ses_parent`) + childSessionStoreMock.register(`${PANE_SERVER}::ses_grand`, `${PANE_SERVER}::ses_child`) + }) + + it('孙 session 发出的权限请求能匹配到 task 工具(全局活动服务器是 local,pane 绑定 wsl:Ubuntu)', () => { + const permission: ApiPermissionRequest = { + id: 'perm-1', + sessionID: `${PANE_SERVER}::ses_grand`, + permission: 'bash', + patterns: ['npm test'], + metadata: {}, + always: [], + } + + const matched = findPermissionRequestForTool([permission], 'call-unknown', { + sessionKey: 'ses_child', + serverId: PANE_SERVER, + }) + + expect(matched).toBe(permission) + }) + + it('孙 session 发出的提问请求同样按 pane 服务器匹配', () => { + const question: ApiQuestionRequest = { + id: 'ques-1', + sessionID: `${PANE_SERVER}::ses_grand`, + questions: [], + } + + const matched = findQuestionRequestForTool([question], 'call-unknown', { + sessionKey: 'ses_child', + serverId: PANE_SERVER, + }) + + expect(matched).toBe(question) + }) +}) diff --git a/src/features/chat/InlineToolRequestContext.tsx b/src/features/chat/InlineToolRequestContext.tsx index 30f40445..a51e9c4b 100644 --- a/src/features/chat/InlineToolRequestContext.tsx +++ b/src/features/chat/InlineToolRequestContext.tsx @@ -11,7 +11,19 @@ import type { ApiPermissionRequest, ApiQuestionRequest, PermissionReply, Questio import { childSessionStore } from '../../store' import { makeSessionKey, splitSessionKey } from '../../utils/sessionKey' +/** + * task 工具匹配子 session 请求时的定位信息。 + * sessionKey 取自工具 metadata,可能是原始 id;serverId 是 pane 绑定的服务器(权威值), + * 绝不能从「全局活动服务器」猜测——多服务器 / WSL 场景下两者不同,孙 session 会永远匹配不上。 + */ +export interface TaskChildSessionRef { + sessionKey: string + serverId: string +} + export interface InlineToolRequestContextValue { + /** 当前 pane 绑定的服务器,供 task 工具解析子 session 的服务器作用域 key */ + serverId: string /** 当前 pending 的权限请求 */ pendingPermissions: ApiPermissionRequest[] /** 当前 pending 的提问请求 */ @@ -27,6 +39,8 @@ export interface InlineToolRequestContextValue { } const defaultValue: InlineToolRequestContextValue = { + // 没有 Provider 就没有 pane 绑定,空串表示「无权威服务器」,不做任何猜测 + serverId: '', pendingPermissions: [], pendingQuestions: [], onPermissionReply: () => {}, @@ -43,26 +57,30 @@ export function useInlineToolRequests() { /** * 根据 callID 查找关联的权限请求。 - * 对于 task tool,额外传入 childSessionId, + * 对于 task tool,额外传入 child(子 session key + pane 绑定的权威服务器), * 匹配子 session(及其子孙)内部发出的权限请求。 */ export function findPermissionRequestForTool( pendingPermissions: ApiPermissionRequest[], callID: string, - childSessionId?: string, + child?: TaskChildSessionRef, ): ApiPermissionRequest | undefined { // 先按 callID 精确匹配(直接工具调用) const direct = pendingPermissions.find(p => p.tool?.callID === callID) if (direct) return direct // 对 task tool,按子 session 归属匹配 - if (childSessionId) { - // 消息 metadata 里的 sessionId 是原始 id,pending 请求的 sessionID 可能是复合 key(SSE) - // 或原始 id(轮询):统一按原始 id 比较,isChildOf 需要复合 key(childSessionStore 存复合) - const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childSessionId) - const childScoped = childSessionId.includes('::') ? childSessionId : makeSessionKey(childServerId, childRawId) + if (child) { + // 复合 key 以调用方传入的权威 serverId 合成:对原始 id 做 splitSessionKey 会回退到 + // 全局活动服务器,pane 绑定其他服务器时(多服务器 / WSL)孙 session 永远匹配不上 + const childScoped = child.sessionKey.includes('::') + ? child.sessionKey + : makeSessionKey(child.serverId, child.sessionKey) + const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childScoped) const isMatch = (sid: string) => { const { sessionId: raw } = splitSessionKey(sid) + // 消息 metadata 里的 sessionId 是原始 id,pending 请求的 sessionID 可能是复合 key(SSE) + // 或原始 id(轮询):统一按原始 id 比较,isChildOf 需要复合 key(childSessionStore 存复合) if (raw === childRawId) return true const scoped = sid.includes('::') ? sid : makeSessionKey(childServerId, raw) return childSessionStore.isChildOf(scoped, childScoped) @@ -75,19 +93,21 @@ export function findPermissionRequestForTool( /** * 根据 callID 查找关联的提问请求。 - * 对于 task tool,额外传入 childSessionId。 + * 对于 task tool,额外传入 child(子 session key + pane 绑定的权威服务器)。 */ export function findQuestionRequestForTool( pendingQuestions: ApiQuestionRequest[], callID: string, - childSessionId?: string, + child?: TaskChildSessionRef, ): ApiQuestionRequest | undefined { const direct = pendingQuestions.find(q => q.tool?.callID === callID) if (direct) return direct - if (childSessionId) { - const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childSessionId) - const childScoped = childSessionId.includes('::') ? childSessionId : makeSessionKey(childServerId, childRawId) + if (child) { + const childScoped = child.sessionKey.includes('::') + ? child.sessionKey + : makeSessionKey(child.serverId, child.sessionKey) + const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childScoped) const isMatch = (sid: string) => { const { sessionId: raw } = splitSessionKey(sid) if (raw === childRawId) return true diff --git a/src/features/chat/ProjectDialog.tsx b/src/features/chat/ProjectDialog.tsx index eea81acd..3e2d09b2 100644 --- a/src/features/chat/ProjectDialog.tsx +++ b/src/features/chat/ProjectDialog.tsx @@ -128,7 +128,7 @@ export function ProjectDialog({ isOpen, onClose, onSelect, initialPath = '', ser cancelled = true clearTimeout(timer) } - }, [isOpen, initialPath]) + }, [isOpen, initialPath, serverId]) // ========================================== // Load Directory @@ -187,7 +187,7 @@ export function ProjectDialog({ isOpen, onClose, onSelect, initialPath = '', ser cancelled = true clearTimeout(timer) } - }, [isOpen, currentDir]) + }, [isOpen, currentDir, serverId]) // ========================================== // Scroll to Selection diff --git a/src/features/chat/sidebar/SessionChildrenSlot.tsx b/src/features/chat/sidebar/SessionChildrenSlot.tsx index 6b51c73e..bbf85d6e 100644 --- a/src/features/chat/sidebar/SessionChildrenSlot.tsx +++ b/src/features/chat/sidebar/SessionChildrenSlot.tsx @@ -73,7 +73,7 @@ export function SessionChildrenSlot({ cancelled = true cancelAnimationFrame(loadingFrameId) } - }, [fetchAll, parentSession.id, parentSession.directory]) + }, [fetchAll, parentSession.id, parentSession.directory, serverId]) const handleRename = useCallback(async (childId: string, newTitle: string) => { try { @@ -83,7 +83,7 @@ export function SessionChildrenSlot({ } catch (e) { uiErrorHandler('rename session', e) } - }, []) + }, [parentSession.directory, serverId]) const handleDeleteConfirmed = useCallback(async () => { const id = deleteConfirm.sessionId @@ -99,7 +99,7 @@ export function SessionChildrenSlot({ } catch (e) { uiErrorHandler('delete session', e) } - }, [deleteConfirm.sessionId, selectedSessionId, onDeleteSelected]) + }, [deleteConfirm.sessionId, selectedSessionId, onDeleteSelected, parentSession.directory, serverId]) const list = fetchAll ? fetched : givenChildren diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index 64442a95..8db35c9d 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -13,6 +13,7 @@ import { useInlineToolRequests, findPermissionRequestForTool, findQuestionRequestForTool, + type TaskChildSessionRef, } from '../chat/InlineToolRequestContext' import { TextPartView, @@ -967,14 +968,14 @@ const ToolGroup = memo(function ToolGroup({ }: ToolGroupProps) { const { t } = useTranslation('message') const { descriptiveToolSteps, inlineToolRequests, immersiveMode, processCollapseEnabled } = useTheme() - const { pendingPermissions, pendingQuestions } = useInlineToolRequests() + const { serverId, pendingPermissions, pendingQuestions } = useInlineToolRequests() const hasPendingInteraction = inlineToolRequests && parts.some(part => { - const childSessionId = getTaskChildSessionId(part) + const childSession = getTaskChildSessionRef(part, serverId) return ( - findPermissionRequestForTool(pendingPermissions, part.callID, childSessionId) || - findQuestionRequestForTool(pendingQuestions, part.callID, childSessionId) + findPermissionRequestForTool(pendingPermissions, part.callID, childSession) || + findQuestionRequestForTool(pendingQuestions, part.callID, childSession) ) }) @@ -1367,10 +1368,12 @@ function isToolPartActive(part: ToolPart): boolean { return part.state.status === 'running' || part.state.status === 'pending' } -function getTaskChildSessionId(part: ToolPart): string | undefined { +/** task 工具派出的子 session:metadata 里是原始 id,服务器以 pane 绑定的 serverId 为权威 */ +function getTaskChildSessionRef(part: ToolPart, serverId: string): TaskChildSessionRef | undefined { if (part.tool.toLowerCase() !== 'task') return undefined const metadata = part.state.metadata as Record | undefined - return metadata?.sessionId as string | undefined + const sessionId = metadata?.sessionId as string | undefined + return sessionId ? { sessionKey: sessionId, serverId } : undefined } /** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */ diff --git a/src/features/message/parts/ToolPartView.test.tsx b/src/features/message/parts/ToolPartView.test.tsx index ef0ceb8d..0f2c09dd 100644 --- a/src/features/message/parts/ToolPartView.test.tsx +++ b/src/features/message/parts/ToolPartView.test.tsx @@ -48,6 +48,7 @@ vi.mock('../../../store/serverStore', () => ({ vi.mock('../../chat/InlineToolRequestContext', () => ({ useInlineToolRequests: () => ({ + serverId: 'local', pendingPermissions: [], pendingQuestions: [], onPermissionReply: vi.fn(), diff --git a/src/features/message/parts/ToolPartView.tsx b/src/features/message/parts/ToolPartView.tsx index 2a62a559..cff276a9 100644 --- a/src/features/message/parts/ToolPartView.tsx +++ b/src/features/message/parts/ToolPartView.tsx @@ -13,6 +13,7 @@ import { useInlineToolRequests, findPermissionRequestForTool, findQuestionRequestForTool, + type TaskChildSessionRef, } from '../../chat/InlineToolRequestContext' import { InlinePermission } from '../../chat/InlinePermission' import { InlineQuestion } from '../../chat/InlineQuestion' @@ -67,14 +68,21 @@ export const ToolPartView = memo(function ToolPartView({ const duration = rawDuration !== undefined && isActive ? Math.max(0, rawDuration) : rawDuration const { inlineToolRequests, immersiveMode, compactInlinePermission } = useTheme() - const { pendingPermissions, pendingQuestions, onPermissionReply, onQuestionReply, onQuestionReject, isReplying } = - useInlineToolRequests() - const childSessionId = getTaskChildSessionId(part) + const { + serverId, + pendingPermissions, + pendingQuestions, + onPermissionReply, + onQuestionReply, + onQuestionReject, + isReplying, + } = useInlineToolRequests() + const childSession = getTaskChildSessionRef(part, serverId) const permissionRequest = inlineToolRequests - ? findPermissionRequestForTool(pendingPermissions, part.callID, childSessionId) + ? findPermissionRequestForTool(pendingPermissions, part.callID, childSession) : undefined const questionRequest = inlineToolRequests - ? findQuestionRequestForTool(pendingQuestions, part.callID, childSessionId) + ? findQuestionRequestForTool(pendingQuestions, part.callID, childSession) : undefined const toolDone = state.status === 'completed' || state.status === 'error' @@ -543,10 +551,12 @@ const ToolBody = memo(function ToolBody({ return }) -function getTaskChildSessionId(part: ToolPart): string | undefined { +/** task 工具派出的子 session:metadata 里是原始 id,服务器以 pane 绑定的 serverId 为权威 */ +function getTaskChildSessionRef(part: ToolPart, serverId: string): TaskChildSessionRef | undefined { if (part.tool.toLowerCase() !== 'task') return undefined const metadata = part.state.metadata as Record | undefined - return metadata?.sessionId as string | undefined + const sessionId = metadata?.sessionId as string | undefined + return sessionId ? { sessionKey: sessionId, serverId } : undefined } /** Extract description from tool input as title fallback (available while running) */ diff --git a/src/features/settings/components/ConfigSettings.search.test.tsx b/src/features/settings/components/ConfigSettings.search.test.tsx index f8857a4b..0c21c9af 100644 --- a/src/features/settings/components/ConfigSettings.search.test.tsx +++ b/src/features/settings/components/ConfigSettings.search.test.tsx @@ -49,5 +49,7 @@ describe('ConfigSettings search', () => { const field = await screen.findByDisplayValue('https://gateway.example.com') await waitFor(() => expect(field.closest('[data-config-field]')).toHaveClass('settings-search-highlight')) expect(field).toHaveFocus() - }) + // 单跑 0.5s 就过;但本文件要挂载完整 ConfigSettings + 配置编辑器弹窗(jsdom 冷启动最重的路径之一), + // 全量并发下会超出 5s 默认值误报。给足时间,避免「新增任何测试文件就把它压崩」。 + }, 20000) }) diff --git a/src/hooks/useChatSession.ts b/src/hooks/useChatSession.ts index 06df67aa..d2c01ebe 100644 --- a/src/hooks/useChatSession.ts +++ b/src/hooks/useChatSession.ts @@ -136,7 +136,12 @@ export function useChatSession({ routeSessionIdRef.current = routeSessionId }, [routeSessionId]) - /** 当前 pane 绑定的服务器(sessionId 为复合 key,split 出 serverId;home 状态跟随 active server) */ + /** + * 当前 pane 绑定的服务器(sessionId 为复合 key,split 出 serverId;home 状态跟随 active server)。 + * 约定:凡发起服务器作用域请求的 memo/effect/回调都必须声明 paneServerId—— + * 它的值会在 pane 生命周期内变化(切会话、活动服务器切换、WSL sidecar 就绪后切回), + * 漏声明就会把请求打到旧服务器。被 routeSessionId 守卫的单元除外:此时 paneServerId 是它的纯函数。 + */ const activeServerId = useSyncExternalStore( cb => serverStore.subscribe(cb), () => serverStore.getActiveServerId(), @@ -490,6 +495,7 @@ export function useChatSession({ // eslint-disable-next-line react-hooks/exhaustive-deps -- refs and stable functions [ paneId, + paneServerId, effectiveDirectory, routeSessionId, sessionFamily, @@ -542,7 +548,7 @@ export function useChatSession({ getSelectableAgents(currentDirectory, paneServerId) .then(setAgents) .catch(err => handleError('fetch agents', err)) - }, [currentDirectory]) + }, [currentDirectory, paneServerId]) // Preload @ root directory and / commands for current session directory useEffect(() => { @@ -550,7 +556,7 @@ export function useChatSession({ prefetchRootDirectory(effectiveDirectory, paneServerId).catch(() => {}) prefetchCommands(effectiveDirectory, paneServerId).catch(() => {}) - }, [routeSessionId, effectiveDirectory]) + }, [routeSessionId, effectiveDirectory, paneServerId]) // agents 列表加载后,校验当前选中的 agent 是否存在于列表中 useEffect(() => { @@ -645,6 +651,7 @@ export function useChatSession({ }, [ routeSessionId, effectiveDirectory, + paneServerId, resetPendingRequests, setPendingPermissionRequests, setPendingQuestionRequests, @@ -750,7 +757,7 @@ export function useChatSession({ return false } }, - [routeSessionId, navigateToSession, createSession], + [routeSessionId, navigateToSession, createSession, paneServerId], ) // Send message handler @@ -960,7 +967,7 @@ export function useChatSession({ handleError('fork session', error) } }, - [effectiveDirectory, navigateToSession], + [effectiveDirectory, navigateToSession, paneServerId], ) // Abort handler @@ -973,7 +980,7 @@ export function useChatSession({ } catch (error) { handleError('abort session', error) } - }, [routeSessionId, sessionDirectory, currentDirectory]) + }, [routeSessionId, sessionDirectory, currentDirectory, paneServerId]) // Command handler (slash commands) const handleCommand = useCallback( @@ -1043,7 +1050,16 @@ export function useChatSession({ return false } }, - [routeSessionId, effectiveDirectory, createSession, navigateToSession, currentModel, navigateHome, handleNewChat], + [ + routeSessionId, + effectiveDirectory, + createSession, + navigateToSession, + currentModel, + navigateHome, + handleNewChat, + paneServerId, + ], ) // Undo with animation @@ -1091,7 +1107,7 @@ export function useChatSession({ } catch (error) { handleError('archive session', error) } - }, [routeSessionId, effectiveDirectory, navigateHome, handleNewChat]) + }, [routeSessionId, effectiveDirectory, navigateHome, handleNewChat, paneServerId]) // Navigate to previous session const handlePreviousSession = useCallback(() => { diff --git a/src/hooks/useFileExplorer.ts b/src/hooks/useFileExplorer.ts index 60b880d3..820bd288 100644 --- a/src/hooks/useFileExplorer.ts +++ b/src/hooks/useFileExplorer.ts @@ -121,7 +121,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx setIsLoading(false) } } - }, [effectiveDirectory, t]) + }, [effectiveDirectory, serverId, t]) const loadStatuses = useCallback(async () => { if (!effectiveDirectory) { @@ -169,7 +169,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx if (loadId !== statusLoadIdRef.current) return setFileStatus(new Map()) } - }, [changeMode, effectiveDirectory, sessionId]) + }, [changeMode, effectiveDirectory, sessionId, serverId]) // 加载子目录 const loadChildren = useCallback( @@ -217,7 +217,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx ) } }, - [effectiveDirectory], + [effectiveDirectory, serverId], ) const updateExpandedPaths = useCallback( @@ -311,7 +311,7 @@ export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileEx } } }, - [effectiveDirectory, t], + [effectiveDirectory, t, serverId], ) const clearPreview = useCallback(() => { diff --git a/src/hooks/usePermissionHandler.test.tsx b/src/hooks/usePermissionHandler.test.tsx index f3cf9cd7..fbcbc1ec 100644 --- a/src/hooks/usePermissionHandler.test.tsx +++ b/src/hooks/usePermissionHandler.test.tsx @@ -2,18 +2,21 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { usePermissionHandler } from './usePermissionHandler' -const { replyPermissionMock, getPendingPermissionsMock, activeSessionStoreMock } = vi.hoisted(() => ({ - replyPermissionMock: vi.fn(() => Promise.resolve(true)), - getPendingPermissionsMock: vi.fn(() => Promise.resolve([])), - activeSessionStoreMock: { - resolvePendingRequest: vi.fn(), - }, -})) +const { replyPermissionMock, getPendingPermissionsMock, replyQuestionMock, rejectQuestionMock, activeSessionStoreMock } = + vi.hoisted(() => ({ + replyPermissionMock: vi.fn(() => Promise.resolve(true)), + getPendingPermissionsMock: vi.fn(() => Promise.resolve([])), + replyQuestionMock: vi.fn((..._args: unknown[]) => Promise.resolve(true)), + rejectQuestionMock: vi.fn((..._args: unknown[]) => Promise.resolve(true)), + activeSessionStoreMock: { + resolvePendingRequest: vi.fn(), + }, + })) vi.mock('../api', () => ({ replyPermission: replyPermissionMock, - replyQuestion: vi.fn(() => Promise.resolve(true)), - rejectQuestion: vi.fn(() => Promise.resolve(true)), + replyQuestion: replyQuestionMock, + rejectQuestion: rejectQuestionMock, getPendingPermissions: getPendingPermissionsMock, getPendingQuestions: vi.fn(() => Promise.resolve([])), })) @@ -90,4 +93,24 @@ describe('usePermissionHandler', () => { expect(result.current.pendingPermissionRequests).toEqual([]) expect(activeSessionStoreMock.resolvePendingRequest).toHaveBeenCalledWith('perm-stale') }) + + // 核心契约:请求必须打到「当前」绑定的服务器。 + // pane 首次渲染时活动服务器可能是 local,之后切到别的服务器(多服务器 / WSL sidecar 就绪后切回), + // 一旦回调把 serverId 冻在旧值上,回复就会发到旧服务器:旧服务器报错、真实服务器仍 pending、 + // 弹窗消失后又冒出来,对话永远不前进。 + it('routes replies to the server the pane is bound to now, not the one captured at mount', async () => { + const { result, rerender } = renderHook(({ serverId }) => usePermissionHandler(serverId), { + initialProps: { serverId: 'local' }, + }) + + rerender({ serverId: 'wsl:Ubuntu' }) + + await act(async () => { + await result.current.handleQuestionReply('question-1', [['A']], '/home/u/project') + await result.current.handleQuestionReject('question-2', '/home/u/project') + }) + + expect(replyQuestionMock).toHaveBeenCalledWith('question-1', [['A']], '/home/u/project', 'wsl:Ubuntu') + expect(rejectQuestionMock).toHaveBeenCalledWith('question-2', '/home/u/project', 'wsl:Ubuntu') + }) }) diff --git a/src/hooks/usePermissionHandler.ts b/src/hooks/usePermissionHandler.ts index fc3df851..aed946f2 100644 --- a/src/hooks/usePermissionHandler.ts +++ b/src/hooks/usePermissionHandler.ts @@ -86,6 +86,10 @@ export function usePermissionHandler(serverId: string): UsePermissionHandlerResu // 防止重复回复 const replyingIdsRef = useRef>(new Set()) + // serverId 是本 hook 的作用域:所有发起请求的回调都必须把它列入依赖。 + // pane 的服务器绑定会变(切会话、多服务器、WSL sidecar 就绪后切回), + // 空依赖数组会把 serverId 冻在首次渲染的值上,回复被发到旧服务器: + // 旧服务器报错 → 请求在真实服务器上仍 pending → 弹窗消失后又冒出来,对话不前进。 const handlePermissionReply = useCallback( async (requestId: string, reply: PermissionReply, directory?: string, sessionId?: string): Promise => { // 防止重复回复 @@ -122,7 +126,7 @@ export function usePermissionHandler(serverId: string): UsePermissionHandlerResu setIsReplying(false) } }, - [], + [serverId], ) const handleQuestionReply = useCallback( @@ -150,7 +154,7 @@ export function usePermissionHandler(serverId: string): UsePermissionHandlerResu setIsReplying(false) } }, - [], + [serverId], ) const handleQuestionReject = useCallback(async (requestId: string, directory?: string): Promise => { @@ -175,7 +179,7 @@ export function usePermissionHandler(serverId: string): UsePermissionHandlerResu replyingIdsRef.current.delete(requestId) setIsReplying(false) } - }, []) + }, [serverId]) // 主动轮询获取 pending 请求(用于 SSE 可能丢失事件的情况) // 一次拉取全量数据,用 sessionFamily 过滤后直接替换本地状态