import { useEffect, useMemo, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { toast } from "sonner"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { Plus, Settings, ArrowLeft, Book, Wrench, RefreshCw, History, BarChart2, Download, FolderArchive, Search, FolderOpen, KeyRound, Shield, Cpu, } from "lucide-react"; import type { Provider, VisibleApps } from "@/types"; import type { EnvConflict } from "@/types/env"; import { useProvidersQuery, useSettingsQuery } from "@/lib/query"; import { providersApi, settingsApi, type AppId, type ProviderSwitchEvent, } from "@/lib/api"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { openclawKeys } from "@/hooks/useOpenClaw"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; import { isTextEditableTarget } from "@/utils/domUtils"; import { cn } from "@/lib/utils"; import { isWindows, isLinux } from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { SettingsPage } from "@/components/settings/SettingsPage"; import { UpdateBadge } from "@/components/UpdateBadge"; import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import { ProxyToggle } from "@/components/proxy/ProxyToggle"; import { FailoverToggle } from "@/components/proxy/FailoverToggle"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { UniversalProviderPanel } from "@/components/universal"; import { McpIcon } from "@/components/BrandIcons"; import { Button } from "@/components/ui/button"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; import { useDisableCurrentOmo, useDisableCurrentOmoSlim, } from "@/lib/query/omo"; import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel"; import EnvPanel from "@/components/openclaw/EnvPanel"; import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; type View = | "providers" | "settings" | "prompts" | "skills" | "skillsDiscovery" | "mcp" | "agents" | "universal" | "sessions" | "workspace" | "openclawEnv" | "openclawTools" | "openclawAgents"; interface WebDavSyncStatusUpdatedPayload { source?: string; status?: string; error?: string; } const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; const STORAGE_KEY = "cc-switch-last-app"; const VALID_APPS: AppId[] = [ "claude", "codex", "gemini", "opencode", "openclaw", ]; const getInitialApp = (): AppId => { const saved = localStorage.getItem(STORAGE_KEY) as AppId | null; if (saved && VALID_APPS.includes(saved)) { return saved; } return "claude"; }; const VIEW_STORAGE_KEY = "cc-switch-last-view"; const VALID_VIEWS: View[] = [ "providers", "settings", "prompts", "skills", "skillsDiscovery", "mcp", "agents", "universal", "sessions", "workspace", "openclawEnv", "openclawTools", "openclawAgents", ]; const getInitialView = (): View => { const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null; if (saved && VALID_VIEWS.includes(saved)) { return saved; } return "providers"; }; function App() { const { t } = useTranslation(); const queryClient = useQueryClient(); const [activeApp, setActiveApp] = useState(getInitialApp); const [currentView, setCurrentView] = useState(getInitialView); const [settingsDefaultTab, setSettingsDefaultTab] = useState("general"); const [isAddOpen, setIsAddOpen] = useState(false); useEffect(() => { localStorage.setItem(VIEW_STORAGE_KEY, currentView); }, [currentView]); const { data: settingsData } = useSettingsQuery(); const visibleApps: VisibleApps = settingsData?.visibleApps ?? { claude: true, codex: true, gemini: true, opencode: true, openclaw: true, }; const getFirstVisibleApp = (): AppId => { if (visibleApps.claude) return "claude"; if (visibleApps.codex) return "codex"; if (visibleApps.gemini) return "gemini"; if (visibleApps.opencode) return "opencode"; if (visibleApps.openclaw) return "openclaw"; return "claude"; // fallback }; useEffect(() => { if (!visibleApps[activeApp]) { setActiveApp(getFirstVisibleApp()); } }, [visibleApps, activeApp]); // Fallback from sessions view when switching to an app without session support useEffect(() => { if ( currentView === "sessions" && activeApp !== "claude" && activeApp !== "codex" && activeApp !== "opencode" && activeApp !== "openclaw" && activeApp !== "gemini" ) { setCurrentView("providers"); } }, [activeApp, currentView]); const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); const [confirmAction, setConfirmAction] = useState<{ provider: Provider; action: "remove" | "delete"; } | null>(null); const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); const effectiveEditingProvider = useLastValidValue(editingProvider); const effectiveUsageProvider = useLastValidValue(usageProvider); const toolbarRef = useRef(null); const isToolbarCompact = useAutoCompact(toolbarRef); const promptPanelRef = useRef(null); const mcpPanelRef = useRef(null); const skillsPageRef = useRef(null); const unifiedSkillsPanelRef = useRef(null); const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; const { isRunning: isProxyRunning, takeoverStatus, status: proxyStatus, } = useProxyStatus(); const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; const activeProviderId = useMemo(() => { const target = proxyStatus?.active_targets?.find( (t) => t.app_type === activeApp, ); return target?.provider_id; }, [proxyStatus?.active_targets, activeApp]); const { data, isLoading, refetch } = useProvidersQuery(activeApp, { isProxyRunning, }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; const hasSkillsSupport = true; const hasSessionSupport = activeApp === "claude" || activeApp === "codex" || activeApp === "opencode" || activeApp === "openclaw" || activeApp === "gemini"; const { addProvider, updateProvider, switchProvider, deleteProvider, saveUsageScript, setAsDefaultModel, } = useProviderActions(activeApp); const disableOmoMutation = useDisableCurrentOmo(); const handleDisableOmo = () => { disableOmoMutation.mutate(undefined, { onSuccess: () => { toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" })); }, onError: (error: Error) => { toast.error( t("omo.disableFailed", { defaultValue: "停用 OMO 失败: {{error}}", error: extractErrorMessage(error), }), ); }, }); }; const disableOmoSlimMutation = useDisableCurrentOmoSlim(); const handleDisableOmoSlim = () => { disableOmoSlimMutation.mutate(undefined, { onSuccess: () => { toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" })); }, onError: (error: Error) => { toast.error( t("omo.disableFailed", { defaultValue: "停用 OMO 失败: {{error}}", error: extractErrorMessage(error), }), ); }, }); }; useEffect(() => { let unsubscribe: (() => void) | undefined; const setupListener = async () => { try { unsubscribe = await providersApi.onSwitched( async (event: ProviderSwitchEvent) => { if (event.appType === activeApp) { await refetch(); } }, ); } catch (error) { console.error("[App] Failed to subscribe provider switch event", error); } }; setupListener(); return () => { unsubscribe?.(); }; }, [activeApp, refetch]); useEffect(() => { let unsubscribe: (() => void) | undefined; const setupListener = async () => { try { const { listen } = await import("@tauri-apps/api/event"); unsubscribe = await listen("universal-provider-synced", async () => { await queryClient.invalidateQueries({ queryKey: ["providers"] }); try { await providersApi.updateTrayMenu(); } catch (error) { console.error("[App] Failed to update tray menu", error); } }); } catch (error) { console.error( "[App] Failed to subscribe universal-provider-synced event", error, ); } }; setupListener(); return () => { unsubscribe?.(); }; }, [queryClient]); useEffect(() => { let unsubscribe: (() => void) | undefined; let active = true; const setupListener = async () => { try { const off = await listen( "webdav-sync-status-updated", async (event) => { const payload = (event.payload ?? {}) as WebDavSyncStatusUpdatedPayload; await queryClient.invalidateQueries({ queryKey: ["settings"] }); if (payload.source !== "auto" || payload.status !== "error") { return; } toast.error( t("settings.webdavSync.autoSyncFailedToast", { error: payload.error || t("common.unknown"), }), ); }, ); if (!active) { off(); return; } unsubscribe = off; } catch (error) { console.error( "[App] Failed to subscribe webdav-sync-status-updated event", error, ); } }; void setupListener(); return () => { active = false; unsubscribe?.(); }; }, [queryClient, t]); useEffect(() => { const checkEnvOnStartup = async () => { try { const allConflicts = await checkAllEnvConflicts(); const flatConflicts = Object.values(allConflicts).flat(); if (flatConflicts.length > 0) { setEnvConflicts(flatConflicts); const dismissed = sessionStorage.getItem("env_banner_dismissed"); if (!dismissed) { setShowEnvBanner(true); } } } catch (error) { console.error( "[App] Failed to check environment conflicts on startup:", error, ); } }; checkEnvOnStartup(); }, []); useEffect(() => { const checkMigration = async () => { try { const migrated = await invoke("get_migration_result"); if (migrated) { toast.success( t("migration.success", { defaultValue: "配置迁移成功" }), { closeButton: true }, ); } } catch (error) { console.error("[App] Failed to check migration result:", error); } }; checkMigration(); }, [t]); useEffect(() => { const checkSkillsMigration = async () => { try { const result = await invoke<{ count: number; error?: string } | null>( "get_skills_migration_result", ); if (result?.error) { toast.error(t("migration.skillsFailed"), { description: t("migration.skillsFailedDescription"), closeButton: true, }); console.error("[App] Skills SSOT migration failed:", result.error); return; } if (result && result.count > 0) { toast.success(t("migration.skillsSuccess", { count: result.count }), { closeButton: true, }); await queryClient.invalidateQueries({ queryKey: ["skills"] }); } } catch (error) { console.error("[App] Failed to check skills migration result:", error); } }; checkSkillsMigration(); }, [t, queryClient]); useEffect(() => { const checkEnvOnSwitch = async () => { try { const conflicts = await checkEnvConflicts(activeApp); if (conflicts.length > 0) { setEnvConflicts((prev) => { const existingKeys = new Set( prev.map((c) => `${c.varName}:${c.sourcePath}`), ); const newConflicts = conflicts.filter( (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`), ); return [...prev, ...newConflicts]; }); const dismissed = sessionStorage.getItem("env_banner_dismissed"); if (!dismissed) { setShowEnvBanner(true); } } } catch (error) { console.error( "[App] Failed to check environment conflicts on app switch:", error, ); } }; checkEnvOnSwitch(); }, [activeApp]); const currentViewRef = useRef(currentView); useEffect(() => { currentViewRef.current = currentView; }, [currentView]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "," && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setCurrentView("settings"); return; } if (event.key !== "Escape" || event.defaultPrevented) return; if (document.body.style.overflow === "hidden") return; const view = currentViewRef.current; if (view === "providers") return; if (isTextEditableTarget(event.target)) return; event.preventDefault(); setCurrentView(view === "skillsDiscovery" ? "skills" : "providers"); }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, []); const handleOpenWebsite = async (url: string) => { try { await settingsApi.openExternal(url); } catch (error) { const detail = extractErrorMessage(error) || t("notifications.openLinkFailed", { defaultValue: "链接打开失败", }); toast.error(detail); } }; const handleEditProvider = async (provider: Provider) => { await updateProvider(provider); setEditingProvider(null); }; const handleConfirmAction = async () => { if (!confirmAction) return; const { provider, action } = confirmAction; if (action === "remove") { // Remove from live config only (for additive mode apps like OpenCode/OpenClaw) // Does NOT delete from database - provider remains in the list await providersApi.removeFromLiveConfig(provider.id, activeApp); // Invalidate queries to refresh the isInConfig state if (activeApp === "opencode") { await queryClient.invalidateQueries({ queryKey: ["opencodeLiveProviderIds"], }); } else if (activeApp === "openclaw") { await queryClient.invalidateQueries({ queryKey: openclawKeys.liveProviderIds, }); } toast.success( t("notifications.removeFromConfigSuccess", { defaultValue: "已从配置移除", }), { closeButton: true }, ); } else { await deleteProvider(provider.id); } setConfirmAction(null); }; const generateUniqueOpencodeKey = ( originalKey: string, existingKeys: string[], ): string => { const baseKey = `${originalKey}-copy`; if (!existingKeys.includes(baseKey)) { return baseKey; } let counter = 2; while (existingKeys.includes(`${baseKey}-${counter}`)) { counter++; } return `${baseKey}-${counter}`; }; const handleDuplicateProvider = async (provider: Provider) => { const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; const duplicatedProvider: Omit & { providerKey?: string; } = { name: `${provider.name} copy`, settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 websiteUrl: provider.websiteUrl, category: provider.category, sortIndex: newSortIndex, // 复制原 sortIndex + 1 meta: provider.meta ? JSON.parse(JSON.stringify(provider.meta)) : undefined, // 深拷贝 icon: provider.icon, iconColor: provider.iconColor, }; if (activeApp === "opencode") { const existingKeys = Object.keys(providers); duplicatedProvider.providerKey = generateUniqueOpencodeKey( provider.id, existingKeys, ); } if (provider.sortIndex !== undefined) { const updates = Object.values(providers) .filter( (p) => p.sortIndex !== undefined && p.sortIndex >= newSortIndex! && p.id !== provider.id, ) .map((p) => ({ id: p.id, sortIndex: p.sortIndex! + 1, })); if (updates.length > 0) { try { await providersApi.updateSortOrder(updates, activeApp); } catch (error) { console.error("[App] Failed to update sort order", error); toast.error( t("provider.sortUpdateFailed", { defaultValue: "排序更新失败", }), ); return; // 如果排序更新失败,不继续添加 } } } await addProvider(duplicatedProvider); }; const handleOpenTerminal = async (provider: Provider) => { try { await providersApi.openTerminal(provider.id, activeApp); toast.success( t("provider.terminalOpened", { defaultValue: "终端已打开", }), ); } catch (error) { console.error("[App] Failed to open terminal", error); const errorMessage = extractErrorMessage(error); toast.error( t("provider.terminalOpenFailed", { defaultValue: "打开终端失败", }) + (errorMessage ? `: ${errorMessage}` : ""), ); } }; const handleImportSuccess = async () => { try { await queryClient.invalidateQueries({ queryKey: ["providers"], refetchType: "all", }); await queryClient.refetchQueries({ queryKey: ["providers"], type: "all", }); } catch (error) { console.error("[App] Failed to refresh providers after import", error); await refetch(); } try { await providersApi.updateTrayMenu(); } catch (error) { console.error("[App] Failed to refresh tray menu", error); } }; const renderContent = () => { const content = (() => { switch (currentView) { case "settings": return ( setCurrentView("providers")} onImportSuccess={handleImportSuccess} defaultTab={settingsDefaultTab} /> ); case "prompts": return ( setCurrentView("providers")} appId={activeApp} /> ); case "skills": return ( setCurrentView("skillsDiscovery")} /> ); case "skillsDiscovery": return ( ); case "mcp": return ( setCurrentView("providers")} /> ); case "agents": return ( setCurrentView("providers")} /> ); case "universal": return (
); case "sessions": return ; case "workspace": return ; case "openclawEnv": return ; case "openclawTools": return ; case "openclawAgents": return ; default: return (
{ setEditingProvider(provider); }} onDelete={(provider) => setConfirmAction({ provider, action: "delete" }) } onRemoveFromConfig={ activeApp === "opencode" || activeApp === "openclaw" ? (provider) => setConfirmAction({ provider, action: "remove" }) : undefined } onDisableOmo={ activeApp === "opencode" ? handleDisableOmo : undefined } onDisableOmoSlim={ activeApp === "opencode" ? handleDisableOmoSlim : undefined } onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} onOpenTerminal={ activeApp === "claude" ? handleOpenTerminal : undefined } onCreate={() => setIsAddOpen(true)} onSetAsDefault={ activeApp === "openclaw" ? setAsDefaultModel : undefined } />
); } })(); return ( {content} ); }; return (
{showEnvBanner && envConflicts.length > 0 && ( { setShowEnvBanner(false); sessionStorage.setItem("env_banner_dismissed", "true"); }} onDeleted={async () => { try { const allConflicts = await checkAllEnvConflicts(); const flatConflicts = Object.values(allConflicts).flat(); setEnvConflicts(flatConflicts); if (flatConflicts.length === 0) { setShowEnvBanner(false); } } catch (error) { console.error( "[App] Failed to re-check conflicts after deletion:", error, ); } }} /> )}
{currentView !== "providers" ? (

{currentView === "settings" && t("settings.title")} {currentView === "prompts" && t("prompts.title", { appName: t(`apps.${activeApp}`) })} {currentView === "skills" && t("skills.title")} {currentView === "skillsDiscovery" && t("skills.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "agents" && t("agents.title")} {currentView === "universal" && t("universalProvider.title", { defaultValue: "统一供应商", })} {currentView === "sessions" && t("sessionManager.title")} {currentView === "workspace" && t("workspace.title")} {currentView === "openclawEnv" && t("openclaw.env.title")} {currentView === "openclawTools" && t("openclaw.tools.title")} {currentView === "openclawAgents" && t("openclaw.agents.title")}

) : (
{ setSettingsDefaultTab("about"); setCurrentView("settings"); }} /> {isCurrentAppTakeoverActive && ( )}
)}
{currentView === "providers" && activeApp !== "opencode" && activeApp !== "openclaw" && settingsData?.enableLocalProxy && (
)}
{currentView === "prompts" && ( )} {currentView === "mcp" && ( <> )} {currentView === "skills" && ( <> )} {currentView === "skillsDiscovery" && ( <> )} {currentView === "providers" && ( <>
{activeApp === "openclaw" ? ( <> ) : ( <> )}
)}
{renderContent()}
{ if (!open) { setEditingProvider(null); } }} onSubmit={handleEditProvider} appId={activeApp} isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} /> {effectiveUsageProvider && ( setUsageProvider(null)} onSave={(script) => { if (usageProvider) { void saveUsageScript(usageProvider, script); } }} /> )} void handleConfirmAction()} onCancel={() => setConfirmAction(null)} />
); } export default App;