fix(desktop): remove install center

This commit is contained in:
Relakkes Yang 2026-04-24 23:05:45 +08:00
parent 761031c036
commit 928be2a4ff
11 changed files with 32 additions and 973 deletions

View File

@ -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(<Settings />)
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()
})
})

View File

@ -50,7 +50,8 @@ async function request<T>(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<T>(method: string, path: string, body?: unknown, options?
return res.json() as Promise<T>
} catch (err) {
clearTimeout(timeout)
if (controller.signal.aborted) {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)
}
throw err
}
}

View File

@ -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 },
)
},
}

View File

@ -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 },
)
},
}

View File

@ -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<typeof vi.fn>
} = {
sessions: [],
fetchSessions: vi.fn(),
}
const chatStoreState: {
sessions: Record<string, { chatState: 'idle'; pendingComputerUsePermission: { request: unknown } | null }>
connectToSession: ReturnType<typeof vi.fn>
disconnectSession: ReturnType<typeof vi.fn>
sendMessage: ReturnType<typeof vi.fn>
stopGeneration: ReturnType<typeof vi.fn>
} = {
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<string, string> = {
'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<string, string | number>,
) => {
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 }) => (
<div data-testid="message-list">session:{sessionId}</div>
),
}))
vi.mock('../chat/ComputerUsePermissionModal', () => ({
ComputerUsePermissionModal: () => null,
}))
vi.mock('../shared/DirectoryPicker', () => ({
DirectoryPicker: () => <div>Directory picker</div>,
}))
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(<InstallCenter />)
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')
})
})

View File

