Compare commits

..
1 Commits
Author SHA1 Message Date
yyyyyynnn 304dcddb1f Release v0.2.31 2026-07-09 22:49:06 +08:00
9 changed files with 215 additions and 47 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to Codex-X will be documented here.
## [v0.2.31] - 2026-07-09
### 更新
- 调整 macOS Toast 展示位置:Toast 现在直接挂在窗口根层,不再被内容区域下移影响,提示位置更靠上、更自然。
- 优化 Skills ZIP 安装体验:从 ZIP 安装 Skill 时会读取 `SKILL.md` 里的真实 `name` / `description`,不再显示 `skill-zip-时间戳` 这类临时目录名。
- Skills 页面刷新时会自动识别旧版 `skill-zip-*` 目录,并尝试重命名为 Skill 自身名称。
### 修复 Bug
- 修复启用第三方供应商后,关闭并重新打开 Codex-X 不再显示“当前启用”的问题。
- 修复 Windows / Linux 上供应商编辑页无法正常下拉滚动的问题,编辑 `config.toml` 时不再需要用 PageDown 绕过。
- 修复 Toast 受内容容器 `contain` 影响导致 macOS 位置调整不生效的问题。
### 开发
- 增加 Skill 元数据读取与旧 ZIP Skill 目录自动修正的单元测试。
## [v0.2.30] - 2026-07-09
### 更新
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.30",
"version": "0.2.31",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -346,7 +346,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.30"
version = "0.2.31"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.30"
version = "0.2.31"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
+139 -14
View File
@@ -1154,31 +1154,100 @@ fn compute_dir_hash(dir: &Path) -> Result<String> {
Ok(format!("{:x}", hasher.finalize()))
}
fn clean_skill_metadata_value(value: &str) -> String {
value
.trim()
.trim_matches('`')
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string()
}
fn read_skill_metadata(skill_dir: &Path, fallback: &str) -> (String, Option<String>) {
let skill_md = skill_dir.join("SKILL.md");
let text = read_to_string_if_exists(&skill_md).unwrap_or_default();
let mut title = None;
let mut desc = None;
for line in text.lines() {
let mut frontmatter_name: Option<String> = None;
let mut frontmatter_desc: Option<String> = None;
let mut heading_title: Option<String> = None;
let mut body_desc: Option<String> = None;
let mut in_frontmatter = false;
let mut frontmatter_seen = false;
for (index, line) in text.lines().enumerate() {
let trimmed = line.trim();
if index == 0 && trimmed == "---" {
in_frontmatter = true;
frontmatter_seen = true;
continue;
}
if in_frontmatter {
if trimmed == "---" {
in_frontmatter = false;
continue;
}
if let Some((key, value)) = trimmed.split_once(':') {
let key = key.trim().to_ascii_lowercase();
let value = clean_skill_metadata_value(value);
if key == "name" && frontmatter_name.is_none() && !value.is_empty() {
frontmatter_name = Some(value);
} else if key == "description" && frontmatter_desc.is_none() && !value.is_empty() {
frontmatter_desc = Some(value);
}
}
continue;
}
if trimmed.is_empty() {
continue;
}
if title.is_none() && trimmed.starts_with('#') {
title = Some(trimmed.trim_start_matches('#').trim().to_string());
if heading_title.is_none() && trimmed.starts_with('#') {
heading_title = Some(trimmed.trim_start_matches('#').trim().to_string());
continue;
}
if desc.is_none() && !trimmed.starts_with('#') && !trimmed.starts_with("---") {
desc = Some(trimmed.trim_matches('`').to_string());
if body_desc.is_none()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with("---")
&& !frontmatter_seen
{
body_desc = Some(clean_skill_metadata_value(trimmed));
break;
}
}
(
title
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_string()),
desc.filter(|s| !s.is_empty()),
)
let title = frontmatter_name
.or(heading_title)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_string());
let desc = frontmatter_desc.or(body_desc).filter(|s| !s.is_empty());
(title, desc)
}
fn normalize_legacy_zip_skill_dirs(base: &Path) -> Result<()> {
if !base.exists() {
return Ok(());
}
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
let entry = entry.map_err(|e| io_err(base, e))?;
let path = entry.path();
if !path.is_dir() || !path.join("SKILL.md").is_file() {
continue;
}
let directory = entry.file_name().to_string_lossy().to_string();
if !directory.starts_with("skill-zip-") {
continue;
}
let (name, _) = read_skill_metadata(&path, &directory);
let dst_name = sanitize_dir_name(&name, "skill");
if dst_name == directory || dst_name.starts_with("skill-zip-") {
continue;
}
let dst = base.join(dst_name);
if dst.exists() {
continue;
}
fs::rename(&path, &dst).map_err(|e| io_err(&dst, e))?;
}
Ok(())
}
fn save_managed_skill(skill: &ManagedSkill) -> Result<()> {
@@ -1457,6 +1526,12 @@ fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result<SkillsMcpS
let mut warnings = Vec::new();
let mut skills = Vec::new();
let mut seen = HashSet::new();
if let Err(e) = normalize_legacy_zip_skill_dirs(&skills_dir) {
warnings.push(format!("修正 ZIP Skill 目录名失败: {e}"));
}
if let Err(e) = normalize_legacy_zip_skill_dirs(&disabled_dir) {
warnings.push(format!("修正已禁用 ZIP Skill 目录名失败: {e}"));
}
if let Err(e) = scan_skill_dir(&skills_dir, true, "Codex", &mut skills, &mut seen) {
warnings.push(e.to_string());
}
@@ -1888,7 +1963,8 @@ fn install_skill_zip_inner(
for src in skill_dirs {
let fallback = file_name.trim_end_matches(".zip");
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
let dst_name = sanitize_dir_name(dir_name, "skill");
let (skill_name, _) = read_skill_metadata(&src, dir_name);
let dst_name = sanitize_dir_name(&skill_name, "skill");
let dst = skills_dir.join(dst_name);
if dst.exists() {
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
@@ -4763,6 +4839,55 @@ mod tests {
dir
}
#[test]
fn skill_metadata_reads_frontmatter_name_before_directory() {
let dir = temp_codex_dir("skill-frontmatter").join("skill-zip-123");
fs::create_dir_all(&dir).expect("create skill dir");
write_text(
&dir.join("SKILL.md"),
r#"---
name: ctf-sandbox-runner
description: Resume authorized CTF sandbox projects.
---
# CTF Sandbox Runner
"#,
)
.expect("write skill");
let (name, desc) = read_skill_metadata(&dir, "skill-zip-123");
assert_eq!(name, "ctf-sandbox-runner");
assert_eq!(
desc.as_deref(),
Some("Resume authorized CTF sandbox projects.")
);
let root = dir.parent().unwrap().to_path_buf();
let _ = fs::remove_dir_all(root);
}
#[test]
fn normalize_legacy_zip_skill_dir_renames_to_metadata_name() {
let root = temp_codex_dir("skill-normalize");
let dir = root.join("skill-zip-1783334291187");
fs::create_dir_all(&dir).expect("create legacy skill dir");
write_text(
&dir.join("SKILL.md"),
r#"---
name: mission-keeper
description: Keep long investigations aligned.
---
"#,
)
.expect("write skill");
normalize_legacy_zip_skill_dirs(&root).expect("normalize");
assert!(!dir.exists());
assert!(root.join("mission-keeper").join("SKILL.md").exists());
let _ = fs::remove_dir_all(root);
}
#[test]
fn switch_provider_writes_ccswitch_custom_provider_and_api_key_auth_mode() {
let codex_dir = temp_codex_dir("switch-provider");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.30",
"version": "0.2.31",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+42 -26
View File
@@ -976,7 +976,20 @@ function App() {
const currentProvider = state?.providers.find((p) => p.isCurrent);
const liveProviderId = (state?.modelProvider || "openai").trim();
const effectiveActiveProviderId = liveProviderId === "custom" ? activeProviderId : liveProviderId;
const liveCustomProvider = React.useMemo(() => (state?.providers || []).find((item) => item.id === "custom"), [state?.providers]);
const inferredActiveProviderId = React.useMemo(() => {
if (liveProviderId !== "custom" || activeProviderId) return activeProviderId;
const liveBaseUrl = (liveCustomProvider?.baseUrl || "").replace(/\/+$/, "");
const liveWireApi = liveCustomProvider?.wireApi || "responses";
const liveModel = state?.model || "";
const matched = savedProviders.find((item) =>
item.baseUrl.replace(/\/+$/, "") === liveBaseUrl &&
(item.model || "") === liveModel &&
(item.wireApi || "responses") === liveWireApi,
);
return matched?.id || "";
}, [activeProviderId, liveCustomProvider?.baseUrl, liveCustomProvider?.wireApi, liveProviderId, savedProviders, state?.model]);
const effectiveActiveProviderId = liveProviderId === "custom" ? inferredActiveProviderId : liveProviderId;
const currentInstructionPath = (state?.instructionFile || "").replace(/\\/g, "/");
const currentInstructionFilename = currentInstructionPath.split("/").pop() || "";
const detectedRows = React.useMemo(() => {
@@ -1125,6 +1138,7 @@ function App() {
}, [autoSessionSync]);
React.useEffect(() => {
if (!state) return;
if (liveProviderId !== "custom") {
if (activeProviderId) {
localStorage.removeItem(ACTIVE_PROVIDER_KEY);
@@ -1132,11 +1146,12 @@ function App() {
}
return;
}
if (!savedProviders.length) return;
if (activeProviderId && !savedProviders.some((item) => item.id === activeProviderId)) {
localStorage.removeItem(ACTIVE_PROVIDER_KEY);
setActiveProviderId("");
}
}, [activeProviderId, liveProviderId, savedProviders]);
}, [activeProviderId, liveProviderId, savedProviders, state]);
React.useEffect(() => {
if (!autoSessionSync || autoSessionSyncRanRef.current || !state?.codexDir) return;
@@ -1808,11 +1823,36 @@ function App() {
["about", t.nav.about, <Info size={18} />],
];
const toastLayer = toast ? (() => {
const [title, ...rest] = toast.split("\n");
const message = rest.join("\n").trim();
return (
<div className="toast ok" onAnimationEnd={() => setToast("")}>
<div className="toast-icon"><CheckCircle2 size={16} /></div>
<div className="toast-copy">
<strong>{title}</strong>
{message && <span>{message}</span>}
</div>
<button className="toast-close" onClick={() => setToast("")}>×</button>
</div>
);
})() : error ? (
<div className="toast error">
<div className="toast-icon"><AlertCircle size={16} /></div>
<div className="toast-copy">
<strong>{lang === "zh" ? "操作失败" : "Action failed"}</strong>
<span>{error}</span>
</div>
<button className="toast-close" onClick={() => setError("")}>×</button>
</div>
) : null;
return (
<main className={cx("app-shell", isMacRuntime && "mac-shell")}>
{isMacRuntime && <div className="window-drag-strip" data-tauri-drag-region />}
<div className="orb orb-a" />
<div className="orb orb-b" />
{toastLayer}
<aside className="sidebar glass">
<div className="brand">
@@ -1854,30 +1894,6 @@ function App() {
</header>
)}
{toast && (() => {
const [title, ...rest] = toast.split("\n");
const message = rest.join("\n").trim();
return (
<div className="toast ok" onAnimationEnd={() => setToast("")}>
<div className="toast-icon"><CheckCircle2 size={16} /></div>
<div className="toast-copy">
<strong>{title}</strong>
{message && <span>{message}</span>}
</div>
<button className="toast-close" onClick={() => setToast("")}>×</button>
</div>
);
})()}
{error && (
<div className="toast error">
<div className="toast-icon"><AlertCircle size={16} /></div>
<div className="toast-copy">
<strong>{lang === "zh" ? "操作失败" : "Action failed"}</strong>
<span>{error}</span>
</div>
<button className="toast-close" onClick={() => setError("")}>×</button>
</div>
)}
{updatePromptOpen && releaseInfo.hasUpdate && (
<div className="update-mask" onClick={() => setUpdatePromptOpen(false)}>
<div className="update-dialog glass" onClick={(e) => e.stopPropagation()}>
+11 -2
View File
@@ -198,7 +198,7 @@ input, select, textarea {
.toast {
position: fixed;
left: 50%;
top: 24px;
top: 14px;
z-index: 50;
transform: translateX(-50%);
min-width: 360px;
@@ -260,7 +260,7 @@ input, select, textarea {
padding: 0;
}
.toast-close:hover { color: #fff; }
.app-shell.mac-shell .toast { top: calc(18px + var(--mac-titlebar-safe)); }
.app-shell.mac-shell .toast { top: 68px; }
@keyframes toast {
0% { opacity: 0; transform: translate(-50%, -10px) scale(.98); }
12%, 84% { opacity: 1; transform: translate(-50%, 0) scale(1); }
@@ -1362,6 +1362,15 @@ html, body, #root {
margin: 0;
}
/* Provider edit page can be taller than one viewport; let the outer content pane scroll.
Keep only the provider list page using the fixed internal list scroller. */
.panel.glass.provider-panel.provider-edit-panel {
height: auto;
max-height: none;
min-height: calc(100vh - var(--shell-vertical-pad));
overflow: visible;
}
/* Simpler instruction prompt page: aligned status + enable/disable controls. */
.simple-instruction-panel .instruction-row {
grid-template-columns: minmax(320px, 1fr) 86px 280px;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.30",
"version": "0.2.31",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",