Files
CC-Switch/src/App.tsx
T
Jason a04e72a267 Fix Codex edit dialog masking live OAuth during proxy takeover
The reported "OAuth access token disappears when enabling Codex proxy
takeover" was a display artifact, not data loss: auth.json on disk kept
the OAuth login the whole time. During takeover the edit dialog falls
back to the stored provider config (so it does not surface the proxy
placeholder), which for a third-party provider shows that provider's own
key instead of the live auth.json, making the OAuth token look gone.

Thread an isProxyTakeover flag from App through ProviderForm into the
Codex editor and show an explicit notice plus storage-aware auth/config
hints clarifying that the form displays the stored provider config while
the live config is temporarily managed by the proxy. Drop the
proxy-running condition so the notice shows whenever takeover is active,
even with the proxy stopped.

Add a regression test asserting the dialog does not read live settings
during takeover and renders the database config. i18n synced across
en/zh/ja/zh-TW.
2026-06-01 11:41:05 +08:00

1605 lines
55 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,
RefreshCw,
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 { useAutoCompact } from "@/hooks/useAutoCompact";
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import { useLastValidValue } from "@/hooks/useLastValidValue";
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 { 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 } 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 WebDavSyncStatusUpdatedPayload {
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",
"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 [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,
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.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 !== "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);
const toolbarRef = useRef<HTMLDivElement>(null);
const isToolbarCompact = useAutoCompact(toolbarRef);
useUsageCacheBridge();
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const unifiedSkillsPanelRef = useRef<any>(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 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 === "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);
}
});
useTauriEvent<WebDavSyncStatusUpdatedPayload | 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<{ 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 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={() => setCurrentView("skillsDiscovery")}
currentApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
initialApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
/>
);
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>
)}
<div
ref={toolbarRef}
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="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={isToolbarCompact}
/>
<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"
: "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;