Files
CC-Switch/src/App.tsx
T
Thefool 1c0ee0c58a feat(grokbuild): add first-class Grok Build support (#5453)
* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
2026-07-17 15:50:50 +08:00

1674 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 { useAutoCompact } from "@/hooks/useAutoCompact";
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);
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);
// 订阅未管理 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
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="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}
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"
: 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;