From 928be2a4ff11b71b8356119fceb124f2f3dbb875 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Fri, 24 Apr 2026 23:05:45 +0800 Subject: [PATCH] fix(desktop): remove install center --- .../src/__tests__/generalSettings.test.tsx | 6 +- desktop/src/api/client.ts | 6 +- desktop/src/api/plugins.ts | 6 +- desktop/src/api/skills.ts | 7 +- .../settings/InstallCenter.test.tsx | 235 ------- .../src/components/settings/InstallCenter.tsx | 590 ------------------ desktop/src/i18n/locales/en.ts | 46 -- desktop/src/i18n/locales/zh.ts | 46 -- desktop/src/lib/installAssistantPrompt.ts | 44 -- desktop/src/pages/Settings.tsx | 18 +- desktop/src/stores/uiStore.ts | 1 - 11 files changed, 32 insertions(+), 973 deletions(-) delete mode 100644 desktop/src/components/settings/InstallCenter.test.tsx delete mode 100644 desktop/src/components/settings/InstallCenter.tsx delete mode 100644 desktop/src/lib/installAssistantPrompt.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index d426f98f..6b3d031c 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -132,13 +132,13 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false) }) - it('keeps install and extension tabs available after removing the embedded terminal tab', () => { + it('keeps extension tabs available alongside the terminal tab', () => { render() - expect(screen.getByText('Install')).toBeInTheDocument() + expect(screen.queryByText('Install')).not.toBeInTheDocument() + expect(screen.getByText('Terminal')).toBeInTheDocument() expect(screen.getByText('MCP')).toBeInTheDocument() expect(screen.getByText('Plugins')).toBeInTheDocument() - expect(screen.queryByText('Terminal')).not.toBeInTheDocument() }) }) diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 3e6ba7fb..883d14c7 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -50,7 +50,8 @@ async function request(method: string, path: string, body?: unknown, options? } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), options?.timeout ?? 30_000) + const timeoutMs = options?.timeout ?? 30_000 + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(url, { method, @@ -69,6 +70,9 @@ async function request(method: string, path: string, body?: unknown, options? return res.json() as Promise } catch (err) { clearTimeout(timeout) + if (controller.signal.aborted) { + throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) + } throw err } } diff --git a/desktop/src/api/plugins.ts b/desktop/src/api/plugins.ts index 11452b77..56bddc8b 100644 --- a/desktop/src/api/plugins.ts +++ b/desktop/src/api/plugins.ts @@ -38,6 +38,10 @@ export const pluginsApi = { reload: (cwd?: string) => { const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' - return api.post<{ ok: true; summary: PluginReloadSummary }>(`/api/plugins/reload${query}`) + return api.post<{ ok: true; summary: PluginReloadSummary }>( + `/api/plugins/reload${query}`, + undefined, + { timeout: 120_000 }, + ) }, } diff --git a/desktop/src/api/skills.ts b/desktop/src/api/skills.ts index 18b0c878..c0816997 100644 --- a/desktop/src/api/skills.ts +++ b/desktop/src/api/skills.ts @@ -4,7 +4,7 @@ import type { SkillMeta, SkillDetail } from '../types/skill' export const skillsApi = { list: (cwd?: string) => { const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' - return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`) + return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 }) }, detail: (source: string, name: string, cwd?: string) => { @@ -14,6 +14,9 @@ export const skillsApi = { }) if (cwd) query.set('cwd', cwd) - return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`) + return api.get<{ detail: SkillDetail }>( + `/api/skills/detail?${query.toString()}`, + { timeout: 120_000 }, + ) }, } diff --git a/desktop/src/components/settings/InstallCenter.test.tsx b/desktop/src/components/settings/InstallCenter.test.tsx deleted file mode 100644 index 3234c205..00000000 --- a/desktop/src/components/settings/InstallCenter.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' - -import type { SessionListItem } from '../../types/session' - -const sessionStoreState: { - sessions: SessionListItem[] - fetchSessions: ReturnType -} = { - sessions: [], - fetchSessions: vi.fn(), -} - -const chatStoreState: { - sessions: Record - connectToSession: ReturnType - disconnectSession: ReturnType - sendMessage: ReturnType - stopGeneration: ReturnType -} = { - sessions: {}, - connectToSession: vi.fn(), - disconnectSession: vi.fn(), - sendMessage: vi.fn(), - stopGeneration: vi.fn(), -} - -const pluginStoreState = { - fetchPlugins: vi.fn(), - reloadPlugins: vi.fn(), -} - -const skillStoreState = { - fetchSkills: vi.fn(), -} - -const mcpStoreState = { - fetchServers: vi.fn(), -} - -const uiStoreState = { - addToast: vi.fn(), - setPendingSettingsTab: vi.fn(), -} - -const { settingsApiState } = vi.hoisted(() => ({ - settingsApiState: { - getCliLauncherStatus: vi.fn(), - }, -})) - -vi.mock('../../stores/sessionStore', () => ({ - useSessionStore: (selector: (state: typeof sessionStoreState) => unknown) => - selector(sessionStoreState), -})) - -vi.mock('../../stores/chatStore', () => ({ - useChatStore: (selector: (state: typeof chatStoreState) => unknown) => - selector(chatStoreState), -})) - -vi.mock('../../stores/pluginStore', () => ({ - usePluginStore: (selector: (state: typeof pluginStoreState) => unknown) => - selector(pluginStoreState), -})) - -vi.mock('../../stores/skillStore', () => ({ - useSkillStore: (selector: (state: typeof skillStoreState) => unknown) => - selector(skillStoreState), -})) - -vi.mock('../../stores/mcpStore', () => ({ - useMcpStore: (selector: (state: typeof mcpStoreState) => unknown) => - selector(mcpStoreState), -})) - -vi.mock('../../stores/uiStore', () => ({ - useUIStore: (selector: (state: typeof uiStoreState) => unknown) => - selector(uiStoreState), -})) - -vi.mock('../../api/settings', () => ({ - settingsApi: settingsApiState, -})) - -vi.mock('../../i18n', () => { - const translations: Record = { - 'settings.install.eyebrow': 'AI 安装助手', - 'settings.install.title': '安装中心', - 'settings.install.description': '安装中心描述', - 'settings.install.targets.plugins': 'Plugins', - 'settings.install.targets.mcp': 'MCP', - 'settings.install.targets.skills': 'Skills', - 'settings.install.contextAuto': '默认上下文', - 'settings.install.composeTitle': '自然语言安装', - 'settings.install.composeHint': '安装提示', - 'settings.install.refresh': '刷新安装状态', - 'settings.install.newConversation': '新建安装会话', - 'settings.install.placeholder': '占位符', - 'settings.install.send': '发送安装请求', - 'settings.install.cliTitle': '内置 CLI 命令', - 'settings.install.cliDescription': 'CLI 描述', - 'settings.install.cliLoading': '正在检查内置 CLI launcher 状态…', - 'settings.install.cliReady': '当前终端已可直接使用', - 'settings.install.cliNeedsRestart': '已安装完成;请新开一个终端以加载 PATH 变更。', - 'settings.install.cliPathMissing': 'launcher 已安装,但 PATH 仍未完全就绪。', - 'settings.install.cliUnavailable': '内置 CLI launcher 还没有准备好。', - 'settings.install.cliLocation': 'CLI 路径', - 'settings.install.cliConfigTarget': 'PATH 集成目标:{target}', - 'settings.install.cliError': '内置 CLI 配置告警:{message}', - 'settings.install.cliSharedConfig': '后续如果你想直接通过命令行安装 Skills、Plugins 或 MCP:电脑上本身装了官方 Claude Code,就继续使用 `claude`;如果没有,就使用 `claude-haha`。这两条命令共用同一套 Skills / Plugins / MCP 配置。', - 'settings.install.cliUseOfficial': '如果你已经安装了官方 Claude Code,继续用原版命令:', - 'settings.install.cliUseBundled': '如果没有官方 CLI,就使用我们打包的 `{command}`:', - 'settings.install.contextDefault': '默认目录提示', - 'settings.install.contextUsing': '当前安装会话目录:{path}', - 'settings.install.contextTitle': '执行目录', - 'settings.install.contextHint': '执行目录提示', - 'settings.install.goPlugins': '查看插件', - 'settings.install.goMcp': '查看 MCP', - 'settings.install.goSkills': '查看技能', - 'settings.install.sessionTitle': '安装助手会话', - 'settings.install.sessionHint': '会话提示', - 'settings.install.sessionEmpty': '还没有安装会话', - 'settings.install.sessionEmptyHint': '空会话提示', - 'settings.install.clearConversation': '清除对话', - 'settings.install.clearConversationReady': '已清除当前安装对话;下一条请求会启动新的安装上下文。', - 'settings.install.newConversationReady': '已准备新的安装会话;下一条请求会启动新的安装上下文。', - } - - return { - useTranslation: () => ( - key: string, - params?: Record, - ) => { - let text = translations[key] ?? key - if (params) { - for (const [name, value] of Object.entries(params)) { - text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)) - } - } - return text - }, - } -}) - -vi.mock('../chat/MessageList', () => ({ - MessageList: ({ sessionId }: { sessionId: string }) => ( -
session:{sessionId}
- ), -})) - -vi.mock('../chat/ComputerUsePermissionModal', () => ({ - ComputerUsePermissionModal: () => null, -})) - -vi.mock('../shared/DirectoryPicker', () => ({ - DirectoryPicker: () =>
Directory picker
, -})) - -import { InstallCenter } from './InstallCenter' - -describe('InstallCenter', () => { - beforeEach(() => { - vi.clearAllMocks() - window.localStorage.clear() - - sessionStoreState.sessions = [ - { - id: 'installer-1', - title: '安装助手会话', - createdAt: '2026-04-23T00:00:00.000Z', - modifiedAt: '2026-04-23T00:00:00.000Z', - messageCount: 2, - projectPath: '', - workDir: '/Users/nanmi', - workDirExists: true, - }, - ] - sessionStoreState.fetchSessions = vi.fn() - - chatStoreState.sessions = { - 'installer-1': { - chatState: 'idle', - pendingComputerUsePermission: null, - }, - } - chatStoreState.connectToSession = vi.fn() - chatStoreState.disconnectSession = vi.fn() - chatStoreState.sendMessage = vi.fn() - chatStoreState.stopGeneration = vi.fn() - - pluginStoreState.fetchPlugins = vi.fn() - pluginStoreState.reloadPlugins = vi.fn() - skillStoreState.fetchSkills = vi.fn() - mcpStoreState.fetchServers = vi.fn() - uiStoreState.addToast = vi.fn() - uiStoreState.setPendingSettingsTab = vi.fn() - settingsApiState.getCliLauncherStatus = vi.fn().mockResolvedValue({ - supported: true, - command: 'claude-haha', - installed: true, - launcherPath: '/Users/nanmi/.local/bin/claude-haha', - binDir: '/Users/nanmi/.local/bin', - pathConfigured: true, - pathInCurrentShell: false, - availableInNewTerminals: true, - needsTerminalRestart: true, - configTarget: '/Users/nanmi/.zshrc', - lastError: null, - }) - - window.localStorage.setItem('cc-haha-installer-session-id', 'installer-1') - window.localStorage.setItem('cc-haha-installer-context-dir', '/Users/nanmi') - }) - - it('shows bundled cli launcher status', async () => { - render() - - expect(await screen.findByText('claude-haha')).toBeInTheDocument() - expect(screen.getByText('已安装完成;请新开一个终端以加载 PATH 变更。')).toBeInTheDocument() - expect(screen.getByText(/共用同一套 Skills \/ Plugins \/ MCP 配置/)).toBeInTheDocument() - expect( - screen.getByText( - 'claude plugin install skill-creator@claude-plugins-official --scope user', - ), - ).toBeInTheDocument() - expect( - screen.getByText( - 'claude-haha mcp add docs --transport http https://example.com/mcp', - ), - ).toBeInTheDocument() - expect(screen.getByTestId('message-list')).toHaveTextContent('session:installer-1') - }) -}) diff --git a/desktop/src/components/settings/InstallCenter.tsx b/desktop/src/components/settings/InstallCenter.tsx deleted file mode 100644 index 53aba245..00000000 --- a/desktop/src/components/settings/InstallCenter.tsx +++ /dev/null @@ -1,590 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { sessionsApi } from '../../api/sessions' -import { settingsApi, type CliLauncherStatus } from '../../api/settings' -import { useChatStore } from '../../stores/chatStore' -import { useMcpStore } from '../../stores/mcpStore' -import { usePluginStore } from '../../stores/pluginStore' -import { useSessionStore } from '../../stores/sessionStore' -import { useSkillStore } from '../../stores/skillStore' -import { useTranslation } from '../../i18n' -import { MessageList } from '../chat/MessageList' -import { ComputerUsePermissionModal } from '../chat/ComputerUsePermissionModal' -import { Button } from '../shared/Button' -import { Textarea } from '../shared/Textarea' -import { DirectoryPicker } from '../shared/DirectoryPicker' -import { useUIStore } from '../../stores/uiStore' -import { buildInstallerPrompt } from '../../lib/installAssistantPrompt' - -const INSTALLER_SESSION_KEY = 'cc-haha-installer-session-id' -const INSTALLER_CONTEXT_KEY = 'cc-haha-installer-context-dir' - -const EXAMPLE_PROMPTS = [ - '安装 plugin:skill-creator@claude-plugins-official,并应用到当前桌面端', - '添加一个 MCP:name=linear,url=https://example.com/mcp,优先用户级', - '帮我把一个 GitHub 仓库里的 skill 装到 ~/.claude/skills,并告诉我装到了哪里', -] - -function readStoredValue(key: string) { - try { - return localStorage.getItem(key) || '' - } catch { - return '' - } -} - -function writeStoredValue(key: string, value: string) { - try { - if (value) { - localStorage.setItem(key, value) - } else { - localStorage.removeItem(key) - } - } catch { - // noop - } -} - -export function InstallCenter() { - const t = useTranslation() - const sessions = useSessionStore((s) => s.sessions) - const fetchSessions = useSessionStore((s) => s.fetchSessions) - const connectToSession = useChatStore((s) => s.connectToSession) - const disconnectSession = useChatStore((s) => s.disconnectSession) - const sendMessage = useChatStore((s) => s.sendMessage) - const stopGeneration = useChatStore((s) => s.stopGeneration) - const fetchPlugins = usePluginStore((s) => s.fetchPlugins) - const reloadPlugins = usePluginStore((s) => s.reloadPlugins) - const fetchSkills = useSkillStore((s) => s.fetchSkills) - const fetchServers = useMcpStore((s) => s.fetchServers) - const addToast = useUIStore((s) => s.addToast) - const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) - - const [sessionId, setSessionId] = useState(() => { - const stored = readStoredValue(INSTALLER_SESSION_KEY) - return stored || null - }) - const [contextDir, setContextDir] = useState(() => readStoredValue(INSTALLER_CONTEXT_KEY)) - const [draft, setDraft] = useState('') - const [isCreating, setIsCreating] = useState(false) - const [cliLauncherStatus, setCliLauncherStatus] = useState(null) - const [isCliLauncherLoading, setIsCliLauncherLoading] = useState(true) - const createPromiseRef = useRef | null>(null) - const previousChatStateRef = useRef<'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'>('idle') - - const installerSession = useMemo( - () => sessions.find((session) => session.id === sessionId) || null, - [sessionId, sessions], - ) - const sessionState = useChatStore((s) => - sessionId ? s.sessions[sessionId] : undefined, - ) - const chatState = sessionState?.chatState ?? 'idle' - const pendingComputerUsePermission = - sessionState?.pendingComputerUsePermission?.request ?? null - const isBusy = isCreating || chatState !== 'idle' - - useEffect(() => { - if (!sessionId) return - connectToSession(sessionId) - return () => { - disconnectSession(sessionId) - } - }, [connectToSession, disconnectSession, sessionId]) - - useEffect(() => { - writeStoredValue(INSTALLER_CONTEXT_KEY, contextDir.trim()) - }, [contextDir]) - - useEffect(() => { - let cancelled = false - - void (async () => { - setIsCliLauncherLoading(true) - try { - const status = await settingsApi.getCliLauncherStatus() - if (!cancelled) { - setCliLauncherStatus(status) - } - } catch { - if (!cancelled) { - setCliLauncherStatus(null) - } - } finally { - if (!cancelled) { - setIsCliLauncherLoading(false) - } - } - })() - - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - if (!sessionId) return - const previousState = previousChatStateRef.current - previousChatStateRef.current = chatState - - if (previousState === 'idle' || chatState !== 'idle') return - - const cwd = installerSession?.workDir || undefined - - void (async () => { - await reloadPlugins(cwd).catch(() => null) - await Promise.allSettled([ - fetchPlugins(cwd), - fetchSkills(cwd), - fetchServers(cwd ? [cwd] : undefined, cwd), - ]) - })() - }, [ - chatState, - fetchPlugins, - fetchServers, - fetchSkills, - installerSession?.workDir, - reloadPlugins, - sessionId, - ]) - - const ensureInstallerSession = async () => { - if (sessionId && installerSession) { - return sessionId - } - - if (createPromiseRef.current) { - return createPromiseRef.current - } - - setIsCreating(true) - createPromiseRef.current = (async () => { - const { sessionId: createdSessionId } = await sessionsApi.create( - contextDir.trim() || undefined, - ) - await sessionsApi.rename( - createdSessionId, - t('settings.install.sessionTitle'), - ) - writeStoredValue(INSTALLER_SESSION_KEY, createdSessionId) - setSessionId(createdSessionId) - await fetchSessions() - connectToSession(createdSessionId) - return createdSessionId - })() - - try { - return await createPromiseRef.current - } finally { - createPromiseRef.current = null - setIsCreating(false) - } - } - - const handleSubmit = async () => { - const request = draft.trim() - if (!request || isBusy) return - - try { - const ensuredSessionId = await ensureInstallerSession() - sendMessage( - ensuredSessionId, - buildInstallerPrompt(request), - undefined, - { displayContent: request }, - ) - setDraft('') - } catch (error) { - addToast({ - type: 'error', - message: - error instanceof Error - ? error.message - : t('settings.install.createFailed'), - }) - } - } - - const handleRefresh = async () => { - const cwd = installerSession?.workDir || undefined - const [launcherStatus] = await Promise.all([ - settingsApi.getCliLauncherStatus().catch(() => null), - fetchPlugins(cwd), - fetchSkills(cwd), - fetchServers(cwd ? [cwd] : undefined, cwd), - ]) - if (launcherStatus) { - setCliLauncherStatus(launcherStatus) - } - addToast({ - type: 'success', - message: t('settings.install.refreshDone'), - }) - } - - const startFreshConversation = async () => { - if (sessionId) { - disconnectSession(sessionId) - } - writeStoredValue(INSTALLER_SESSION_KEY, '') - setSessionId(null) - previousChatStateRef.current = 'idle' - addToast({ - type: 'info', - message: t('settings.install.newConversationReady'), - }) - } - - return ( -
-
-
-
-
- {t('settings.install.eyebrow')} -
-
- - download - -

- {t('settings.install.title')} -

-
-

- {t('settings.install.description')} -

-
- -
- - - - -
-
-
- - {(isCliLauncherLoading || cliLauncherStatus?.supported) && ( -
-
-
-

- {t('settings.install.cliTitle')} -

-

- {t('settings.install.cliDescription')} -

-
-
- {cliLauncherStatus?.command || 'claude-haha'} -
-
- -
- {isCliLauncherLoading ? ( -

- {t('settings.install.cliLoading')} -

- ) : cliLauncherStatus ? ( - - ) : ( -

- {t('settings.install.cliUnavailable')} -

- )} -
-
- )} - -
-
-
-

- {t('settings.install.composeTitle')} -

-

- {t('settings.install.composeHint')} -

-
-
- - -
-
- -
-
-