From 28f36da0fd91c9bf0880f2538c9355940eee303a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 22 Apr 2026 01:06:57 +0800 Subject: [PATCH] Reduce extension setup friction inside desktop settings The desktop app already had solid session streaming, permission, and tool-rendering flows, but extension setup still forced users into manual forms or external shell work. This change adds an Install Center in Settings that reuses the session chat pipeline for natural-language installs, adds installer-specific guidance for plugin and skill URLs, and includes an agent-browser E2E script for real UI validation. Constraint: Must reuse the existing session/chat execution path instead of introducing a second install runtime Constraint: Plugin installs need real CLI commands while skill installs may come from published install commands on third-party pages Rejected: Separate terminal-only install surface | duplicates session UX and weakens permission/tool visibility Rejected: Pure form-based installer expansion | too much friction for plugin, MCP, and skill onboarding Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep installer prompts aligned with the actual CLI install surfaces; do not let the installer fall back to slash-command syntax inside Bash Tested: cd desktop && bun run lint Tested: Real UI automation via agent-browser for Telegram plugin install flow through Settings > Install, verified Plugins page shows telegram enabled Tested: Real UI automation via agent-browser for ui-ux-pro-max skill install flow through Settings > Install, verified ~/.claude/skills/ui-ux-pro-max and Skills page visibility Not-tested: Full e2e-install-center-agent-browser.sh script as a single uninterrupted green run after the latest stability tweaks --- .../e2e-install-center-agent-browser.sh | 168 +++++++ desktop/src/components/chat/MessageList.tsx | 33 +- .../src/components/settings/InstallCenter.tsx | 433 ++++++++++++++++++ desktop/src/i18n/locales/en.ts | 31 ++ desktop/src/i18n/locales/zh.ts | 31 ++ desktop/src/lib/installAssistantPrompt.ts | 42 ++ desktop/src/pages/Settings.tsx | 3 + desktop/src/stores/chatStore.ts | 12 +- desktop/src/stores/uiStore.ts | 13 +- 9 files changed, 750 insertions(+), 16 deletions(-) create mode 100755 desktop/scripts/e2e-install-center-agent-browser.sh create mode 100644 desktop/src/components/settings/InstallCenter.tsx create mode 100644 desktop/src/lib/installAssistantPrompt.ts diff --git a/desktop/scripts/e2e-install-center-agent-browser.sh b/desktop/scripts/e2e-install-center-agent-browser.sh new file mode 100755 index 00000000..b0a21e5a --- /dev/null +++ b/desktop/scripts/e2e-install-center-agent-browser.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-install-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-install-e2e-${RUN_ID}-XXXX")" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" + +if [[ "${CC_HAHA_E2E_USE_REAL_CONFIG:-1}" == "1" ]]; then + CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +else + CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${ARTIFACT_DIR}/claude-config}" + mkdir -p "${CONFIG_DIR}" +fi + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ $exit_code -ne 0 ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +echo "Starting isolated backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +browser_body() { + agent-browser --session "${SESSION_NAME}" get text body +} + +wait_for_body_contains() { + local needle="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if browser_body | grep -Fq "${needle}"; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for page text: ${needle}" >&2 + return 1 +} + +wait_for_body_contains_any() { + local attempts="$1" + shift + + for _ in $(seq 1 "${attempts}"); do + local body + body="$(browser_body)" + for needle in "$@"; do + if grep -Fq "${needle}" <<<"${body}"; then + return 0 + fi + done + sleep 2 + done + + echo "Timed out waiting for any expected page text: $*" >&2 + return 1 +} + +wait_for_path() { + local path="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if [[ -e "${path}" ]]; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for path: ${path}" >&2 + return 1 +} + +AB="agent-browser --session ${SESSION_NAME}" +TELEGRAM_PROMPT="请根据 https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram 安装 Telegram 官方插件。如果 URL 对应官方 external_plugins,请直接识别 plugin id 并完成用户级安装,然后告诉我结果。" +SKILL_PROMPT="请根据 https://www.aitmpl.com/component/skill/creative-design/ui-ux-pro-max 这个 skill 页面安装 ui-ux-pro-max。如果页面里有官方安装命令,请直接识别并执行,然后告诉我结果。" +PLUGIN_DIR="${CONFIG_DIR}/plugins/cache/claude-plugins-official/telegram" +SKILL_DIR="${CONFIG_DIR}/skills/ui-ux-pro-max" + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +${AB} open "${WEB_URL}" +${AB} click 'button:has-text("Settings")' +${AB} click 'button:has-text("Install")' +${AB} screenshot "${ARTIFACT_DIR}/01-install-center.png" >/dev/null + +run_install_prompt() { + local prompt="$1" + ${AB} click 'button:has-text("New install chat")' + ${AB} fill 'textarea' "${prompt}" + ${AB} click 'button:has-text("Send request")' +} + +echo "Running plugin install flow" +run_install_prompt "${TELEGRAM_PROMPT}" +wait_for_body_contains 'telegram@claude-plugins-official' +wait_for_path "${PLUGIN_DIR}" +${AB} click 'button:has-text("Open Plugins")' +wait_for_body_contains 'Enabled1' +wait_for_body_contains 'telegram Enabled' +${AB} screenshot "${ARTIFACT_DIR}/02-plugin-installed.png" >/dev/null + +echo "Running skill install flow" +${AB} click 'button:has-text("Install")' +run_install_prompt "${SKILL_PROMPT}" +wait_for_body_contains 'claude-code-templates@latest' +wait_for_path "${SKILL_DIR}" +${AB} click 'button:has-text("Open Skills")' +wait_for_body_contains 'Installed Skills' +wait_for_body_contains 'ui-ux-pro-max' +${AB} screenshot "${ARTIFACT_DIR}/03-skill-installed.png" >/dev/null + +echo "Install Center E2E passed" +echo "Config dir: ${CONFIG_DIR}" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 84fa723d..753c92f2 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -114,13 +114,22 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { return { renderItems: items, toolResultMap, childToolCallsByParent } } -export function MessageList() { +type MessageListProps = { + sessionId?: string | null +} + +export function MessageList({ sessionId }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) - const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const resolvedSessionId = sessionId ?? activeTabId + const sessionState = useChatStore((s) => + resolvedSessionId ? s.sessions[resolvedSessionId] : undefined, + ) const stopGeneration = useChatStore((s) => s.stopGeneration) const reloadHistory = useChatStore((s) => s.reloadHistory) const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill) - const isMemberSession = useTeamStore((s) => activeTabId ? Boolean(s.getMemberBySessionId(activeTabId)) : false) + const isMemberSession = useTeamStore((s) => + resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, + ) const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? [] const chatState = sessionState?.chatState ?? 'idle' @@ -144,7 +153,7 @@ export function MessageList() { }, [messages.length, streamingText]) useEffect(() => { - if (!activeTabId || !rewindTarget) return + if (!resolvedSessionId || !rewindTarget) return let cancelled = false setIsLoadingPreview(true) @@ -152,7 +161,7 @@ export function MessageList() { setRewindError(null) void sessionsApi - .rewind(activeTabId, { + .rewind(resolvedSessionId, { userMessageIndex: rewindTarget.userMessageIndex, dryRun: true, }) @@ -182,7 +191,7 @@ export function MessageList() { return () => { cancelled = true } - }, [activeTabId, rewindTarget]) + }, [resolvedSessionId, rewindTarget]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( () => buildRenderModel(messages), @@ -198,22 +207,22 @@ export function MessageList() { }, [isExecutingRewind]) const handleConfirmRewind = useCallback(async () => { - if (!activeTabId || !rewindTarget || isExecutingRewind) return + if (!resolvedSessionId || !rewindTarget || isExecutingRewind) return setIsExecutingRewind(true) setRewindError(null) try { if (chatState !== 'idle') { - stopGeneration(activeTabId) + stopGeneration(resolvedSessionId) } - const result = await sessionsApi.rewind(activeTabId, { + const result = await sessionsApi.rewind(resolvedSessionId, { userMessageIndex: rewindTarget.userMessageIndex, }) - await reloadHistory(activeTabId) - queueComposerPrefill(activeTabId, { + await reloadHistory(resolvedSessionId) + queueComposerPrefill(resolvedSessionId, { text: rewindTarget.content, attachments: rewindTarget.attachments, }) @@ -245,12 +254,12 @@ export function MessageList() { setIsExecutingRewind(false) } }, [ - activeTabId, addToast, chatState, isExecutingRewind, queueComposerPrefill, reloadHistory, + resolvedSessionId, rewindTarget, stopGeneration, t, diff --git a/desktop/src/components/settings/InstallCenter.tsx b/desktop/src/components/settings/InstallCenter.tsx new file mode 100644 index 00000000..9a81aaa3 --- /dev/null +++ b/desktop/src/components/settings/InstallCenter.tsx @@ -0,0 +1,433 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { sessionsApi } from '../../api/sessions' +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 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(() => { + 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 + await Promise.all([ + fetchPlugins(cwd), + fetchSkills(cwd), + fetchServers(cwd ? [cwd] : undefined, cwd), + ]) + 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')} +

+
+ +
+ + + + +
+
+
+ +
+
+
+

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

+

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

+
+
+ + +
+
+ +
+
+