@ -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 = [
'安装 pluginskill-creator@claude-plugins-official并应用到当前桌面端',
'添加一个 MCPname=linearurl=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<string | null>(() => {
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<CliLauncherStatus | null>(null)
const [isCliLauncherLoading, setIsCliLauncherLoading] = useState(true)
const createPromiseRef = useRef<Promise<string> | 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 (
<div className="w-full min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1.5fr)_minmax(280px,1fr)] xl:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.install.eyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
download
</span>
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.install.title')}
</h2>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.install.description')}
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-2">
<SummaryPill
label={t('settings.install.targets.plugins')}
icon="extension"
/>
<SummaryPill
label={t('settings.install.targets.mcp')}
icon="hub"
/>
<SummaryPill
label={t('settings.install.targets.skills')}
icon="auto_awesome"
/>
<SummaryPill
label={installerSession?.workDir || t('settings.install.contextAuto')}
icon="folder"
/>
</div>
</div>
</section>
{(isCliLauncherLoading || cliLauncherStatus?.supported) && (
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.install.cliTitle')}
</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.install.cliDescription')}
</p>
</div>
<div className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1 text-sm font-semibold text-[var(--color-text-primary)]">
{cliLauncherStatus?.command || 'claude-haha'}
</div>
</div>
<div className="mt-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
{isCliLauncherLoading ? (
<p className="text-sm text-[var(--color-text-secondary)]">
{t('settings.install.cliLoading')}
</p>
) : cliLauncherStatus ? (
<CliLauncherStatusPanel status={cliLauncherStatus} />
) : (
<p className="text-sm text-[var(--color-danger)]">
{t('settings.install.cliUnavailable')}
</p>
)}
</div>
</section>
)}
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.install.composeTitle')}
</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.install.composeHint')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => void handleRefresh()}
>
{t('settings.install.refresh')}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void startFreshConversation()}
>
{t('settings.install.newConversation')}
</Button>
</div>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-w-0">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
void handleSubmit()
}
}}
placeholder={t('settings.install.placeholder')}
className="min-h-[140px]"
/>
<div className="mt-3 flex flex-wrap items-center gap-2">
{EXAMPLE_PROMPTS.map((example) => (
<button
key={example}
type="button"
onClick={() => setDraft(example)}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{example}
</button>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
{chatState !== 'idle' && sessionId ? (
<Button
size="sm"
variant="danger"
onClick={() => stopGeneration(sessionId)}
>
{t('settings.install.stop')}
</Button>
) : null}
<Button
size="sm"
onClick={() => void handleSubmit()}
loading={isCreating}
disabled={!draft.trim() || isBusy}
icon={
!isCreating ? (
<span className="material-symbols-outlined text-[16px]">send</span>
) : undefined
}
>
{t('settings.install.send')}
</Button>
<span className="text-xs text-[var(--color-text-tertiary)]">
{installerSession?.workDir
? t('settings.install.contextUsing', {
path: installerSession.workDir,
})
: t('settings.install.contextDefault')}
</span>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('settings.install.contextTitle')}
</div>
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.install.contextHint')}
</p>
<div className="mt-3">
<DirectoryPicker value={contextDir} onChange={setContextDir} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSettingsTab('plugins')}
>
{t('settings.install.goPlugins')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSettingsTab('mcp')}
>
{t('settings.install.goMcp')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSettingsTab('skills')}
>
{t('settings.install.goSkills')}
</Button>
</div>
</div>
</div>
</section>
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.install.sessionTitle')}
</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{sessionId
? t('settings.install.sessionHint')
: t('settings.install.sessionEmpty')}
</p>
</div>
{sessionId ? (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{chatState}
</span>
) : null}
</div>
<div className="min-h-[420px] bg-[var(--color-surface-container-lowest)]">
{sessionId ? (
<>
<MessageList sessionId={sessionId} />
<ComputerUsePermissionModal
sessionId={sessionId}
request={pendingComputerUsePermission}
/>
</>
) : (
<div className="flex h-[420px] flex-col items-center justify-center px-6 text-center">
<span className="material-symbols-outlined text-[36px] text-[var(--color-text-tertiary)] mb-3">
forum
</span>
<p className="text-sm text-[var(--color-text-secondary)]">
{t('settings.install.sessionEmpty')}
</p>
<p className="mt-2 max-w-md text-xs leading-6 text-[var(--color-text-tertiary)]">
{t('settings.install.sessionEmptyHint')}
</p>
</div>
)}
</div>
</section>
</div>
)
}
function CliLauncherStatusPanel({ status }: { status: CliLauncherStatus }) {
const t = useTranslation()
const bundledCommand = status.command || 'claude-haha'
const exampleCommands = [
{
label: t('settings.install.cliUseOfficial'),
command:
'claude plugin install skill-creator@claude-plugins-official --scope user',
},
{
label: t('settings.install.cliUseBundled', {
command: bundledCommand,
}),
command: `${bundledCommand} plugin install skill-creator@claude-plugins-official --scope user`,
},
{
label: t('settings.install.cliUseBundled', {
command: bundledCommand,
}),
command: `${bundledCommand} mcp add docs --transport http https://example.com/mcp`,
},
]
let statusText = t('settings.install.cliUnavailable')
let statusClassName = 'border-[var(--color-danger)]/25 bg-[var(--color-danger)]/10 text-[var(--color-danger)]'
if (status.installed && status.availableInNewTerminals) {
if (status.needsTerminalRestart) {
statusText = t('settings.install.cliNeedsRestart')
statusClassName = 'border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
} else {
statusText = t('settings.install.cliReady')
statusClassName = 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
}
} else if (status.installed) {
statusText = t('settings.install.cliPathMissing')
statusClassName = 'border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold ${statusClassName}`}
>
{statusText}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.install.cliLocation')}
</span>
<span className="font-mono text-xs text-[var(--color-text-primary)] break-all">
{status.launcherPath}
</span>
</div>
{status.configTarget && (
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.install.cliConfigTarget', {
target: status.configTarget,
})}
</p>
)}
{status.lastError && (
<p className="text-xs text-[var(--color-danger)]">
{t('settings.install.cliError', {
message: status.lastError,
})}
</p>
)}
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<p className="text-xs leading-6 text-[var(--color-text-secondary)]">
{t('settings.install.cliSharedConfig')}
</p>
<div className="mt-3 space-y-2">
{exampleCommands.map((example) => (
<div key={example.command} className="space-y-1">
<p className="text-[11px] text-[var(--color-text-tertiary)]">
{example.label}
</p>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 font-mono text-xs text-[var(--color-text-primary)] break-all">
{example.command}
</div>
</div>
))}
</div>
</div>
</div>
)
}
function SummaryPill({
label,
icon,
}: {
label: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
</div>
)
}

View File

