mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
fda88b2e42
Change `ProviderMeta.live_config_managed` from `bool` to `Option<bool>` to introduce a three-state semantic: - `Some(true)`: provider has been written to live config - `Some(false)`: explicitly db-only, never written to live config - `None`: legacy data or unknown state (pre-existing providers) Previously, legacy providers defaulted to `live_config_managed = false` via `#[serde(default)]`, which silently swallowed live config parse errors. This could mask genuine configuration issues for providers that had actually been synced to live config before the field was introduced. Now, only providers with an explicit `Some(false)` marker tolerate parse errors; legacy `None` providers surface errors as before, preserving safety for already-managed configurations. Also wrap the `ensureQueryData` call for live provider IDs during duplication in a try/catch so that a malformed config file shows a user-facing toast instead of silently failing. Add tests for both the legacy error propagation path and the frontend duplication failure scenario.
1348 lines
46 KiB
TypeScript
1348 lines
46 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 { listen } from "@tauri-apps/api/event";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Plus,
|
|
Settings,
|
|
ArrowLeft,
|
|
Book,
|
|
Wrench,
|
|
RefreshCw,
|
|
History,
|
|
BarChart2,
|
|
Download,
|
|
FolderArchive,
|
|
Search,
|
|
FolderOpen,
|
|
KeyRound,
|
|
Shield,
|
|
Cpu,
|
|
} from "lucide-react";
|
|
import type { Provider, VisibleApps } from "@/types";
|
|
import type { EnvConflict } from "@/types/env";
|
|
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
|
|
import {
|
|
providersApi,
|
|
settingsApi,
|
|
type AppId,
|
|
type ProviderSwitchEvent,
|
|
} from "@/lib/api";
|
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
|
import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
|
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
|
import { cn } from "@/lib/utils";
|
|
import { isWindows, isLinux } from "@/lib/platform";
|
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
|
import { ProviderList } from "@/components/providers/ProviderList";
|
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
|
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
|
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
|
|
import { FailoverToggle } from "@/components/proxy/FailoverToggle";
|
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
|
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
|
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
|
import { UniversalProviderPanel } from "@/components/universal";
|
|
import { McpIcon } from "@/components/BrandIcons";
|
|
import { Button } from "@/components/ui/button";
|
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
|
import {
|
|
useDisableCurrentOmo,
|
|
useDisableCurrentOmoSlim,
|
|
} from "@/lib/query/omo";
|
|
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
|
import EnvPanel from "@/components/openclaw/EnvPanel";
|
|
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
|
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
|
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
|
|
|
|
type View =
|
|
| "providers"
|
|
| "settings"
|
|
| "prompts"
|
|
| "skills"
|
|
| "skillsDiscovery"
|
|
| "mcp"
|
|
| "agents"
|
|
| "universal"
|
|
| "sessions"
|
|
| "workspace"
|
|
| "openclawEnv"
|
|
| "openclawTools"
|
|
| "openclawAgents";
|
|
|
|
interface WebDavSyncStatusUpdatedPayload {
|
|
source?: string;
|
|
status?: string;
|
|
error?: string;
|
|
}
|
|
|
|
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
|
const HEADER_HEIGHT = 64; // px
|
|
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
|
|
|
const STORAGE_KEY = "cc-switch-last-app";
|
|
const VALID_APPS: AppId[] = [
|
|
"claude",
|
|
"codex",
|
|
"gemini",
|
|
"opencode",
|
|
"openclaw",
|
|
];
|
|
|
|
const getInitialApp = (): AppId => {
|
|
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
|
if (saved && VALID_APPS.includes(saved)) {
|
|
return saved;
|
|
}
|
|
return "claude";
|
|
};
|
|
|
|
const VIEW_STORAGE_KEY = "cc-switch-last-view";
|
|
const VALID_VIEWS: View[] = [
|
|
"providers",
|
|
"settings",
|
|
"prompts",
|
|
"skills",
|
|
"skillsDiscovery",
|
|
"mcp",
|
|
"agents",
|
|
"universal",
|
|
"sessions",
|
|
"workspace",
|
|
"openclawEnv",
|
|
"openclawTools",
|
|
"openclawAgents",
|
|
];
|
|
|
|
const getInitialView = (): View => {
|
|
const saved = localStorage.getItem(VIEW_STORAGE_KEY) as View | null;
|
|
if (saved && VALID_VIEWS.includes(saved)) {
|
|
return saved;
|
|
}
|
|
return "providers";
|
|
};
|
|
|
|
function App() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
|
|
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
|
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
|
|
}, [currentView]);
|
|
|
|
const { data: settingsData } = useSettingsQuery();
|
|
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
|
claude: true,
|
|
codex: true,
|
|
gemini: true,
|
|
opencode: true,
|
|
openclaw: true,
|
|
};
|
|
|
|
const getFirstVisibleApp = (): AppId => {
|
|
if (visibleApps.claude) return "claude";
|
|
if (visibleApps.codex) return "codex";
|
|
if (visibleApps.gemini) return "gemini";
|
|
if (visibleApps.opencode) return "opencode";
|
|
if (visibleApps.openclaw) return "openclaw";
|
|
return "claude"; // fallback
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!visibleApps[activeApp]) {
|
|
setActiveApp(getFirstVisibleApp());
|
|
}
|
|
}, [visibleApps, activeApp]);
|
|
|
|
// Fallback from sessions view when switching to an app without session support
|
|
useEffect(() => {
|
|
if (
|
|
currentView === "sessions" &&
|
|
activeApp !== "claude" &&
|
|
activeApp !== "codex" &&
|
|
activeApp !== "opencode" &&
|
|
activeApp !== "openclaw" &&
|
|
activeApp !== "gemini"
|
|
) {
|
|
setCurrentView("providers");
|
|
}
|
|
}, [activeApp, currentView]);
|
|
|
|
const [editingProvider, setEditingProvider] = useState<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);
|
|
|
|
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 = true;
|
|
const hasSessionSupport =
|
|
activeApp === "claude" ||
|
|
activeApp === "codex" ||
|
|
activeApp === "opencode" ||
|
|
activeApp === "openclaw" ||
|
|
activeApp === "gemini";
|
|
|
|
const {
|
|
addProvider,
|
|
updateProvider,
|
|
switchProvider,
|
|
deleteProvider,
|
|
saveUsageScript,
|
|
setAsDefaultModel,
|
|
} = useProviderActions(activeApp, isProxyRunning);
|
|
|
|
const disableOmoMutation = useDisableCurrentOmo();
|
|
const handleDisableOmo = () => {
|
|
disableOmoMutation.mutate(undefined, {
|
|
onSuccess: () => {
|
|
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(
|
|
t("omo.disableFailed", {
|
|
defaultValue: "停用 OMO 失败: {{error}}",
|
|
error: extractErrorMessage(error),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
};
|
|
|
|
const disableOmoSlimMutation = useDisableCurrentOmoSlim();
|
|
const handleDisableOmoSlim = () => {
|
|
disableOmoSlimMutation.mutate(undefined, {
|
|
onSuccess: () => {
|
|
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(
|
|
t("omo.disableFailed", {
|
|
defaultValue: "停用 OMO 失败: {{error}}",
|
|
error: extractErrorMessage(error),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
let unsubscribe: (() => void) | undefined;
|
|
|
|
const setupListener = async () => {
|
|
try {
|
|
unsubscribe = await providersApi.onSwitched(
|
|
async (event: ProviderSwitchEvent) => {
|
|
if (event.appType === activeApp) {
|
|
await refetch();
|
|
}
|
|
},
|
|
);
|
|
} catch (error) {
|
|
console.error("[App] Failed to subscribe provider switch event", error);
|
|
}
|
|
};
|
|
|
|
setupListener();
|
|
return () => {
|
|
unsubscribe?.();
|
|
};
|
|
}, [activeApp, refetch]);
|
|
|
|
useEffect(() => {
|
|
let unsubscribe: (() => void) | undefined;
|
|
|
|
const setupListener = async () => {
|
|
try {
|
|
const { listen } = await import("@tauri-apps/api/event");
|
|
unsubscribe = await listen("universal-provider-synced", async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
|
try {
|
|
await providersApi.updateTrayMenu();
|
|
} catch (error) {
|
|
console.error("[App] Failed to update tray menu", error);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to subscribe universal-provider-synced event",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
setupListener();
|
|
return () => {
|
|
unsubscribe?.();
|
|
};
|
|
}, [queryClient]);
|
|
|
|
useEffect(() => {
|
|
let unsubscribe: (() => void) | undefined;
|
|
let active = true;
|
|
|
|
const setupListener = async () => {
|
|
try {
|
|
const off = await listen(
|
|
"webdav-sync-status-updated",
|
|
async (event) => {
|
|
const payload = (event.payload ??
|
|
{}) as WebDavSyncStatusUpdatedPayload;
|
|
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
|
|
|
if (payload.source !== "auto" || payload.status !== "error") {
|
|
return;
|
|
}
|
|
|
|
toast.error(
|
|
t("settings.webdavSync.autoSyncFailedToast", {
|
|
error: payload.error || t("common.unknown"),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
if (!active) {
|
|
off();
|
|
return;
|
|
}
|
|
unsubscribe = off;
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to subscribe webdav-sync-status-updated event",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
void setupListener();
|
|
return () => {
|
|
active = false;
|
|
unsubscribe?.();
|
|
};
|
|
}, [queryClient, t]);
|
|
|
|
useEffect(() => {
|
|
const checkEnvOnStartup = async () => {
|
|
try {
|
|
const allConflicts = await checkAllEnvConflicts();
|
|
const flatConflicts = Object.values(allConflicts).flat();
|
|
|
|
if (flatConflicts.length > 0) {
|
|
setEnvConflicts(flatConflicts);
|
|
const dismissed = sessionStorage.getItem("env_banner_dismissed");
|
|
if (!dismissed) {
|
|
setShowEnvBanner(true);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[App] Failed to check environment conflicts on startup:",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
checkEnvOnStartup();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const checkMigration = async () => {
|
|
try {
|
|
const migrated = await invoke<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 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,
|
|
});
|
|
}
|
|
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: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
|
websiteUrl: provider.websiteUrl,
|
|
category: provider.category,
|
|
sortIndex: newSortIndex, // 复制原 sortIndex + 1
|
|
meta: provider.meta
|
|
? JSON.parse(JSON.stringify(provider.meta))
|
|
: undefined, // 深拷贝
|
|
icon: provider.icon,
|
|
iconColor: provider.iconColor,
|
|
};
|
|
|
|
if (activeApp === "opencode" || activeApp === "openclaw") {
|
|
let liveProviderIds: string[] = [];
|
|
try {
|
|
liveProviderIds =
|
|
activeApp === "opencode"
|
|
? await queryClient.ensureQueryData({
|
|
queryKey: ["opencodeLiveProviderIds"],
|
|
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
|
})
|
|
: await queryClient.ensureQueryData({
|
|
queryKey: openclawKeys.liveProviderIds,
|
|
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
|
|
});
|
|
} 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 {
|
|
await providersApi.openTerminal(provider.id, activeApp);
|
|
toast.success(
|
|
t("provider.terminalOpened", {
|
|
defaultValue: "终端已打开",
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
console.error("[App] Failed to open terminal", error);
|
|
const errorMessage = extractErrorMessage(error);
|
|
toast.error(
|
|
t("provider.terminalOpenFailed", {
|
|
defaultValue: "打开终端失败",
|
|
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleImportSuccess = async () => {
|
|
try {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["providers"],
|
|
refetchType: "all",
|
|
});
|
|
await queryClient.refetchQueries({
|
|
queryKey: ["providers"],
|
|
type: "all",
|
|
});
|
|
} catch (error) {
|
|
console.error("[App] Failed to refresh providers after import", error);
|
|
await refetch();
|
|
}
|
|
try {
|
|
await providersApi.updateTrayMenu();
|
|
} catch (error) {
|
|
console.error("[App] Failed to refresh tray menu", error);
|
|
}
|
|
};
|
|
|
|
const renderContent = () => {
|
|
const content = (() => {
|
|
switch (currentView) {
|
|
case "settings":
|
|
return (
|
|
<SettingsPage
|
|
open={true}
|
|
onOpenChange={() => setCurrentView("providers")}
|
|
onImportSuccess={handleImportSuccess}
|
|
defaultTab={settingsDefaultTab}
|
|
/>
|
|
);
|
|
case "prompts":
|
|
return (
|
|
<PromptPanel
|
|
ref={promptPanelRef}
|
|
open={true}
|
|
onOpenChange={() => setCurrentView("providers")}
|
|
appId={activeApp}
|
|
/>
|
|
);
|
|
case "skills":
|
|
return (
|
|
<UnifiedSkillsPanel
|
|
ref={unifiedSkillsPanelRef}
|
|
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
|
currentApp={activeApp === "openclaw" ? "claude" : activeApp}
|
|
/>
|
|
);
|
|
case "skillsDiscovery":
|
|
return (
|
|
<SkillsPage
|
|
ref={skillsPageRef}
|
|
initialApp={activeApp === "openclaw" ? "claude" : activeApp}
|
|
/>
|
|
);
|
|
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={activeApp} appId={activeApp} />;
|
|
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"
|
|
? (provider) =>
|
|
setConfirmAction({ provider, action: "remove" })
|
|
: undefined
|
|
}
|
|
onDisableOmo={
|
|
activeApp === "opencode" ? handleDisableOmo : undefined
|
|
}
|
|
onDisableOmoSlim={
|
|
activeApp === "opencode"
|
|
? handleDisableOmoSlim
|
|
: undefined
|
|
}
|
|
onDuplicate={handleDuplicateProvider}
|
|
onConfigureUsage={setUsageProvider}
|
|
onOpenWebsite={handleOpenWebsite}
|
|
onOpenTerminal={
|
|
activeApp === "claude" ? handleOpenTerminal : undefined
|
|
}
|
|
onCreate={() => setIsAddOpen(true)}
|
|
onSetAsDefault={
|
|
activeApp === "openclaw" ? setAsDefaultModel : undefined
|
|
}
|
|
/>
|
|
</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"
|
|
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
|
>
|
|
<div
|
|
className="fixed top-0 left-0 right-0 z-[60]"
|
|
data-tauri-drag-region
|
|
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
|
/>
|
|
{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"
|
|
data-tauri-drag-region
|
|
style={
|
|
{
|
|
WebkitAppRegion: "drag",
|
|
top: DRAG_BAR_HEIGHT,
|
|
height: HEADER_HEIGHT,
|
|
} as any
|
|
}
|
|
>
|
|
<div
|
|
className="flex h-full items-center justify-between gap-2 px-6"
|
|
data-tauri-drag-region
|
|
style={{ WebkitAppRegion: "drag" } 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.${activeApp}`) })}
|
|
{currentView === "skills" && t("skills.title")}
|
|
{currentView === "skillsDiscovery" && t("skills.title")}
|
|
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
|
{currentView === "agents" && t("agents.title")}
|
|
{currentView === "universal" &&
|
|
t("universalProvider.title", {
|
|
defaultValue: "统一供应商",
|
|
})}
|
|
{currentView === "sessions" && t("sessionManager.title")}
|
|
{currentView === "workspace" && t("workspace.title")}
|
|
{currentView === "openclawEnv" && t("openclaw.env.title")}
|
|
{currentView === "openclawTools" && t("openclaw.tools.title")}
|
|
{currentView === "openclawAgents" &&
|
|
t("openclaw.agents.title")}
|
|
</h1>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative inline-flex items-center">
|
|
<a
|
|
href="https://github.com/farion1231/cc-switch"
|
|
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" && (
|
|
<div
|
|
className="flex shrink-0 items-center gap-1.5"
|
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
|
>
|
|
{settingsData?.enableLocalProxy && (
|
|
<ProxyToggle activeApp={activeApp} />
|
|
)}
|
|
{settingsData?.enableFailoverToggle && (
|
|
<FailoverToggle activeApp={activeApp} />
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={toolbarRef}
|
|
className="flex flex-1 min-w-0 overflow-x-hidden items-center"
|
|
>
|
|
<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" : "default"
|
|
}
|
|
className="flex items-center gap-1"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
{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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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={isProxyRunning && 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)}
|
|
/>
|
|
|
|
<DeepLinkImportDialog />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|