diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index 7a3d349..abff6a9 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -46,6 +46,11 @@ describe('AISettingsModal edit password behavior', () => { it('wires the external MCP client install panel actions back to the modal handlers', () => { expect(source).toContain('mcpClientStatuses={mcpClientStatuses}'); expect(source).toContain('selectedMCPClient={selectedMCPClient}'); + expect(source).toContain('const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false);'); + expect(source).toContain('const handleSelectMCPClient = useCallback((client: MCPClientKey) => {'); + expect(source).toContain('pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)'); + expect(source).toContain('setMCPClientSelectionTouched(true);'); + expect(source).toContain('onSelectClient={handleSelectMCPClient}'); expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}'); expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}'); expect(source).toContain('onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}'); diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 6f8f07d..cbe8b69 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -112,14 +112,14 @@ const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [ displayName: 'Claude Code', installed: false, matchesCurrent: false, - message: '未安装到 Claude Code 用户级配置', + message: '未检测到 Claude Code 用户级 GoNavi MCP 配置', }, { client: 'codex', displayName: 'Codex', installed: false, matchesCurrent: false, - message: '未安装到 Codex 用户级配置', + message: '未检测到 Codex 用户级 GoNavi MCP 配置', }, ]; @@ -219,6 +219,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const [mcpTools, setMCPTools] = useState([]); const [mcpClientStatuses, setMCPClientStatuses] = useState(EMPTY_MCP_CLIENT_STATUSES); const [selectedMCPClient, setSelectedMCPClient] = useState('claude-code'); + const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false); const [mcpClientStatusLoading, setMCPClientStatusLoading] = useState(false); const [skills, setSkills] = useState([]); const [editingProvider, setEditingProvider] = useState(null); @@ -263,6 +264,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo () => formatMCPLaunchCommand(selectedMCPClientStatus), [selectedMCPClientStatus], ); + const handleSelectMCPClient = useCallback((client: MCPClientKey) => { + setMCPClientSelectionTouched(true); + setSelectedMCPClient(client); + }, []); const resolveAIService = useCallback(async () => { const service = await waitForAIService(); @@ -291,7 +296,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo if (Array.isArray(result)) { const normalizedStatuses = normalizeMCPClientStatuses(result); setMCPClientStatuses(normalizedStatuses); - setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, prev)); + setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)); } } catch (e: any) { if (silent) { @@ -304,7 +309,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo setMCPClientStatusLoading(false); } } - }, [messageApi, resolveAIService]); + }, [mcpClientSelectionTouched, messageApi, resolveAIService]); const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => { if (typeof navigator?.clipboard?.writeText !== 'function') { @@ -362,13 +367,19 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo if (Array.isArray(mcpClientStatusesRes)) { const normalizedStatuses = normalizeMCPClientStatuses(mcpClientStatusesRes); setMCPClientStatuses(normalizedStatuses); - setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, prev)); + setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)); } } catch (e) { console.warn('Failed to load AI config', e); } - }, [resolveAIService]); + }, [mcpClientSelectionTouched, resolveAIService]); useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]); + useEffect(() => { + if (open) { + setMCPClientSelectionTouched(false); + } + }, [open]); + useEffect(() => { if (!open || !focusProviderId) { return; @@ -641,6 +652,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo } try { setLoading(true); + setMCPClientSelectionTouched(true); const Service = await resolveAIService(); let result: MCPClientInstallResult; if (targetClient === 'codex') { @@ -904,7 +916,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo inputBg={inputBg} loading={loading} mcpClientStatusLoading={mcpClientStatusLoading} - onSelectClient={setSelectedMCPClient} + onSelectClient={handleSelectMCPClient} onRefreshStatus={() => void loadMCPClientStatuses()} onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()} onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()} diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx index a889754..8a986f7 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx @@ -15,14 +15,14 @@ describe('AIMCPClientInstallPanel', () => { displayName: 'Claude Code', installed: false, matchesCurrent: false, - message: '未安装到 Claude Code 用户级配置', + message: '未检测到 Claude Code 用户级 GoNavi MCP 配置', }, { client: 'codex', displayName: 'Codex', installed: true, matchesCurrent: false, - message: '检测到旧的 Codex 配置,建议更新', + message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新', configPath: '~/.codex/config.toml', command: 'gonavi-mcp-server', args: ['stdio'], @@ -34,7 +34,7 @@ describe('AIMCPClientInstallPanel', () => { displayName: 'Codex', installed: true, matchesCurrent: false, - message: '检测到旧的 Codex 配置,建议更新', + message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新', configPath: '~/.codex/config.toml', command: 'gonavi-mcp-server', args: ['stdio'], @@ -54,14 +54,54 @@ describe('AIMCPClientInstallPanel', () => { />, ); - expect(markup).toContain('不是给 GoNavi 自己安装 MCP'); - expect(markup).toContain('安装到外部 AI 客户端'); - expect(markup).toContain('第 1 步:选择安装目标'); - expect(markup).toContain('第 2 步:确认当前状态并安装'); - expect(markup).toContain('待安装'); + expect(markup).toContain('不是给 GoNavi 自己再装一个 MCP'); + expect(markup).toContain('把 GoNavi MCP 接入外部 AI 客户端'); + expect(markup).toContain('第 1 步:选择目标客户端'); + expect(markup).toContain('第 2 步:确认状态后写入'); + expect(markup).toContain('未接入'); expect(markup).toContain('需更新'); expect(markup).toContain('复制配置路径'); expect(markup).toContain('复制启动命令'); expect(markup).toContain('更新 Codex 配置'); + expect(markup).toContain('不会下载 Claude Code / Codex'); + }); + + it('shows an already-connected label when the selected client matches the current GoNavi path', () => { + const markup = renderToStaticMarkup( + {}} + onRefreshStatus={() => {}} + onCopyConfigPath={() => {}} + onCopyLaunchCommand={() => {}} + onInstall={() => {}} + />, + ); + + expect(markup).toContain('已接入'); + expect(markup).toContain('已接入 Claude Code'); }); }); diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx index cb81efd..30d9fcc 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx @@ -29,7 +29,7 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b const messageText = String(status?.message || ''); if (status?.matchesCurrent) { return { - label: '已安装', + label: '已接入', color: '#16a34a', bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)', }; @@ -49,24 +49,25 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b }; } return { - label: '待安装', + label: '未接入', color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b', bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)', }; }; const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => { + const label = status?.displayName || '这个客户端'; const messageText = String(status?.message || ''); if (status?.matchesCurrent) { - return '这个客户端已经安装当前 GoNavi MCP,不需要再装一遍。'; + return `${label} 已经写入当前 GoNavi 路径,可直接把 GoNavi 当作 MCP Server 使用。`; } if (status?.installed) { - return '这个客户端已经有旧配置,更新后会改成当前 GoNavi 安装路径。'; + return `${label} 已检测到旧的 GoNavi 路径,更新后会改成当前这份 GoNavi。`; } if (messageText.includes('失败') || messageText.includes('异常')) { - return '状态读取异常,建议先刷新,再决定是否安装。'; + return '状态读取异常,建议先刷新,再决定是否写入。'; } - return '这个客户端还没有安装 GoNavi MCP。'; + return `${label} 还没有写入 GoNavi MCP 配置。`; }; const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) => { @@ -82,12 +83,12 @@ const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => { const label = status?.displayName || '客户端'; if (status?.matchesCurrent) { - return `已安装到 ${label}`; + return `已接入 ${label}`; } if (status?.installed) { return `更新 ${label} 配置`; } - return `安装到 ${label}`; + return `写入 ${label} 配置`; }; const AIMCPClientInstallPanel: React.FC = ({ @@ -109,7 +110,7 @@ const AIMCPClientInstallPanel: React.FC = ({ }) => (
- 这里不是给 GoNavi 自己安装 MCP,而是把 GoNavi 作为 MCP Server 安装到 Claude Code、Codex 这类外部 AI 客户端里使用。 + 这里的“安装”不是给 GoNavi 自己再装一个 MCP,而是把 GoNavi 暴露成 MCP Server,写入 Claude Code、Codex 这类外部 AI 客户端里使用。
= ({ }} >
-
安装到外部 AI 客户端
+
把 GoNavi MCP 接入外部 AI 客户端
- 先选 1 个要安装到的目标客户端,GoNavi 会自动把当前安装路径写入它的用户级 MCP 配置文件,不需要你自己找本机 exe,也不用手改配置。 + 先选 1 个要接入的目标客户端,GoNavi 会自动把当前安装路径写入它的用户级 MCP 配置文件,不需要你自己找本机 exe,也不用手改配置。
@@ -141,14 +142,14 @@ const AIMCPClientInstallPanel: React.FC = ({ lineHeight: 1.7, }} > - 只会修改你选中的外部客户端用户级 MCP 配置,不会下载新的 GoNavi,也不会替换 GoNavi 自己的程序文件。 + 只会修改你选中的外部客户端用户级 MCP 配置,不会下载 Claude Code / Codex,也不会重新安装 GoNavi 自己的程序文件。
-
第 1 步:选择安装目标
+
第 1 步:选择目标客户端
- 每次只安装到一个外部客户端,避免重复写入。 + 每次只处理一个外部客户端,先看状态,再决定是否写入。
@@ -171,6 +172,7 @@ const AIMCPClientInstallPanel: React.FC = ({ flexDirection: 'column', gap: 10, textAlign: 'left', + minHeight: 118, transition: 'all 0.2s ease', opacity: statusLoading ? 0.72 : 1, }} @@ -234,7 +236,7 @@ const AIMCPClientInstallPanel: React.FC = ({ >
- 第 2 步:确认当前状态并安装 + 第 2 步:确认状态后写入
@@ -305,7 +307,7 @@ const AIMCPClientInstallPanel: React.FC = ({
- 写入后重启对应客户端即可生效;如果已经是当前路径,按钮会自动禁用,避免重复安装。 + 写入后重启对应客户端即可生效;如果当前路径已经一致,按钮会自动禁用,避免重复写入。