@ -51,7 +51,6 @@ export const en = {
'settings.tab.providers': 'Providers',
'settings.tab.permissions': 'Permissions',
'settings.tab.general': 'General',
'settings.tab.install': 'Install',
'settings.tab.terminal': 'Terminal',
'settings.tab.skills': 'Skills',
'settings.tab.mcp': 'MCP',
@ -309,51 +308,6 @@ export const en = {
'settings.agents.source.flagSettings': 'CLI arg',
'settings.agents.source.built-in': 'Built-in',
// Settings > Install Center
'settings.install.eyebrow': 'AI Installer',
'settings.install.title': 'Install Center',
'settings.install.description': 'Describe the plugin, MCP server, or skill you want. The desktop app will reuse a dedicated session to execute the installation flow and then sync results back to the existing management pages.',
'settings.install.targets.plugins': 'Plugins',
'settings.install.targets.mcp': 'MCP',
'settings.install.targets.skills': 'Skills',
'settings.install.contextAuto': 'Default context',
'settings.install.composeTitle': 'Natural language install',
'settings.install.composeHint': 'Describe what to install, where it comes from, and whether it should land in user or project scope.',
'settings.install.placeholder': 'Example: install plugin skill-creator@claude-plugins-official and apply it to the current desktop runtime. Or: add a user-scope MCP at https://example.com/mcp.',
'settings.install.send': 'Send request',
'settings.install.stop': 'Stop',
'settings.install.refresh': 'Refresh state',
'settings.install.newConversation': 'New install chat',
'settings.install.clearConversation': 'Clear chat',
'settings.install.cliTitle': 'Bundled CLI command',
'settings.install.cliDescription': 'The desktop app installs a terminal command named `claude-haha` so system shells can use the bundled CLI even when the official Claude CLI is not installed.',
'settings.install.cliLoading': 'Checking bundled CLI launcher status…',
'settings.install.cliReady': 'Ready in current terminals',
'settings.install.cliNeedsRestart': 'Installed. Open a new terminal to pick up PATH changes.',
'settings.install.cliPathMissing': 'Installed, but PATH still needs attention.',
'settings.install.cliUnavailable': 'Bundled CLI launcher is not ready yet.',
'settings.install.cliLocation': 'CLI path',
'settings.install.cliConfigTarget': 'PATH integration target: {target}',
'settings.install.cliError': 'Bundled CLI setup warning: {message}',
'settings.install.cliSharedConfig': 'For direct terminal installs later, keep using `claude` if the official Claude Code CLI is already installed. Otherwise use `claude-haha`. Both command paths share the same Skills / Plugins / MCP state.',
'settings.install.cliUseOfficial': 'If the official Claude Code CLI is already installed, keep using it:',
'settings.install.cliUseBundled': 'If not, use our bundled `{command}` command:',
'settings.install.contextDefault': 'If no directory is selected, the installer session starts from your home directory.',
'settings.install.contextUsing': 'Installer session directory: {path}',
'settings.install.contextTitle': 'Execution directory',
'settings.install.contextHint': 'Most user-scope installs do not need a project path. If you need project skills or local/project MCP config, pick a directory here and then start a new install chat.',
'settings.install.goPlugins': 'Open Plugins',
'settings.install.goMcp': 'Open MCP',
'settings.install.goSkills': 'Open Skills',
'settings.install.sessionTitle': 'Installer session',
'settings.install.sessionHint': 'This panel shows the full installer session, including tool calls and results.',
'settings.install.sessionEmpty': 'No installer session yet',
'settings.install.sessionEmptyHint': 'After you send the first installation request, the app will reuse the session chat pipeline and show the full execution trace here.',
'settings.install.createFailed': 'Failed to create installer session',
'settings.install.refreshDone': 'Plugin, MCP, and skill state refreshed',
'settings.install.newConversationReady': 'A fresh installer conversation is ready; your next request will start a new install context.',
'settings.install.clearConversationReady': 'Installer chat cleared. Your next request will start a fresh install context.',
// Settings > Skills
'settings.skills.title': 'Installed Skills',
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',

View File

@ -53,7 +53,6 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.providers': '服务商',
'settings.tab.permissions': '权限',
'settings.tab.general': '通用',
'settings.tab.install': '安装中心',
'settings.tab.terminal': '终端',
'settings.tab.skills': '技能',
'settings.tab.mcp': 'MCP',
@ -311,51 +310,6 @@ export const zh: Record<TranslationKey, string> = {
'settings.agents.source.flagSettings': 'CLI 参数',
'settings.agents.source.built-in': '内置',
// Settings > Install Center
'settings.install.eyebrow': 'AI 安装助手',
'settings.install.title': '安装中心',
'settings.install.description': '直接描述你想安装的 plugin、MCP 或 skill。底层会复用安装专用 session 去执行,并把结果同步回现有管理页。',
'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.placeholder': '例如:安装 plugin skill-creator@claude-plugins-official并帮我应用到当前桌面端或者添加一个 user scope 的 MCP地址是 https://example.com/mcp。',
'settings.install.send': '发送安装请求',
'settings.install.stop': '中断执行',
'settings.install.refresh': '刷新安装状态',
'settings.install.newConversation': '新建安装会话',
'settings.install.clearConversation': '清除对话',
'settings.install.cliTitle': '内置 CLI 命令',
'settings.install.cliDescription': '桌面端会把打包进去的 CLI 暴露成系统终端命令 `claude-haha`,即使用户没有安装官方 Claude 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': '未指定目录时默认使用 Home 目录启动安装会话。',
'settings.install.contextUsing': '当前安装会话目录:{path}',
'settings.install.contextTitle': '执行目录',
'settings.install.contextHint': '大部分用户级安装不依赖项目目录;如果你要安装 project skill 或 local/project MCP可以先在这里选目录再开始新的安装会话。',
'settings.install.goPlugins': '查看插件',
'settings.install.goMcp': '查看 MCP',
'settings.install.goSkills': '查看技能',
'settings.install.sessionTitle': '安装助手会话',
'settings.install.sessionHint': '这里展示安装专用 session 的完整执行过程、工具调用和结果。',
'settings.install.sessionEmpty': '还没有安装会话',
'settings.install.sessionEmptyHint': '发送第一条安装请求后,这里会直接复用 session 聊天链路展示完整过程。',
'settings.install.createFailed': '创建安装会话失败',
'settings.install.refreshDone': '已刷新插件、MCP 和技能状态',
'settings.install.newConversationReady': '已准备新的安装会话;下一条请求会启动新的安装上下文。',
'settings.install.clearConversationReady': '已清除当前安装对话;下一条请求会启动新的安装上下文。',
// Settings > Skills
'settings.skills.title': '已安装技能',
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',

View File

@ -1,44 +0,0 @@
const INSTALLER_RULES = `You are the installation assistant for Claude Code Haha desktop.
Your job is to help the user install or configure Claude Code extensions and integrations, especially:
- Skills
- MCP servers
- Plugins and marketplaces
Execution rules:
1. Act directly when the request is clear. Do not ask for confirmation on obvious next steps.
2. Prefer existing Claude CLI capabilities and existing config formats over inventing new files.
3. Inspect before changing things if the request depends on the current local state.
4. Prefer user scope unless the user explicitly asks for project/local scope.
5. For plugin installation, use the existing plugin and marketplace commands, and run reload/apply steps when needed.
6. For MCP setup, prefer the existing MCP commands and config locations, and include required auth/header details when available.
7. For skill installation, install to ~/.claude/skills unless the user clearly asks for a project-scoped skill.
8. Keep unrelated workspace files untouched.
Command guidance:
- In Claude Code Haha Desktop installer sessions, the Bash shell already exposes a \`claude\` command wired to the bundled desktop CLI. Prefer that and do not spend time checking whether a separate global Claude CLI is installed.
- Outside the desktop app, the bundled CLI is exposed to system terminals as \`claude-haha\`. Inside installer sessions, continue to use the injected \`claude\` command.
- Plugin install: run Bash with \`claude plugin install <plugin-id> --scope <scope>\`
- For a normal plugin installation request, do both:
1. \`claude plugin install <plugin-id> --scope <scope>\`
2. \`claude plugin enable <plugin-id> --scope <scope>\`
- Only skip the enable step if the user explicitly asks to install without enabling.
- Marketplace add: run Bash with \`claude plugin marketplace add <source> --scope <scope>\`
- MCP add: run Bash with \`claude mcp add ...\`
- Do NOT run \`claude /plugin ...\` in Bash. Slash commands such as \`/plugin install\` are not shell syntax.
- If the user gives a GitHub URL under \`anthropics/claude-plugins-official/external_plugins/<name>\`, treat the plugin id as \`<name>@claude-plugins-official\`.
- If the user gives a skill marketplace page, inspect the page and prefer the exact published install command shown on that page.
- For AI Templates skill pages (\`aitmpl.com/component/skill/...\`), if the page shows an install command like \`npx claude-code-templates@latest --skill <slug>\`, run that command directly in Bash.
- After a successful plugin install or enable step, tell the user that the desktop Install Center will refresh Plugins / Skills / MCP views automatically. Only do extra reload steps if they are necessary for the current task.
Response rules:
- Briefly explain what you are doing.
- After finishing, summarize exactly what was installed or changed, where it landed, and whether the user should check Plugins, MCP, or Skills.
- If you are blocked by missing information, ask only the minimal question needed to proceed.`
export function buildInstallerPrompt(userRequest: string) {
return `${INSTALLER_RULES}
User request:
${userRequest.trim()}`
}

View File

@ -28,7 +28,6 @@ import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes'
import { InstallCenter } from '../components/settings/InstallCenter'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
@ -51,7 +50,6 @@ export function Settings() {
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="download" label={t('settings.tab.install')} active={activeTab === 'install'} onClick={() => setActiveTab('install')} />
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
@ -70,7 +68,6 @@ export function Settings() {
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'install' && <InstallCenter />}
{activeTab === 'terminal' && <TerminalSettings />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
@ -1400,7 +1397,20 @@ function AboutSettings() {
const initialize = useUpdateStore((s) => s.initialize)
useEffect(() => {
import('@tauri-apps/api/app').then((mod) => mod.getVersion()).then(setVersion).catch(() => setVersion('0.1.0'))
let cancelled = false
import('@tauri-apps/api/app')
.then((mod) => mod.getVersion())
.then((value) => {
if (!cancelled) setVersion(value)
})
.catch(() => {
if (!cancelled) setVersion('0.1.0')
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {

View File

@ -33,7 +33,6 @@ export type SettingsTab =
| 'permissions'
| 'general'
| 'adapters'
| 'install'
| 'terminal'
| 'mcp'
| 'agents'