Files
CC-Switch/src/App.tsx
T
Jason f5f4281d06 refactor(ui): always show icon-only app switcher, drop auto-compact
The toolbar app switcher used a ResizeObserver-based overflow detection
(useAutoCompact) to collapse app labels when space ran out. With the
number of managed apps growing, the labels are collapsed in practice
anyway, so remove the mechanism and render icons only. Buttons now carry
title/aria-label so app names remain discoverable via tooltip and
accessible to screen readers.
2026-07-30 08:52:48 +08:00

1666 lines
58 KiB
TypeScript

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 { useQueryClient } from "@tanstack/react-query";
import {
Plus,
Settings,
ArrowLeft,
Minus,
Maximize2,
Minimize2,
X,
Book,
Brain,
Wrench,
History,
BarChart2,
Download,
FolderArchive,
Search,
FolderOpen,
KeyRound,
Shield,
Cpu,
LayoutDashboard,
} from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";
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, useOpenClawHealth } from "@/hooks/useOpenClaw";
import { hermesKeys, useOpenHermesWebUI } from "@/hooks/useHermes";
import { hermesApi } from "@/lib/api/hermes";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { useScanUnmanagedSkills } from "@/hooks/useSkills";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { deepClone } from "@/utils/deepClone";
import { cn } from "@/lib/utils";
import {
isWindows,
isLinux,
DRAG_REGION_ATTR,
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher";
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 { ClaudeDesktopRouteToggle } from "@/components/proxy/ClaudeDesktopRouteToggle";
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,
getSkillsPageHeaderActions,
type SkillsPageSource,
} from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
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";
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel";
type View =
| "providers"
| "settings"
| "prompts"
| "skills"
| "skillsDiscovery"
| "mcp"
| "agents"
| "universal"
| "sessions"
| "workspace"
| "openclawEnv"
| "openclawTools"
| "openclawAgents"
| "hermesMemory";
interface SyncStatusUpdatedPayload {
source?: string;
status?: string;
error?: string;
}
const DEFAULT_DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = [
"claude",
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
];
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",
"hermesMemory",
];
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<AppId>(getInitialApp);
const sharedFeatureApp: AppId =
activeApp === "claude-desktop" ? "claude" : activeApp;
const [currentView, setCurrentView] = useState<View>(getInitialView);
const [skillsDiscoverySource, setSkillsDiscoverySource] =
useState<SkillsPageSource>("repos");
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
const { data: settingsData } = useSettingsQuery();
const useAppWindowControls =
isLinux() && (settingsData?.useAppWindowControls ?? false);
const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT;
const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
};
const getFirstVisibleApp = (): AppId => {
if (visibleApps.claude) return "claude";
if (visibleApps["claude-desktop"]) return "claude-desktop";
if (visibleApps.codex) return "codex";
if (visibleApps.gemini) return "gemini";
if (visibleApps.grokbuild) return "grokbuild";
if (visibleApps.opencode) return "opencode";
if (visibleApps.openclaw) return "openclaw";
if (visibleApps.hermes) return "hermes";
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" &&
sharedFeatureApp !== "claude" &&
sharedFeatureApp !== "codex" &&
sharedFeatureApp !== "grokbuild" &&
sharedFeatureApp !== "opencode" &&
sharedFeatureApp !== "openclaw" &&
sharedFeatureApp !== "gemini" &&
sharedFeatureApp !== "hermes"
) {
setCurrentView("providers");
}
}, [sharedFeatureApp, currentView]);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmAction, setConfirmAction] = useState<{
provider: Provider;
action: "remove" | "delete";
} | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
useUsageCacheBridge();
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const unifiedSkillsPanelRef = useRef<any>(null);
// 订阅未管理 Skill 的共享缓存(实际扫描由 UnifiedSkillsPanel 进入页面时触发)。
// 这里 enabled 默认 false,仅用于「导入」按钮的绿点提示,不主动发起扫描。
const { data: unmanagedSkills } = useScanUnmanagedSkills();
const hasUnmanagedSkills = (unmanagedSkills?.length ?? 0) > 0;
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 isOpenClawView =
activeApp === "openclaw" &&
(currentView === "providers" ||
currentView === "workspace" ||
currentView === "sessions" ||
currentView === "openclawEnv" ||
currentView === "openclawTools" ||
currentView === "openclawAgents");
const { data: openclawHealthWarnings = [] } =
useOpenClawHealth(isOpenClawView);
const hasSkillsSupport = sharedFeatureApp !== "openclaw";
const hasSessionSupport =
sharedFeatureApp === "claude" ||
sharedFeatureApp === "codex" ||
sharedFeatureApp === "grokbuild" ||
sharedFeatureApp === "opencode" ||
sharedFeatureApp === "openclaw" ||
sharedFeatureApp === "gemini" ||
sharedFeatureApp === "hermes";
const {
addProvider,
updateProvider,
switchProvider,
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(
activeApp,
isProxyRunning,
isProxyRunning && isCurrentAppTakeoverActive,
);
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;
let active = true;
const setupListener = async () => {
try {
const off = await providersApi.onSwitched(
async (event: ProviderSwitchEvent) => {
if (event.appType === activeApp) {
await refetch();
}
},
);
if (!active) {
off();
return;
}
unsubscribe = off;
} catch (error) {
console.error("[App] Failed to subscribe provider switch event", error);
}
};
void setupListener();
return () => {
active = false;
unsubscribe?.();
};
}, [activeApp, refetch]);
useTauriEvent("universal-provider-synced", async () => {
await queryClient.invalidateQueries({ queryKey: ["providers"] });
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to update tray menu", error);
}
});
// 应用项目后刷新相关缓存(providers 由既有 provider-switched 监听承接;
// proxy 状态由后端直接改 DB,不走 mutation,必须显式刷新)
useTauriEvent("profile-applied", async () => {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
await queryClient.invalidateQueries({ queryKey: ["skills"] });
await queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
await queryClient.invalidateQueries({
queryKey: ["providers", "claude-desktop"],
});
});
useTauriEvent<SyncStatusUpdatedPayload | null | undefined>(
"webdav-sync-status-updated",
async (payload) => {
const statusPayload = payload ?? {};
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (statusPayload.source !== "auto" || statusPayload.status !== "error") {
return;
}
toast.error(
t("settings.webdavSync.autoSyncFailedToast", {
error: statusPayload.error || t("common.unknown"),
}),
);
},
);
useTauriEvent<SyncStatusUpdatedPayload | null | undefined>(
"s3-sync-status-updated",
async (payload) => {
const statusPayload = payload ?? {};
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (statusPayload.source !== "auto" || statusPayload.status !== "error") {
return;
}
toast.error(
t("settings.s3Sync.autoSyncFailedToast", {
error: statusPayload.error || t("common.unknown"),
}),
);
},
);
useTauriEvent<{ appType: string; providerName: string }>(
"proxy-official-warning",
(payload) => {
toast.warning(
t("notifications.proxyOfficialWarning", {
name: payload.providerName,
defaultValue: `当前供应商 ${payload.providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
}),
{ duration: 8000 },
);
},
);
useEffect(() => {
let active = true;
let unlistenResize: (() => void) | undefined;
const setupWindowStateSync = async () => {
try {
const currentWindow = getCurrentWindow();
const syncWindowMaximizedState = async () => {
const maximized = await currentWindow.isMaximized();
if (active) {
setIsWindowMaximized(maximized);
}
};
await syncWindowMaximizedState();
unlistenResize = await currentWindow.onResized(() => {
void syncWindowMaximizedState();
});
} catch (error) {
console.error("[App] Failed to sync window maximized state", error);
}
};
void setupWindowStateSync();
return () => {
active = false;
unlistenResize?.();
};
}, []);
useEffect(() => {
// settingsData 未加载时跳过,避免用 fallback false 覆盖 Rust 侧已设好的装饰状态
if (!settingsData) return;
const syncWindowDecorations = async () => {
try {
await getCurrentWindow().setDecorations(!useAppWindowControls);
} catch (error) {
console.error("[App] Failed to update window decorations", error);
}
};
void syncWindowDecorations();
}, [useAppWindowControls, settingsData]);
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<boolean>("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 [launchDashboardOpen, setLaunchDashboardOpen] = useState(false);
const openHermesWebUI = useOpenHermesWebUI(() =>
setLaunchDashboardOpen(true),
);
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,
originalId,
}: {
provider: Provider;
originalId?: string;
}) => {
await updateProvider(provider, originalId);
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,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
} else if (activeApp === "hermes") {
await queryClient.invalidateQueries({
queryKey: hermesKeys.liveProviderIds,
});
}
toast.success(
t("notifications.removeFromConfigSuccess", {
defaultValue: "已从配置移除",
}),
{ closeButton: true },
);
} else {
await deleteProvider(provider.id);
}
setConfirmAction(null);
};
const generateUniqueProviderCopyKey = (
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<Provider, "id" | "createdAt"> & {
providerKey?: string;
addToLive?: boolean;
} = {
name: `${provider.name} copy`,
settingsConfig: deepClone(provider.settingsConfig),
websiteUrl: provider.websiteUrl,
category: provider.category,
sortIndex: newSortIndex, // 复制原 sortIndex + 1
meta: provider.meta ? deepClone(provider.meta) : undefined,
icon: provider.icon,
iconColor: provider.iconColor,
};
if (
activeApp === "opencode" ||
activeApp === "openclaw" ||
activeApp === "hermes"
) {
let liveProviderIds: string[] = [];
try {
liveProviderIds =
activeApp === "opencode"
? await queryClient.ensureQueryData({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
})
: activeApp === "openclaw"
? await queryClient.ensureQueryData({
queryKey: openclawKeys.liveProviderIds,
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
})
: await queryClient.ensureQueryData({
queryKey: hermesKeys.liveProviderIds,
queryFn: () => providersApi.getHermesLiveProviderIds(),
});
} catch (error) {
console.error(
"[App] Failed to load live provider IDs for duplication",
error,
);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.duplicateLiveIdsLoadFailed", {
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
return;
}
const existingKeys = Array.from(
new Set([...Object.keys(providers), ...liveProviderIds]),
);
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
provider.id,
existingKeys,
);
duplicatedProvider.addToLive = false;
}
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 {
const selectedDir = await settingsApi.pickDirectory();
if (!selectedDir) {
return;
}
await providersApi.openTerminal(provider.id, activeApp, {
cwd: selectedDir,
});
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 notifyWindowControlError = (error: unknown) => {
toast.error(
t("notifications.windowControlFailed", {
defaultValue: "窗口控制失败:{{error}}",
error: extractErrorMessage(error),
}),
);
};
const handleWindowMinimize = async () => {
try {
await getCurrentWindow().minimize();
} catch (error) {
console.error("[App] Failed to minimize window", error);
notifyWindowControlError(error);
}
};
const handleWindowToggleMaximize = async () => {
try {
const currentWindow = getCurrentWindow();
await currentWindow.toggleMaximize();
setIsWindowMaximized(await currentWindow.isMaximized());
} catch (error) {
console.error("[App] Failed to toggle maximize", error);
notifyWindowControlError(error);
}
};
const handleWindowClose = async () => {
try {
await getCurrentWindow().close();
} catch (error) {
console.error("[App] Failed to close window", error);
notifyWindowControlError(error);
}
};
const handleOpenSkillsDiscovery = () => {
setSkillsDiscoverySource("repos");
setCurrentView("skillsDiscovery");
};
const renderContent = () => {
const content = (() => {
switch (currentView) {
case "settings":
return (
<SettingsPage
open={true}
onOpenChange={() => setCurrentView("providers")}
onImportSuccess={handleImportSuccess}
defaultTab={settingsDefaultTab}
/>
);
case "prompts":
return (
<PromptPanel
ref={promptPanelRef}
open={true}
onOpenChange={() => setCurrentView("providers")}
appId={sharedFeatureApp}
/>
);
case "hermesMemory":
return <HermesMemoryPanel />;
case "skills":
return (
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={handleOpenSkillsDiscovery}
currentApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
initialApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
onSourceChange={setSkillsDiscoverySource}
/>
);
case "mcp":
return (
<UnifiedMcpPanel
ref={mcpPanelRef}
onOpenChange={() => setCurrentView("providers")}
/>
);
case "agents":
return (
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
);
case "universal":
return (
<div className="px-6 pt-4">
<UniversalProviderPanel />
</div>
);
case "sessions":
return (
<SessionManagerPage
key={sharedFeatureApp}
appId={sharedFeatureApp}
/>
);
case "workspace":
return <WorkspaceFilesPanel />;
case "openclawEnv":
return <EnvPanel />;
case "openclawTools":
return <ToolsPanel />;
case "openclawAgents":
return <AgentsDefaultsPanel />;
default:
return (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<AnimatePresence mode="wait">
<motion.div
key={activeApp}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="space-y-4"
>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={
isProxyRunning && isCurrentAppTakeoverActive
}
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={(provider) => {
setEditingProvider(provider);
}}
onDelete={(provider) =>
setConfirmAction({ provider, action: "delete" })
}
onRemoveFromConfig={
activeApp === "opencode" ||
activeApp === "openclaw" ||
activeApp === "hermes"
? (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
: activeApp === "hermes"
? switchProvider
: undefined
}
/>
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
})();
return (
<AnimatePresence mode="wait">
<motion.div
key={currentView}
className="flex-1 min-h-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{content}
</motion.div>
</AnimatePresence>
);
};
return (
<div
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30 pb-4"
style={{ overflowX: "hidden", paddingTop: contentTopOffset }}
>
{(dragBarHeight > 0 || useAppWindowControls) && (
<div
className="fixed top-0 left-0 right-0 z-[70] flex items-center justify-end px-2"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: dragBarHeight } as any}
>
{useAppWindowControls && (
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowMinimize()}
title={t("header.windowMinimize")}
className="h-7 w-7"
>
<Minus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowToggleMaximize()}
title={
isWindowMaximized
? t("header.windowRestore")
: t("header.windowMaximize")
}
className="h-7 w-7"
>
{isWindowMaximized ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowClose()}
title={t("header.windowClose")}
className="h-7 w-7 hover:bg-red-500/15 hover:text-red-500"
>
<X className="w-4 h-4" />
</Button>
</div>
)}
</div>
)}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => {
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,
);
}
}}
/>
)}
<header
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
{...DRAG_REGION_ATTR}
style={
{
...DRAG_REGION_STYLE,
top: dragBarHeight,
height: HEADER_HEIGHT,
} as any
}
>
<div
className="flex h-full items-center justify-between gap-2 px-6"
{...DRAG_REGION_ATTR}
style={{ ...DRAG_REGION_STYLE } as any}
>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView !== "providers" ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() =>
setCurrentView(
currentView === "skillsDiscovery"
? "skills"
: "providers",
)
}
className="mr-2 rounded-lg"
>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")}
{currentView === "prompts" &&
t("prompts.title", {
appName: t(`apps.${sharedFeatureApp}`),
})}
{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")}
{currentView === "hermesMemory" && t("hermes.memory.title")}
</h1>
</div>
) : (
<div className="flex items-center gap-2">
<div className="relative inline-flex items-center">
<a
href="https://ccswitch.io"
target="_blank"
rel="noreferrer"
className={cn(
"text-xl font-semibold transition-colors",
isProxyRunning && isCurrentAppTakeoverActive
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
)}
>
CC Switch
</a>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSettingsDefaultTab("general");
setCurrentView("settings");
}}
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4" />
</Button>
<UpdateBadge
onClick={() => {
setSettingsDefaultTab("about");
setCurrentView("settings");
}}
/>
{isCurrentAppTakeoverActive && (
<Button
variant="ghost"
size="icon"
onClick={() => {
setSettingsDefaultTab("usage");
setCurrentView("settings");
}}
title={t("usage.title", {
defaultValue: "使用统计",
})}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<BarChart2 className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
<div className="flex flex-1 min-w-0 items-center justify-end gap-1.5">
{currentView === "providers" &&
activeApp !== "opencode" &&
activeApp !== "openclaw" &&
activeApp !== "hermes" && (
<div
className="flex shrink-0 items-center gap-1.5"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{activeApp === "claude-desktop" ? (
<ClaudeDesktopRouteToggle />
) : (
settingsData?.enableLocalProxy && (
<ProxyToggle activeApp={activeApp} />
)
)}
{activeApp !== "claude-desktop" &&
settingsData?.enableFailoverToggle && (
<FailoverToggle activeApp={activeApp} />
)}
</div>
)}
{currentView === "providers" &&
(settingsData?.showProfileSwitcher ?? true) && (
<div
className="flex shrink-0 items-center"
style={{ WebkitAppRegion: "no-drag" } as any}
>
<ProfileSwitcher activeApp={activeApp} />
</div>
)}
<div className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2">
<div
className="flex shrink-0 items-center gap-1.5 ml-auto"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
variant="ghost"
size="sm"
onClick={() => promptPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button>
)}
{currentView === "mcp" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
</Button>
</>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openRestoreFromBackup()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<History className="w-4 h-4 mr-2" />
{t("skills.restoreFromBackup.button")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openImport()
}
className="relative hover:bg-black/5 dark:hover:bg-white/5"
title={
hasUnmanagedSkills
? t("skills.unmanagedAvailable")
: undefined
}
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
{hasUnmanagedSkills && (
<span
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500"
aria-hidden="true"
/>
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleOpenSkillsDiscovery}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
{getSkillsPageHeaderActions(skillsDiscoverySource).map(
({ key, labelKey, Icon, execute }) => (
<Button
key={key}
variant="ghost"
size="sm"
onClick={() => execute(skillsPageRef.current)}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Icon className="w-4 h-4 mr-2" />
{t(labelKey)}
</Button>
),
)}
</>
)}
{currentView === "providers" && (
<>
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
/>
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
<AnimatePresence mode="wait">
<motion.div
key={
activeApp === "openclaw"
? "openclaw"
: activeApp === "hermes"
? "hermes"
: activeApp === "grokbuild"
? "grokbuild"
: "default"
}
className="flex items-center gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{activeApp === "hermes" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("skills.manage")}
>
<Wrench className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("hermesMemory")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("hermes.memory.title")}
>
<Brain className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void openHermesWebUI()}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("hermes.webui.open")}
>
<LayoutDashboard className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
) : activeApp === "openclaw" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
)}
</motion.div>
</AnimatePresence>
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="w-5 h-5" />
</Button>
</>
)}
</div>
</div>
</div>
</div>
</header>
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
{isOpenClawView && openclawHealthWarnings.length > 0 && (
<OpenClawHealthBanner warnings={openclawHealthWarnings} />
)}
{renderContent()}
</main>
<AddProviderDialog
open={isAddOpen}
onOpenChange={setIsAddOpen}
appId={activeApp}
onSubmit={addProvider}
/>
<EditProviderDialog
open={Boolean(editingProvider)}
provider={effectiveEditingProvider}
onOpenChange={(open) => {
if (!open) {
setEditingProvider(null);
}
}}
onSubmit={handleEditProvider}
appId={activeApp}
isProxyTakeover={isCurrentAppTakeoverActive}
/>
{effectiveUsageProvider && (
<UsageScriptModal
key={effectiveUsageProvider.id}
provider={effectiveUsageProvider}
appId={activeApp}
isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)}
onSave={(script) => {
if (usageProvider) {
void saveUsageScript(usageProvider, script);
}
}}
/>
)}
<ConfirmDialog
isOpen={Boolean(confirmAction)}
title={
confirmAction?.action === "remove"
? t("confirm.removeProvider")
: t("confirm.deleteProvider")
}
message={
confirmAction
? confirmAction.action === "remove"
? t("confirm.removeProviderMessage", {
name: confirmAction.provider.name,
})
: t("confirm.deleteProviderMessage", {
name: confirmAction.provider.name,
})
: ""
}
onConfirm={() => void handleConfirmAction()}
onCancel={() => setConfirmAction(null)}
/>
<ConfirmDialog
isOpen={launchDashboardOpen}
title={t("hermes.webui.launchConfirmTitle")}
message={t("hermes.webui.launchConfirmMessage")}
confirmText={t("hermes.webui.launchConfirmAction")}
variant="info"
onConfirm={() => {
setLaunchDashboardOpen(false);
void (async () => {
try {
await hermesApi.launchDashboard();
toast.success(t("hermes.webui.launching"));
} catch (error) {
toast.error(t("hermes.webui.launchFailed"), {
description: extractErrorMessage(error) || undefined,
});
}
})();
}}
onCancel={() => setLaunchDashboardOpen(false)}
/>
<DeepLinkImportDialog />
<FirstRunNoticeDialog />
</div>
);
}
export default App;