feat(ui): follow the spec for preset versions, tray problems and skills storage

Presets: name the Tencent family "Tencent Cloud" (腾讯云), since it also
covers pay-as-you-go plans in some apps, and let a family list its
versions in design order (Kimi by region, Tencent Lite before Pro).
Families without an order keep the preset file order.

Tray: a failed tray switch no longer expires after ten minutes. It stays
until the app switches successfully or its page is opened in the main
window, reported through the new tray_app_page_seen command.

Skills storage and sync: turn the sheet into the spec's dialog with a
real three-way sync method (auto, symlink only, copy only; auto was
shown as symlink before), radio buttons for where the main copy lives,
the real CC Switch skills path with an Open button, and a visible line
explaining why "Move and switch" is unavailable.
This commit is contained in:
Jason committed 2026-10-03 23:28:58 +08:00
1 parent e9170adb65
commit b7a5ef2885
20 files changed
+727 -413

No files matched your search

+27 -1
View File
@@ -14,7 +14,8 @@ use crate::services::skill::{
use crate::store::AppState;
use std::str::FromStr;
use std::sync::Arc;
use tauri::State;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>);
@@ -178,6 +179,31 @@ pub async fn migrate_skill_storage(
SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string())
}
/// CC Switch 目录下放 Skill 主副本的位置(改过配置目录就是改后的)
fn cc_switch_skills_dir() -> std::path::PathBuf {
crate::config::get_app_config_dir().join("skills")
}
/// 「存储与同步」里显示的 CC Switch 目录路径
#[tauri::command]
pub async fn get_cc_switch_skills_dir() -> Result<String, String> {
Ok(cc_switch_skills_dir().to_string_lossy().to_string())
}
/// 在系统文件管理器里打开 CC Switch 目录下的 Skills 目录(不存在就先建)
#[tauri::command]
pub async fn open_cc_switch_skills_dir(handle: AppHandle) -> Result<bool, String> {
let dir = cc_switch_skills_dir();
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
handle
.opener()
.open_path(dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {e}"))?;
Ok(true)
}
/// 搜索 skills.sh 公共目录
#[tauri::command]
pub async fn search_skills_sh(
+3
View File
@@ -1544,6 +1544,8 @@ pub fn run() {
commands::resync_skills_to_apps,
commands::update_skill,
commands::migrate_skill_storage,
commands::get_cc_switch_skills_dir,
commands::open_cc_switch_skills_dir,
commands::search_skills_sh,
// Skill management (legacy API compatibility)
commands::get_skills,
@@ -1568,6 +1570,7 @@ pub fn run() {
commands::get_app_mode,
commands::take_startup_attach_failures,
tray::take_tray_navigation,
tray::tray_app_page_seen,
commands::exit_proxy_apps_in_mode,
commands::get_direct_provider,
commands::get_proxy_status,
+24 -10
View File
@@ -64,8 +64,6 @@ const MAX_PROBLEM_ROWS: usize = 2;
const MAX_NAME_CHARS: usize = 32;
/// 问题区里切换失败的原因最多几个字。
const MAX_REASON_CHARS: usize = 60;
/// 托盘里切换失败的问题行留多久(之后多半已经在主界面处理过了)。
const SWITCH_FAILURE_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60);
/// 额度剩余不到这个百分比算「快用完」(和前端 `quotaRules.WARN_BELOW_PERCENT` 一致)。
const WARN_BELOW_PERCENT: f64 = 10.0;
@@ -1243,10 +1241,11 @@ static STARTUP_SETTLED: AtomicBool = AtomicBool::new(false);
/// 启动时没能接上、已退回直连的应用(托盘自己留一份,界面照样取走它的那份)。
static ATTACH_FAILURES: Lazy<Mutex<Vec<(AppType, bool)>>> = Lazy::new(|| Mutex::new(Vec::new()));
/// 托盘里切换失败的应用。规格 5.5:处理掉才消失(同一应用之后切换成功、或打开过它的页面),
/// 不按时间过期。
struct SwitchFailure {
app: AppType,
reason: String,
at: std::time::Instant,
}
static SWITCH_FAILURES: Lazy<Mutex<Vec<SwitchFailure>>> = Lazy::new(|| Mutex::new(Vec::new()));
@@ -1275,9 +1274,25 @@ pub fn mark_startup_settled(app: &tauri::AppHandle) {
refresh_tray_menu(app);
}
fn clear_app_problems(app: &AppType) {
lock(&ATTACH_FAILURES).retain(|(existing, _)| existing != app);
lock(&SWITCH_FAILURES).retain(|failure| failure.app != *app);
/// 清掉某个应用的问题行,返回有没有清掉东西。
fn clear_app_problems(app: &AppType) -> bool {
let mut attach = lock(&ATTACH_FAILURES);
let mut switch = lock(&SWITCH_FAILURES);
let before = attach.len() + switch.len();
attach.retain(|(existing, _)| existing != app);
switch.retain(|failure| failure.app != *app);
attach.len() + switch.len() != before
}
/// 主界面打开了某个应用的页面:这个应用的问题行算处理过了(规格 5.5「打开过对应页面」)。
#[tauri::command]
pub fn tray_app_page_seen(app: tauri::AppHandle, app_type: String) {
let Ok(app_type) = app_type.parse::<AppType>() else {
return;
};
if clear_app_problems(&app_type) {
schedule_tray_status_check(&app);
}
}
fn record_switch_failure(app: &AppType, reason: String) {
@@ -1286,7 +1301,6 @@ fn record_switch_failure(app: &AppType, reason: String) {
failures.push(SwitchFailure {
app: app.clone(),
reason,
at: std::time::Instant::now(),
});
}
@@ -1325,8 +1339,7 @@ fn collect_problems(
}
}
{
let mut failures = lock(&SWITCH_FAILURES);
failures.retain(|failure| failure.at.elapsed() < SWITCH_FAILURE_TTL);
let failures = lock(&SWITCH_FAILURES);
for failure in failures.iter() {
if visible.iter().any(|(app, _)| *app == failure.app) {
problems.push(TrayProblem::SwitchFailed {
@@ -4153,7 +4166,8 @@ mod tests {
}
]
);
clear_app_problems(&AppType::Codex);
assert!(clear_app_problems(&AppType::Codex));
assert!(!clear_app_problems(&AppType::Codex));
assert!(collect_problems(None, &rerouted).is_empty());
}
}
+6 -1
View File
@@ -24,7 +24,10 @@ import { hermesApi } from "@/lib/api/hermes";
import type { ProviderEditorSave } from "@/lib/api/providers";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
import { useTrayNavigation } from "@/hooks/useTrayNavigation";
import {
useTrayAppPageSeen,
useTrayNavigation,
} from "@/hooks/useTrayNavigation";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { useSidebarCollapsed } from "@/hooks/useSidebarCollapsed";
@@ -599,6 +602,8 @@ function App() {
}
setCurrentView(page);
};
useTrayAppPageSeen(currentView === "providers" ? activeApp : null);
useTrayNavigation((navigation) => {
if (navigation.section) {
openSettings(navigation.section);
+21 -2
View File
@@ -146,7 +146,11 @@ export function groupPresetRows(entries: PresetEntry[]): PresetRowItem[] {
if (family && versions && versions.length > 1) {
if (done.has(family)) continue;
done.add(family);
rows.push({ key: `family:${family}`, family, versions });
rows.push({
key: `family:${family}`,
family,
versions: sortFamilyVersions(family, versions),
});
} else {
rows.push({ key: entry.id, versions: [entry] });
}
@@ -162,7 +166,22 @@ export function presetVersions(
const family = entry.preset.family;
if (!family) return [entry];
const versions = entries.filter((item) => item.preset.family === family);
return versions.length > 1 ? versions : [entry];
return versions.length > 1 ? sortFamilyVersions(family, versions) : [entry];
}
/** 按 PRESET_FAMILIES 里的 versionOrder 排版本;没列到的保持文件顺序排在后面 */
function sortFamilyVersions(
family: PresetFamilyId,
versions: PresetEntry[],
): PresetEntry[] {
const order: readonly string[] =
(PRESET_FAMILIES[family] as PresetFamilyInfo).versionOrder ?? [];
if (order.length === 0) return versions;
const rank = (entry: PresetEntry) => {
const index = order.indexOf(entry.preset.versionKey ?? "");
return index === -1 ? order.length : index;
};
return [...versions].sort((a, b) => rank(a) - rank(b));
}
export function familyDisplayName(family: PresetFamilyId, t: Translate) {
@@ -1,163 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { skillsApi, type MigrationResult } from "@/lib/api/skills";
import type { SkillStorageLocation } from "@/types";
export interface SkillStorageLocationSettingsProps {
value: SkillStorageLocation;
installedCount: number;
onMigrated: (target: SkillStorageLocation) => void;
}
export function SkillStorageLocationSettings({
value,
installedCount,
onMigrated,
}: SkillStorageLocationSettingsProps) {
const { t } = useTranslation();
const [pendingTarget, setPendingTarget] =
useState<SkillStorageLocation | null>(null);
const [isMigrating, setIsMigrating] = useState(false);
const handleSelect = (target: SkillStorageLocation) => {
if (target === value) return;
if (installedCount > 0) {
setPendingTarget(target);
} else {
doMigrate(target);
}
};
const doMigrate = async (target: SkillStorageLocation) => {
setIsMigrating(true);
setPendingTarget(null);
try {
const result: MigrationResult = await skillsApi.migrateStorage(target);
if (result.errors.length > 0) {
toast.warning(
t("settings.skillStorage.migrationPartial", {
migrated: result.migratedCount,
errors: result.errors.length,
}),
);
} else {
toast.success(
t("settings.skillStorage.migrationSuccess", {
count: result.migratedCount,
}),
);
}
onMigrated(target);
} catch (error) {
toast.error(String(error));
} finally {
setIsMigrating(false);
}
};
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.skillStorage.title")}
</h3>
<p className="text-xs text-fg-2">
{t("settings.skillStorage.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border bg-surface p-1">
<StorageButton
active={value === "cc_switch"}
disabled={isMigrating}
onClick={() => handleSelect("cc_switch")}
>
{t("settings.skillStorage.ccSwitch")}
</StorageButton>
<StorageButton
active={value === "unified"}
disabled={isMigrating}
onClick={() => handleSelect("unified")}
>
{isMigrating && value !== "unified" ? (
<Loader2 size={14} className="mr-1 animate-spin" />
) : null}
{t("settings.skillStorage.unified")}
</StorageButton>
</div>
<p className="text-xs text-fg-2">
{value === "unified"
? t("settings.skillStorage.unifiedHint")
: t("settings.skillStorage.ccSwitchHint")}
</p>
{/* 迁移确认对话框 */}
<Dialog
open={pendingTarget !== null}
onOpenChange={(open) => {
if (!open) setPendingTarget(null);
}}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>{t("settings.skillStorage.confirmTitle")}</DialogTitle>
<DialogDescription>
{t("settings.skillStorage.confirmMessage", {
count: installedCount,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPendingTarget(null)}>
{t("common.cancel")}
</Button>
<Button onClick={() => pendingTarget && doMigrate(pendingTarget)}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
interface StorageButtonProps {
active: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}
function StorageButton({
active,
disabled,
onClick,
children,
}: StorageButtonProps) {
return (
<Button
type="button"
onClick={onClick}
disabled={disabled}
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"min-w-[96px]",
active ? "shadow-sm" : "text-fg-2 hover:text-fg-1 hover:bg-subtle",
)}
>
{children}
</Button>
);
}
@@ -1,76 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { SkillSyncMethod } from "@/types";
export interface SkillSyncMethodSettingsProps {
value: SkillSyncMethod;
onChange: (value: SkillSyncMethod) => void;
}
export function SkillSyncMethodSettings({
value,
onChange,
}: SkillSyncMethodSettingsProps) {
const { t } = useTranslation();
// Handle default values: undefined or "auto" defaults to symlink display
const displayValue = value === "copy" ? "copy" : "symlink";
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.skillSync.title")}</h3>
<p className="text-xs text-fg-2">
{t("settings.skillSync.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border bg-surface p-1">
<SyncMethodButton
active={displayValue === "symlink"}
onClick={() => onChange("symlink")}
>
{t("settings.skillSync.symlink")}
</SyncMethodButton>
<SyncMethodButton
active={displayValue === "copy"}
onClick={() => onChange("copy")}
>
{t("settings.skillSync.copy")}
</SyncMethodButton>
</div>
{displayValue === "symlink" && (
<p className="text-xs text-fg-2">
{t("settings.skillSync.symlinkHint")}
</p>
)}
</section>
);
}
interface SyncMethodButtonProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
function SyncMethodButton({
active,
onClick,
children,
}: SyncMethodButtonProps) {
return (
<Button
type="button"
onClick={onClick}
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"min-w-[96px]",
active ? "shadow-sm" : "text-fg-2 hover:text-fg-1 hover:bg-subtle",
)}
>
{children}
</Button>
);
}
+286 -45
View File
@@ -1,21 +1,25 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
import { useInstalledSkills, useResyncSkillsToApps } from "@/hooks/useSkills";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
useCcSwitchSkillsDir,
useInstalledSkills,
useResyncSkillsToApps,
} from "@/hooks/useSkills";
import { DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
CHECKBOX_CLASS,
V7ConfirmDialog,
V7Dialog,
} from "@/components/mcp/formBits";
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings";
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
import type { SkillAppSyncOutcome } from "@/lib/api/skills";
import { shortenHomePath } from "@/components/sessions/utils";
import { skillsApi, type SkillAppSyncOutcome } from "@/lib/api/skills";
import type { AppId } from "@/lib/api/types";
import type { SkillStorageLocation, SkillSyncMethod } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
interface SkillsStorageSheetProps {
@@ -25,7 +29,33 @@ interface SkillsStorageSheetProps {
const appName = (app: string) => APP_DISPLAY_NAME[app as AppId] ?? app;
/** Skills 页 →「存储与同步」:存在哪、怎么同步到各应用(原来在设置 → 通用)。 */
const DEFAULT_CC_SWITCH_DIR = "~/.cc-switch/skills";
const UNIFIED_DIR = "~/.agents/skills";
/** 和后端 SyncMethod(auto / symlink / copy)一一对应。 */
const SYNC_METHODS: {
id: SkillSyncMethod;
labelKey: string;
descKey?: string;
}[] = [
{ id: "auto", labelKey: "skills.storageSheet.syncAuto" },
{
id: "symlink",
labelKey: "skills.storageSheet.syncSymlink",
descKey: "skills.storageSheet.syncSymlinkDesc",
},
{
id: "copy",
labelKey: "skills.storageSheet.syncCopy",
descKey: "skills.storageSheet.syncCopyDesc",
},
];
const LEGEND_CLASS = "mb-2 p-0 text-body font-semibold text-fg-1";
const RADIO_LABEL_CLASS =
"flex cursor-pointer items-center gap-2.5 text-body text-fg-1 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60";
/** Skills 页 ⋯ →「存储与同步」对话框:主副本放在哪、怎么同步到各应用(原来在设置 → 通用)。 */
export function SkillsStorageSheet({
open,
onOpenChange,
@@ -33,8 +63,90 @@ export function SkillsStorageSheet({
const { t } = useTranslation();
const { settings, updateSettings, autoSaveSettings } = useSettings();
const { data: installedSkills } = useInstalledSkills();
const { data: ccSwitchDirRaw } = useCcSwitchSkillsDir(open);
const resyncMutation = useResyncSkillsToApps();
const location: SkillStorageLocation =
settings?.skillStorageLocation ?? "cc_switch";
const syncMethod: SkillSyncMethod = settings?.skillSyncMethod ?? "auto";
const installedCount = installedSkills?.length ?? 0;
const ccSwitchDir = ccSwitchDirRaw
? shortenHomePath(ccSwitchDirRaw)
: DEFAULT_CC_SWITCH_DIR;
// 单选只是「想换到哪」;点「移动并切换…」才真正迁移
const [pendingLocation, setPendingLocation] =
useState<SkillStorageLocation>(location);
const [confirmMove, setConfirmMove] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
useEffect(() => {
if (open) setPendingLocation(location);
}, [open, location]);
const targetDir = pendingLocation === "unified" ? UNIFIED_DIR : ccSwitchDir;
// 对话框里的禁用原因写成看得见的一行(HELP_BRIEF:菜单 / 对话框例外),不挂悬停气泡
const locationUnchanged = pendingLocation === location;
const doMigrate = async (target: SkillStorageLocation) => {
setIsMigrating(true);
setConfirmMove(false);
const path = target === "unified" ? UNIFIED_DIR : ccSwitchDir;
try {
const result = await skillsApi.migrateStorage(target);
if (result.errors.length > 0) {
toast.warning(
t("skills.storageSheet.movePartial", {
migrated: result.migratedCount,
errors: result.errors.length,
}),
{ description: result.errors.join("\n"), closeButton: true },
);
} else {
toast.success(
t("skills.storageSheet.moveDone", {
count: result.migratedCount,
path,
}),
{ closeButton: true },
);
}
updateSettings({ skillStorageLocation: target });
} catch (error) {
toast.error(t("common.error"), {
description: extractErrorMessage(error) || String(error),
});
} finally {
setIsMigrating(false);
}
};
const handleMove = () => {
if (pendingLocation === location || isMigrating) return;
// 一个 Skill 都没有时没什么可移动的,直接切换
if (installedCount > 0) {
setConfirmMove(true);
} else {
void doMigrate(pendingLocation);
}
};
const handleOpenFolder = async () => {
try {
await skillsApi.openCcSwitchSkillsDir();
} catch (error) {
toast.error(t("common.error"), {
description: extractErrorMessage(error) || String(error),
});
}
};
const handleSyncMethod = (method: SkillSyncMethod) => {
if (method === syncMethod) return;
updateSettings({ skillSyncMethod: method });
void autoSaveSettings({ skillSyncMethod: method }).catch(() => undefined);
};
const reportResync = (outcomes: SkillAppSyncOutcome[]) => {
const failed = outcomes.filter((outcome) => !outcome.ok);
if (failed.length === 0) {
@@ -75,55 +187,184 @@ export function SkillsStorageSheet({
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent width={480} closeLabel={t("common.close")}>
<SheetHeader>
<SheetTitle>{t("skills.storageSheet.title")}</SheetTitle>
</SheetHeader>
<SheetBody className="space-y-6">
{settings && (
<>
<SkillStorageLocationSettings
value={settings.skillStorageLocation ?? "cc_switch"}
installedCount={installedSkills?.length ?? 0}
onMigrated={(location) =>
updateSettings({ skillStorageLocation: location })
}
/>
<div className="space-y-3">
<SkillSyncMethodSettings
value={settings.skillSyncMethod ?? "auto"}
onChange={(method) => {
updateSettings({ skillSyncMethod: method });
void autoSaveSettings({ skillSyncMethod: method }).catch(
() => undefined,
);
}}
/>
<p className="m-0 text-caption text-fg-2">
{t("skills.storageSheet.resyncHint")}
<>
<V7Dialog
open={open}
onOpenChange={(next) => {
if (!next && isMigrating) return;
onOpenChange(next);
}}
width={520}
>
<DialogTitle>{t("skills.storageSheet.title")}</DialogTitle>
{settings && (
<>
<fieldset className="m-0 flex min-w-0 flex-col gap-2.5 border-0 p-0">
<legend className={LEGEND_CLASS}>
{t("skills.storageSheet.locationTitle")}
</legend>
<div className="flex flex-col gap-0.5">
<label className={RADIO_LABEL_CLASS}>
<input
type="radio"
name="skills-storage-location"
className={CHECKBOX_CLASS}
checked={pendingLocation === "cc_switch"}
disabled={isMigrating}
aria-describedby="skills-storage-cc-path"
onChange={() => setPendingLocation("cc_switch")}
/>
{t("skills.storageSheet.locationCcSwitch")}
</label>
<div className="flex min-w-0 items-center gap-2 pl-[26px]">
<code
id="skills-storage-cc-path"
className="min-w-0 truncate font-mono text-caption text-fg-2"
title={ccSwitchDirRaw ?? ccSwitchDir}
>
{ccSwitchDir}
</code>
<button
type="button"
aria-label={t("skills.storageSheet.openFolderAria", {
path: ccSwitchDir,
})}
onClick={() => void handleOpenFolder()}
className="shrink-0 whitespace-nowrap text-caption font-medium text-fg-1 underline decoration-border-strong underline-offset-[3px] hover:decoration-fg-1"
>
{t("skills.storageSheet.openFolder")}
</button>
</div>
</div>
<div className="flex flex-col gap-0.5">
<label className={RADIO_LABEL_CLASS}>
<input
type="radio"
name="skills-storage-location"
className={CHECKBOX_CLASS}
checked={pendingLocation === "unified"}
disabled={isMigrating}
aria-describedby="skills-storage-unified-warn"
onChange={() => setPendingLocation("unified")}
/>
<span>
{t("skills.storageSheet.locationUnified")}{" "}
<code className="whitespace-nowrap font-mono text-caption">
{UNIFIED_DIR}
</code>
</span>
</label>
<p
id="skills-storage-unified-warn"
className="m-0 pl-[26px] text-caption text-warning-text"
>
{t("skills.storageSheet.unifiedWarning")}
</p>
</div>
<div className="flex items-center justify-between gap-3 pt-0.5">
<p
id="skills-storage-move-hint"
className="m-0 min-w-0 text-caption text-fg-2"
>
{t("skills.storageSheet.moveHint", { count: installedCount })}
</p>
<Button
type="button"
variant="neutral"
size="regular"
className="shrink-0"
disabled={isMigrating || locationUnchanged}
aria-describedby="skills-storage-move-hint"
onClick={handleMove}
>
{isMigrating && <Loader2 className="h-4 w-4 animate-spin" />}
{isMigrating
? t("skills.storageSheet.moving")
: t("skills.storageSheet.move")}
</Button>
</div>
</fieldset>
<div className="h-px bg-border" />
<fieldset className="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
<legend className={LEGEND_CLASS}>
{t("skills.storageSheet.syncTitle")}
</legend>
{SYNC_METHODS.map((option) => (
<label key={option.id} className={RADIO_LABEL_CLASS}>
<input
type="radio"
name="skills-sync-method"
value={option.id}
className={CHECKBOX_CLASS}
checked={syncMethod === option.id}
onChange={() => handleSyncMethod(option.id)}
/>
<span>
{t(option.labelKey)}
{option.descKey && (
<span className="ml-2 text-caption text-fg-2">
{t(option.descKey)}
</span>
)}
</span>
</label>
))}
<div className="flex items-center gap-3 pt-0.5">
<p className="m-0 min-w-0 flex-1 text-caption text-fg-2">
{t("skills.storageSheet.resyncHint")}
</p>
<Button
type="button"
variant="neutral"
size="compact"
className="shrink-0"
disabled={resyncMutation.isPending}
onClick={() => void handleResync()}
>
{resyncMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" strokeWidth={2} />
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} />
)}
{resyncMutation.isPending
? t("skills.storageSheet.resyncing")
: t("skills.storageSheet.resync")}
</Button>
</div>
</>
)}
</SheetBody>
</SheetContent>
</Sheet>
</fieldset>
</>
)}
<div className="flex justify-end pt-1">
<Button
type="button"
variant="solid"
size="regular"
autoFocus
disabled={isMigrating}
onClick={() => onOpenChange(false)}
>
{t("common.done")}
</Button>
</div>
</V7Dialog>
<V7ConfirmDialog
open={confirmMove}
danger={false}
title={t("skills.storageSheet.moveConfirmTitle", {
count: installedCount,
path: targetDir,
})}
body={t("skills.storageSheet.moveConfirmBody")}
confirmLabel={t("skills.storageSheet.moveConfirmButton")}
pending={isMigrating}
onConfirm={() => void doMigrate(pendingLocation)}
onCancel={() => setConfirmMove(false)}
/>
</>
);
}
+18 -2
View File
@@ -11,20 +11,36 @@ export interface PresetFamilyInfo {
name: string;
/** 名称需要翻译时用的 i18n key */
nameKey?: string;
/** 版本的显示顺序(设计稿顺序);不写或没列到的版本按预设文件顺序排在后面 */
versionOrder?: readonly PresetVersionKey[];
}
export const PRESET_FAMILIES = {
"aws-bedrock": { name: "AWS Bedrock" },
"baidu-qianfan": { name: "Baidu Qianfan" },
compshare: { name: "Compshare", nameKey: "providerForm.presets.ucloud" },
kimi: { name: "Kimi" },
kimi: {
name: "Kimi",
versionOrder: ["paygCn", "codingCn", "paygIntl", "codingIntl"],
},
minimax: { name: "MiniMax" },
qianwen: { name: "千问AI平台" },
qwencloud: { name: "QwenCloud" },
siliconflow: { name: "SiliconFlow" },
stepfun: { name: "StepFun" },
sudocode: { name: "SudoCode" },
tencent: { name: "Tencent Token Plan" },
tencent: {
name: "Tencent Cloud",
nameKey: "providerPreset.family.tencent",
versionOrder: [
"tokenPlanCn",
"tokenPlanIntl",
"enterpriseLiteCn",
"enterpriseLiteIntl",
"enterpriseProCn",
"enterpriseProIntl",
],
},
volcengine: {
name: "Volcengine",
nameKey: "providerPreset.family.volcengine",
+12
View File
@@ -427,3 +427,15 @@ export function useResyncSkillsToApps() {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }),
});
}
/**
* CC Switch 目录下放 Skill 主副本的真实路径(改过配置目录就是改后的)
*/
export function useCcSwitchSkillsDir(enabled = true) {
return useQuery({
queryKey: ["skills", "ccSwitchDir"],
queryFn: () => skillsApi.getCcSwitchSkillsDir(),
enabled,
staleTime: Infinity,
});
}
+18
View File
@@ -83,3 +83,21 @@ export function useTrayNavigation(
};
}, []);
}
/**
* 主界面正显示某个应用的供应商页:告诉托盘这个应用的问题行处理过了(规格「打开过对应页面」)。
* 切到这个页面、以及窗口重新拿到焦点时各报一次;`app` 为 null 表示当前不是应用页。
*/
export function useTrayAppPageSeen(app: AppId | null): void {
useEffect(() => {
if (!app) return;
const report = () => {
invoke("tray_app_page_seen", { appType: app }).catch((error) =>
console.error("Failed to report app page to tray", error),
);
};
report();
window.addEventListener("focus", report);
return () => window.removeEventListener("focus", report);
}, [app]);
}
+24 -22
View File
@@ -741,25 +741,6 @@
"showProfileSwitcher": "Show project switcher",
"showProfileSwitcherDescription": "Show the project switcher in the main page header"
},
"skillStorage": {
"title": "Skill Storage Location",
"description": "Choose where CC Switch stores the master copies of your skills",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.",
"unifiedHint": "Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.",
"confirmTitle": "Migrate Skill Storage",
"confirmMessage": "{{count}} skill(s) will be moved to the new location. Continue?",
"migrationSuccess": "Successfully migrated {{count}} skill(s)",
"migrationPartial": "Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details."
},
"skillSync": {
"title": "Skill Sync Method",
"description": "Choose how to sync Skills files",
"symlink": "Symlink",
"copy": "Copy Files",
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
},
"terminal": {
"title": "Preferred Terminal",
"description": "Choose which terminal app to use when clicking the terminal button",
@@ -1821,7 +1802,8 @@
"matchedVersions": "Matches {{name}} and {{more}} more",
"versionLabel": "Version",
"family": {
"volcengine": "Volcengine"
"volcengine": "Volcengine",
"tencent": "Tencent Cloud"
},
"version": {
"payg": "Pay-as-you-go",
@@ -3049,10 +3031,30 @@
"open": "Storage and sync…",
"resync": "Resync now",
"resyncing": "Resyncing…",
"resyncHint": "Changes only apply to Skills you enable or update afterwards. Resync now to put enabled Skills back into each app's Skills folder using the current method.",
"resyncHint": "Changes only apply to Skills you enable or update afterwards. To switch Skills that are already enabled, resync now.",
"resyncDone": "Resynced Skills to every app using the current method",
"resyncPartial": "{{count}} apps didn't fully sync: {{apps}}",
"resyncSkillItem": "{{directory}}: {{error}}"
"resyncSkillItem": "{{directory}}: {{error}}",
"locationTitle": "Keep the master copies in",
"locationCcSwitch": "CC Switch folder",
"locationUnified": "Shared folder",
"openFolder": "Open",
"openFolderAria": "Open {{path}}",
"unifiedWarning": "Codex, Gemini CLI, OpenCode and others read this folder directly, so the per-app switches no longer fully apply to them.",
"move": "Move and switch…",
"moving": "Moving…",
"moveConfirmTitle": "Move {{count}} Skills to {{path}}?",
"moveConfirmBody": "Each master copy is moved, then the links in every app are pointed at the new location. Don't quit CC Switch until the move finishes.",
"moveConfirmButton": "Move and switch",
"moveDone": "Moved {{count}} Skills to {{path}}",
"movePartial": "Moved {{migrated}} Skills, {{errors}} failed. Check the logs for details.",
"syncTitle": "How Skills reach each app",
"syncAuto": "Automatic: symlink first, copy if that fails (recommended)",
"syncSymlink": "Symlink only",
"syncSymlinkDesc": "Requires Developer Mode on Windows",
"syncCopy": "Copy only",
"syncCopyDesc": "Uses more disk space; copied again after each update",
"moveHint": "Switching moves {{count}} Skills to the new location."
},
"updatesIncomplete": "No updates found, but {{count}} repositories couldn't be read"
},
+24 -22
View File
@@ -741,25 +741,6 @@
"showProfileSwitcher": "プロジェクト切り替えを表示",
"showProfileSwitcherDescription": "メイン画面上部にプロジェクト切り替えを表示します"
},
"skillStorage": {
"title": "スキル保存場所",
"description": "CC Switch がスキルのマスターコピーを保存するディレクトリを選択します",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。",
"unifiedHint": "スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。",
"confirmTitle": "スキル保存場所の移行",
"confirmMessage": "{{count}} 個のスキルを新しい場所に移動します。続行しますか?",
"migrationSuccess": "{{count}} 個のスキルを移行しました",
"migrationPartial": "{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。"
},
"skillSync": {
"title": "スキル同期方式",
"description": "スキルファイルの同期方法を選択",
"symlink": "シンボリックリンク",
"copy": "ファイルコピー",
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
},
"terminal": {
"title": "優先ターミナル",
"description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択",
@@ -1821,7 +1802,8 @@
"matchedVersions": "一致:{{name}} ほか {{more}} 件",
"versionLabel": "バージョン",
"family": {
"volcengine": "Volcengine"
"volcengine": "Volcengine",
"tencent": "Tencent Cloud"
},
"version": {
"payg": "従量課金",
@@ -3049,10 +3031,30 @@
"open": "保存と同期…",
"resync": "今すぐ再同期",
"resyncing": "再同期中…",
"resyncHint": "変更はこの後に有効化または更新した Skill にのみ適用されます。「今すぐ再同期」で、有効な Skill を現在の方式で各アプリの Skills フォルダに入れ直します。",
"resyncHint": "変更はこの後に有効化または更新した Skill にのみ適用されます。有効な Skill も現在の方式に切り替えるには「今すぐ再同期」を押してください。",
"resyncDone": "現在の方式で Skill を各アプリに再同期しました",
"resyncPartial": "{{count}} 個のアプリで同期が完了しませんでした:{{apps}}",
"resyncSkillItem": "{{directory}}:{{error}}"
"resyncSkillItem": "{{directory}}:{{error}}",
"locationTitle": "マスターコピーの保存先",
"locationCcSwitch": "CC Switch フォルダ",
"locationUnified": "共通フォルダ",
"openFolder": "開く",
"openFolderAria": "{{path}} を開く",
"unifiedWarning": "Codex、Gemini CLI、OpenCode などはこのフォルダを直接読むため、アプリごとのスイッチが完全には効かなくなります。",
"move": "移動して切り替え…",
"moving": "移動中…",
"moveConfirmTitle": "{{count}} 個の Skill を {{path}} に移動しますか?",
"moveConfirmBody": "マスターコピーを 1 つずつ移動し、各アプリのリンクを新しい場所に向け直します。移動が終わるまで CC Switch を終了しないでください。",
"moveConfirmButton": "移動して切り替え",
"moveDone": "{{count}} 個の Skill を {{path}} に移動しました",
"movePartial": "{{migrated}} 個の Skill を移動、{{errors}} 個が失敗しました。詳細はログを確認してください。",
"syncTitle": "各アプリへの同期方式",
"syncAuto": "自動:シンボリックリンクを優先し、失敗したらコピー(推奨)",
"syncSymlink": "シンボリックリンクのみ",
"syncSymlinkDesc": "Windows では開発者モードが必要です",
"syncCopy": "コピーのみ",
"syncCopyDesc": "ディスク容量を多く使い、更新のたびにコピーし直します",
"moveHint": "場所を切り替えると {{count}} 個の Skill を移動します。"
},
"updatesIncomplete": "更新は見つかりませんでしたが、{{count}} 個のリポジトリを読み込めませんでした"
},
+24 -22
View File
@@ -741,25 +741,6 @@
"showProfileSwitcher": "顯示專案切換",
"showProfileSwitcherDescription": "在主頁面頂部顯示專案切換入口"
},
"skillStorage": {
"title": "Skills 儲存位置",
"description": "選擇 CC Switch 存放 Skills 主副本的目錄",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "技能儲存在 ~/.cc-switch/skills/,由 CC Switch 統一管理並同步至各應用程式。",
"unifiedHint": "技能儲存在 ~/.agents/skills/,遵循 Agent Skills 開放標準。相容的工具(Claude Code、Codex、Gemini CLI 等)可直接發現此目錄中的技能。",
"confirmTitle": "遷移技能儲存位置",
"confirmMessage": "將移動 {{count}} 個技能至新位置,是否繼續?",
"migrationSuccess": "已成功遷移 {{count}} 個技能",
"migrationPartial": "遷移了 {{migrated}} 個技能,{{errors}} 個失敗,請查看日誌"
},
"skillSync": {
"title": "Skills 同步方式",
"description": "選擇 Skills 的檔案同步策略",
"symlink": "軟連結",
"copy": "檔案複製",
"symlinkHint": "軟連結節省磁碟空間並支援即時同步。注意:Windows 可能需要管理員權限或開啟開發者模式"
},
"terminal": {
"title": "首選終端機",
"description": "選擇點擊終端機按鈕時使用的終端機應用程式",
@@ -1822,7 +1803,8 @@
"matchedVersions": "符合:{{name}} 等 {{n}} 個版本",
"versionLabel": "版本",
"family": {
"volcengine": "火山引擎"
"volcengine": "火山引擎",
"tencent": "騰訊雲"
},
"version": {
"payg": "按量付費",
@@ -3050,10 +3032,30 @@
"open": "儲存與同步…",
"resync": "立即重新同步",
"resyncing": "正在重新同步…",
"resyncHint": "修改只對之後啟用或更新的 Skill 生效。點「立即重新同步」,已啟用的 Skill 會按目前方式重新放進各應用的 Skills 目錄。",
"resyncHint": "修改只對之後啟用或更新的 Skill 生效。要讓已啟用的 Skill 也換成目前方式,點「立即重新同步」。",
"resyncDone": "已按目前方式把 Skill 重新同步到各應用",
"resyncPartial": "{{count}} 個應用沒有同步完整:{{apps}}",
"resyncSkillItem": "{{directory}}:{{error}}"
"resyncSkillItem": "{{directory}}:{{error}}",
"locationTitle": "主副本放在",
"locationCcSwitch": "CC Switch 目錄",
"locationUnified": "通用目錄",
"openFolder": "打開",
"openFolderAria": "打開 {{path}}",
"unifiedWarning": "Codex、Gemini CLI、OpenCode 等會直接讀這個目錄,各應用的開關對它們不再完全生效。",
"move": "移動並切換…",
"moving": "正在移動…",
"moveConfirmTitle": "把 {{count}} 個 Skill 移到 {{path}}?",
"moveConfirmBody": "會逐個移動主副本,再讓各應用裡的連結指向新位置。移動完成前請不要退出 CC Switch。",
"moveConfirmButton": "移動並切換",
"moveDone": "已把 {{count}} 個 Skill 移到 {{path}}",
"movePartial": "移動了 {{migrated}} 個 Skill,{{errors}} 個失敗,請查看日誌",
"syncTitle": "同步到各應用的方式",
"syncAuto": "自動:優先用軟連結,失敗時複製(推薦)",
"syncSymlink": "只用軟連結",
"syncSymlinkDesc": "Windows 需要開啟開發人員模式",
"syncCopy": "只用複製",
"syncCopyDesc": "佔用更多空間,更新後會重新複製",
"moveHint": "切換位置會把 {{count}} 個 Skill 移動過去。"
},
"updatesIncomplete": "沒有發現更新,但有 {{count}} 個倉庫沒有讀到"
},
+24 -22
View File
@@ -741,25 +741,6 @@
"showProfileSwitcher": "显示项目切换",
"showProfileSwitcherDescription": "在主页面顶部显示项目切换入口"
},
"skillStorage": {
"title": "Skills 存储位置",
"description": "选择 CC Switch 存放 Skills 主副本的目录",
"ccSwitch": "CC Switch",
"unified": "~/.agents/skills",
"ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。",
"unifiedHint": "技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。",
"confirmTitle": "迁移技能存储",
"confirmMessage": "将移动 {{count}} 个技能到新位置,是否继续?",
"migrationSuccess": "已成功迁移 {{count}} 个技能",
"migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志"
},
"skillSync": {
"title": "Skills 同步方式",
"description": "选择 Skills 的文件同步策略",
"symlink": "软连接",
"copy": "文件复制",
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
},
"terminal": {
"title": "首选终端",
"description": "选择点击终端按钮时使用的终端应用",
@@ -1821,7 +1802,8 @@
"matchedVersions": "匹配:{{name}} 等 {{n}} 个版本",
"versionLabel": "版本",
"family": {
"volcengine": "火山引擎"
"volcengine": "火山引擎",
"tencent": "腾讯云"
},
"version": {
"payg": "按量付费",
@@ -3049,10 +3031,30 @@
"open": "存储与同步…",
"resync": "立即重新同步",
"resyncing": "正在重新同步…",
"resyncHint": "修改只对之后启用或更新的 Skill 生效。点「立即重新同步」,已启用的 Skill 会按当前方式重新放进各应用的 Skills 目录。",
"resyncHint": "修改只对之后启用或更新的 Skill 生效。要让已启用的 Skill 也换成当前方式,点「立即重新同步」。",
"resyncDone": "已按当前方式把 Skill 重新同步到各应用",
"resyncPartial": "{{count}} 个应用没有同步完整:{{apps}}",
"resyncSkillItem": "{{directory}}:{{error}}"
"resyncSkillItem": "{{directory}}:{{error}}",
"locationTitle": "主副本放在",
"locationCcSwitch": "CC Switch 目录",
"locationUnified": "通用目录",
"openFolder": "打开",
"openFolderAria": "打开 {{path}}",
"unifiedWarning": "Codex、Gemini CLI、OpenCode 等会直接读这个目录,各应用的开关对它们不再完全生效。",
"move": "移动并切换…",
"moving": "正在移动…",
"moveConfirmTitle": "把 {{count}} 个 Skill 移到 {{path}}?",
"moveConfirmBody": "会逐个移动主副本,再让各应用里的链接指向新位置。移动完成前请不要退出 CC Switch。",
"moveConfirmButton": "移动并切换",
"moveDone": "已把 {{count}} 个 Skill 移到 {{path}}",
"movePartial": "移动了 {{migrated}} 个 Skill,{{errors}} 个失败,请查看日志",
"syncTitle": "同步到各应用的方式",
"syncAuto": "自动:优先用软链接,失败时复制(推荐)",
"syncSymlink": "只用软链接",
"syncSymlinkDesc": "Windows 需要开启开发者模式",
"syncCopy": "只用复制",
"syncCopyDesc": "占用更多空间,更新后会重新复制",
"moveHint": "切换位置会把 {{count}} 个 Skill 移动过去。"
},
"updatesIncomplete": "没有发现更新,但有 {{count}} 个仓库没有读到"
},
+10
View File
@@ -268,6 +268,16 @@ export const skillsApi = {
return await invoke("migrate_skill_storage", { target });
},
/** CC Switch 目录下放 Skill 主副本的路径(改过配置目录就是改后的) */
async getCcSwitchSkillsDir(): Promise<string> {
return await invoke("get_cc_switch_skills_dir");
},
/** 在文件管理器里打开 CC Switch 目录下的 Skills 目录 */
async openCcSwitchSkillsDir(): Promise<void> {
await invoke("open_cc_switch_skills_dir");
},
/** 搜索 skills.sh 公共目录 */
async searchSkillsSh(
query: string,
@@ -88,7 +88,7 @@ const entries: PresetEntry[] = [
},
];
// 同一家的三个版本(按预设文件顺序),外加一个单独的预设
// 同一家的三个版本(按预设文件顺序;显示时按 versionOrder 排),外加一个单独的预设
const kimiEntries: PresetEntry[] = [
{
id: "kimi-cn",
@@ -152,8 +152,8 @@ describe("preset families", () => {
const kimi = rows.find((row) => row.family === "kimi");
expect(kimi?.versions.map((entry) => entry.id)).toEqual([
"kimi-cn",
"kimi-intl",
"kimi-coding",
"kimi-intl",
]);
});
@@ -164,9 +164,9 @@ describe("preset families", () => {
hits,
]);
expect(hits("kimi")).toEqual([["family:kimi", []]]);
expect(hits("coding")).toEqual([["family:kimi", [2]]]);
expect(hits("coding")).toEqual([["family:kimi", [1]]]);
// 带点的词可以命中某个版本自己的完整域名
expect(hits("kimi.ai")).toEqual([["family:kimi", [1]]]);
expect(hits("kimi.ai")).toEqual([["family:kimi", [2]]]);
// 别名算整家命中
expect(hits("moonshot")).toEqual([["family:kimi", []]]);
});
@@ -178,6 +178,32 @@ describe("preset families", () => {
expect(groupPresetRows(claudeEntries)).toHaveLength(71);
});
it("orders versions as the design does where a family says so", () => {
const claudeEntries = providerPresets
.filter((item) => !item.hidden)
.map((item, index) => ({ id: `claude-${index}`, preset: item }));
const versionsOf = (family: string) =>
groupPresetRows(claudeEntries)
.find((row) => row.family === family)
?.versions.map((entry) => entry.preset.versionKey);
expect(versionsOf("kimi")).toEqual([
"paygCn",
"codingCn",
"paygIntl",
"codingIntl",
]);
expect(versionsOf("tencent")?.slice(2, 4)).toEqual([
"enterpriseLiteCn",
"enterpriseLiteIntl",
]);
// 没写 versionOrder 的保持文件顺序
expect(versionsOf("volcengine")).toEqual([
"agentPlan",
"codingPlan",
"payg",
]);
});
it("has every family name and version label in all four locales", () => {
const lookup = (data: unknown, key: string) =>
key
@@ -321,8 +347,8 @@ describe("ProviderPresetSelector", () => {
const buttons = within(versions).getAllByRole("button");
expect(buttons.map((button) => button.textContent)).toEqual([
"providerPreset.version.paygCn",
"providerPreset.version.paygIntl",
"providerPreset.version.codingCn",
"providerPreset.version.paygIntl",
]);
expect(buttons[0]).toHaveAttribute("aria-pressed", "true");
@@ -331,7 +357,7 @@ describe("ProviderPresetSelector", () => {
expect(
screen.queryByText("providerPreset.switchVersionTitle"),
).not.toBeInTheDocument();
expect(onPresetChange).toHaveBeenLastCalledWith("kimi-coding");
expect(onPresetChange).toHaveBeenLastCalledWith("kimi-intl");
// 换版本留在第 2 步
expect(screen.queryByTestId("host")).not.toBeInTheDocument();
expect(
@@ -385,13 +411,13 @@ describe("ProviderPresetSelector", () => {
name: "providerPreset.switchVersionConfirm",
}),
);
expect(onPresetChange).toHaveBeenLastCalledWith("kimi-intl");
expect(onPresetChange).toHaveBeenLastCalledWith("kimi-coding");
expect(screen.getByLabelText("api-key")).toHaveValue("");
await user.click(versionButtons()[2]);
expect(
screen.queryByText("providerPreset.switchVersionTitle"),
).not.toBeInTheDocument();
expect(onPresetChange).toHaveBeenLastCalledWith("kimi-coding");
expect(onPresetChange).toHaveBeenLastCalledWith("kimi-intl");
});
it("selects the version a search matched", async () => {
+143 -17
View File
@@ -5,7 +5,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { SkillsStorageSheet } from "@/components/skills/SkillsStorageSheet";
const m = vi.hoisted(() => ({
settings: {
skillStorageLocation: "cc_switch",
skillSyncMethod: "auto",
} as Record<string, string | undefined>,
installed: [] as { id: string }[],
updateSettings: vi.fn(),
autoSaveSettings: vi.fn(),
resync: vi.fn(),
migrate: vi.fn(),
openDir: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
toastError: vi.fn(),
@@ -21,34 +30,142 @@ vi.mock("sonner", () => ({
vi.mock("@/hooks/useSettings", () => ({
useSettings: () => ({
settings: { skillStorageLocation: "cc_switch", skillSyncMethod: "auto" },
updateSettings: vi.fn(),
autoSaveSettings: vi.fn().mockResolvedValue(undefined),
settings: m.settings,
updateSettings: m.updateSettings,
autoSaveSettings: m.autoSaveSettings,
}),
}));
vi.mock("@/hooks/useSkills", () => ({
useInstalledSkills: () => ({ data: [] }),
useInstalledSkills: () => ({ data: m.installed }),
useCcSwitchSkillsDir: () => ({ data: "/Users/jason/.cc-switch/skills" }),
useResyncSkillsToApps: () => ({ mutateAsync: m.resync, isPending: false }),
}));
vi.mock("@/components/settings/SkillStorageLocationSettings", () => ({
SkillStorageLocationSettings: () => <div data-testid="location" />,
vi.mock("@/lib/api/skills", () => ({
skillsApi: {
migrateStorage: m.migrate,
openCcSwitchSkillsDir: m.openDir,
},
}));
vi.mock("@/components/settings/SkillSyncMethodSettings", () => ({
SkillSyncMethodSettings: () => <div data-testid="sync-method" />,
}));
const renderSheet = () =>
const renderDialog = () =>
render(<SkillsStorageSheet open onOpenChange={vi.fn()} />);
const radio = (name: string | RegExp) => screen.getByRole("radio", { name });
const moveButton = () =>
screen.getByRole("button", { name: /skills\.storageSheet\.move$/ });
describe("SkillsStorageSheet", () => {
beforeEach(() => {
m.resync.mockReset();
m.toastSuccess.mockReset();
m.toastWarning.mockReset();
m.toastError.mockReset();
m.settings = { skillStorageLocation: "cc_switch", skillSyncMethod: "auto" };
m.installed = [];
m.autoSaveSettings.mockResolvedValue(undefined);
});
it.each(["auto", "symlink", "copy"] as const)(
"shows the backend sync method %s as the checked option",
(method) => {
m.settings = { ...m.settings, skillSyncMethod: method };
renderDialog();
const options = {
auto: radio("skills.storageSheet.syncAuto"),
symlink: radio(/skills\.storageSheet\.syncSymlink/),
copy: radio(/skills\.storageSheet\.syncCopy/),
};
for (const [id, element] of Object.entries(options)) {
if (id === method) expect(element).toBeChecked();
else expect(element).not.toBeChecked();
}
},
);
it("treats a missing sync method as automatic, like the backend default", () => {
m.settings = { skillStorageLocation: "cc_switch" };
renderDialog();
expect(radio("skills.storageSheet.syncAuto")).toBeChecked();
});
it("saves the picked sync method", async () => {
renderDialog();
await userEvent.click(radio(/skills\.storageSheet\.syncCopy/));
expect(m.updateSettings).toHaveBeenCalledWith({ skillSyncMethod: "copy" });
expect(m.autoSaveSettings).toHaveBeenCalledWith({
skillSyncMethod: "copy",
});
});
it("shows the real CC Switch folder and opens it", async () => {
m.openDir.mockResolvedValue(undefined);
renderDialog();
expect(screen.getByText("~/.cc-switch/skills")).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", {
name: "skills.storageSheet.openFolderAria",
}),
);
expect(m.openDir).toHaveBeenCalledTimes(1);
});
it("keeps move-and-switch unavailable until another location is picked", async () => {
m.installed = [{ id: "a" }, { id: "b" }];
renderDialog();
expect(radio("skills.storageSheet.locationCcSwitch")).toBeChecked();
expect(moveButton()).toBeDisabled();
// 原因是看得见的一行,按钮用 aria-describedby 指向它
expect(moveButton()).toHaveAccessibleDescription(
"skills.storageSheet.moveHint",
);
await userEvent.click(moveButton());
expect(
screen.queryByText("skills.storageSheet.moveConfirmTitle"),
).not.toBeInTheDocument();
expect(m.migrate).not.toHaveBeenCalled();
});
it("confirms before moving Skills to the other location", async () => {
m.installed = [{ id: "a" }, { id: "b" }];
m.migrate.mockResolvedValue({
migratedCount: 2,
skippedCount: 0,
errors: [],
});
renderDialog();
await userEvent.click(radio(/skills\.storageSheet\.locationUnified/));
expect(moveButton()).toBeEnabled();
await userEvent.click(moveButton());
expect(
screen.getByText("skills.storageSheet.moveConfirmTitle"),
).toBeInTheDocument();
expect(m.migrate).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", {
name: "skills.storageSheet.moveConfirmButton",
}),
);
await waitFor(() => expect(m.migrate).toHaveBeenCalledWith("unified"));
await waitFor(() =>
expect(m.updateSettings).toHaveBeenCalledWith({
skillStorageLocation: "unified",
}),
);
expect(m.toastSuccess).toHaveBeenCalledWith(
"skills.storageSheet.moveDone",
expect.anything(),
);
});
it("resyncs Skills to every app on demand", async () => {
@@ -56,7 +173,7 @@ describe("SkillsStorageSheet", () => {
{ app: "claude", ok: true, failedSkills: [] },
{ app: "codex", ok: true, failedSkills: [] },
]);
renderSheet();
renderDialog();
expect(
screen.getByText("skills.storageSheet.resyncHint"),
@@ -87,7 +204,7 @@ describe("SkillsStorageSheet", () => {
failedSkills: [],
},
]);
renderSheet();
renderDialog();
await userEvent.click(
screen.getByRole("button", { name: "skills.storageSheet.resync" }),
@@ -100,4 +217,13 @@ describe("SkillsStorageSheet", () => {
expect(options.description).toContain("Hermes: permission denied");
expect(m.toastSuccess).not.toHaveBeenCalled();
});
it("closes from the solid Done button", async () => {
const onOpenChange = vi.fn();
render(<SkillsStorageSheet open onOpenChange={onOpenChange} />);
await userEvent.click(screen.getByRole("button", { name: "common.done" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
+24
View File
@@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import {
parseTrayNavigation,
useTrayAppPageSeen,
useTrayNavigation,
} from "@/hooks/useTrayNavigation";
import { server } from "../msw/server";
@@ -88,3 +89,26 @@ describe("useTrayNavigation", () => {
);
});
});
describe("useTrayAppPageSeen", () => {
it("reports the app page when shown and when the window regains focus", async () => {
const seen: unknown[] = [];
server.use(
http.post(`${TAURI_ENDPOINT}/tray_app_page_seen`, async ({ request }) => {
seen.push(await request.json());
return HttpResponse.json(null);
}),
);
const { rerender } = renderHook(
({ app }: { app: "codex" | null }) => useTrayAppPageSeen(app),
{ initialProps: { app: null as "codex" | null } },
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(seen).toEqual([]);
rerender({ app: "codex" });
await waitFor(() => expect(seen).toEqual([{ appType: "codex" }]));
window.dispatchEvent(new Event("focus"));
await waitFor(() => expect(seen).toHaveLength(2));
});
});
+5
View File
@@ -212,6 +212,10 @@ export const handlers = [
// Skills / 用量:新加的只读或重投影命令,测试里默认都成功
http.post(`${TAURI_ENDPOINT}/resync_skills_to_apps`, () => success([])),
http.post(`${TAURI_ENDPOINT}/get_cc_switch_skills_dir`, () =>
success("/home/test/.cc-switch/skills"),
),
http.post(`${TAURI_ENDPOINT}/open_cc_switch_skills_dir`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/get_session_usage_last_sync`, () =>
success(null),
),
@@ -422,6 +426,7 @@ export const handlers = [
success([]),
),
http.post(`${TAURI_ENDPOINT}/take_tray_navigation`, () => success(null)),
http.post(`${TAURI_ENDPOINT}/tray_app_page_seen`, () => success(null)),
http.post(`${TAURI_ENDPOINT}/get_available_providers_for_failover`, () =>
success([]),
),