fix(codex-config): close fix-round-1 parity gaps against CcConfig

Isolate the codex-CLI-backed reads (mcp/plugins/marketplaces) from the
file-backed ones in fetchAll via two independent Promise.all groups, so a
missing `codex` binary degrades only the mcp/plugins tabs instead of
leaving the whole panel stuck on skeletons. Generalize EditorModal's
nameless-type detection behind a `namelessTypes` prop (defaulting to
Claude's own ["memory"]) so Codex's config.toml can be created without a
name. Port BackupsModal, disable the plugin/mcp Remove button when no
stable identifier resolved, hide the inert search box on memory/config,
drop a redundant empty state, and reword the delete-dialog copy to be
tool-neutral.

Co-authored-by: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
This commit is contained in:
2026-09-08 21:34:06 +07:00
parent 8015730adf
commit 56bf5bbf4d
8 changed files with 432 additions and 73 deletions
+319 -50
View File
@@ -35,6 +35,10 @@ import {
X,
AlertCircle,
Info,
History,
Terminal,
Copy,
Check,
} from "lucide-react";
import { api } from "../lib/api";
import { FileViewer } from "../components/config/FileViewer";
@@ -54,6 +58,7 @@ import {
import type {
CodexAgent,
CodexArtifactType,
CodexBackup,
CodexConfigFile,
CodexFileResponse,
CodexMarketplace,
@@ -94,6 +99,10 @@ function isNamedArtifact(tab: TabKey): tab is "skills" | "agents" | "rules" {
return tab === "skills" || tab === "agents" || tab === "rules";
}
// Both memory (AGENTS.md) and config (config.toml) are a single fixed file
// per scope - unlike Claude, where only memory is nameless.
const NAMELESS_TYPES = ["memory", "config"];
interface PageState {
overview: CodexOverview | null;
skills: CodexSkill[] | null;
@@ -138,6 +147,11 @@ export function CodexConfig() {
const [data, setData] = useState<PageState>(EMPTY_STATE);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// mcp/plugins/marketplaces shell out to the `codex` CLI, which may not be
// installed - that's a normal state for those two tabs, not a page error
// (mirrors CcConfig.tsx's PluginsPanel: "a failure here is a normal state").
const [cliError, setCliError] = useState<string | null>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
const [viewer, setViewer] = useState<{
@@ -158,37 +172,53 @@ export function CodexConfig() {
const fetchAll = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [overview, skills, agents, rules, memory, config, mcp, plugins, marketplaces] =
await Promise.all([
api.codexConfig.overview(),
api.codexConfig.skills(scope),
api.codexConfig.agents(scope),
api.codexConfig.rules(scope),
api.codexConfig.memory(),
api.codexConfig.config(),
api.codexConfig.mcp(),
api.codexConfig.plugins(),
api.codexConfig.marketplaces(),
]);
setData({
overview,
skills: skills.skills,
agents: agents.agents,
rules: rules.rules,
memory: memory.memory,
config: config.config,
mcp: mcp.mcp,
plugins: plugins.plugins,
marketplaces: marketplaces.marketplaces,
// File-backed reads (skills/agents/rules/memory/config/overview) and the
// CLI-backed ones (mcp/plugins/marketplaces) fail independently: a
// missing `codex` binary must not take down the file-backed tabs too.
const filesDone = Promise.all([
api.codexConfig.overview(),
api.codexConfig.skills(scope),
api.codexConfig.agents(scope),
api.codexConfig.rules(scope),
api.codexConfig.memory(),
api.codexConfig.config(),
])
.then(([overview, skills, agents, rules, memory, config]) => {
setData((d) => ({
...d,
overview,
skills: skills.skills,
agents: agents.agents,
rules: rules.rules,
memory: memory.memory,
config: config.config,
}));
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "unknown error");
});
setLastUpdated(new Date());
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "unknown error";
setError(msg);
} finally {
setLoading(false);
}
const cliDone = Promise.all([
api.codexConfig.mcp(),
api.codexConfig.plugins(),
api.codexConfig.marketplaces(),
])
.then(([mcp, plugins, marketplaces]) => {
setCliError(null);
setData((d) => ({
...d,
mcp: mcp.mcp,
plugins: plugins.plugins,
marketplaces: marketplaces.marketplaces,
}));
})
.catch((err: unknown) => {
setCliError(err instanceof Error ? err.message : "unknown error");
});
await Promise.all([filesDone, cliDone]);
setLastUpdated(new Date());
setLoading(false);
}, [scope]);
useEffect(() => {
@@ -273,14 +303,10 @@ export function CodexConfig() {
name: string | undefined;
content: string;
}) => {
// memory/config are singleton files per scope - the server forbids a
// `name` for them, but EditorModal always computes one for "create"
// (it only special-cases the literal type "memory"). Strip it here.
const name = args.type === "memory" || args.type === "config" ? undefined : args.name;
const result: CodexMutationResult = await api.codexConfig.write({
scope: args.targetScope as "user" | "project",
type: args.type,
name,
name: args.name,
content: args.content,
});
setEditor(null);
@@ -321,6 +347,7 @@ export function CodexConfig() {
scope={scope}
onScopeChange={setScope}
onRefresh={fetchAll}
onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected}
/>
@@ -334,7 +361,7 @@ export function CodexConfig() {
<Tabs current={tab} onSelect={setTab} counts={data.overview?.counts} />
<div className="rounded-xl border border-border bg-surface-1">
{tab !== "overview" && (
{tab !== "overview" && tab !== "memory" && tab !== "config" && (
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Search className="w-4 h-4 text-fg-muted" />
<input
@@ -370,6 +397,7 @@ export function CodexConfig() {
tab={tab}
data={data}
search={search}
cliError={cliError}
onOpenFile={openViewer}
onEdit={openEdit}
onDelete={openDelete}
@@ -380,6 +408,9 @@ export function CodexConfig() {
</div>
</div>
{backupsOpen && (
<BackupsModal roots={data.overview?.roots} onClose={() => setBackupsOpen(false)} />
)}
{viewer && <FileViewer state={viewer} onClose={() => setViewer(null)} />}
{editor && (
<EditorModal<CodexArtifactType>
@@ -387,6 +418,7 @@ export function CodexConfig() {
fetchContent={(p) => api.codexConfig.file(p).then((r) => r.text)}
onClose={() => setEditor(null)}
onSave={handleSave}
namelessTypes={NAMELESS_TYPES}
/>
)}
{confirmDelete && (
@@ -409,6 +441,7 @@ interface HeaderProps {
scope: CodexScope;
onScopeChange: (s: CodexScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
}
@@ -418,6 +451,7 @@ function Header({
scope,
onScopeChange,
onRefresh,
onOpenBackups,
wsConnected,
}: HeaderProps) {
const { t } = useTranslation("codexConfig");
@@ -456,6 +490,13 @@ function Header({
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<History className="w-3.5 h-3.5" />
{t("backups.openButton")}
</button>
<button
onClick={onRefresh}
disabled={loading}
@@ -587,6 +628,7 @@ interface TabPanelProps {
tab: TabKey;
data: PageState;
search: string;
cliError: string | null;
onOpenFile: (path: string) => void;
onEdit: (
type: CodexArtifactType,
@@ -607,6 +649,7 @@ function TabPanel({
tab,
data,
search,
cliError,
onOpenFile,
onEdit,
onDelete,
@@ -673,13 +716,22 @@ function TabPanel({
/>
);
case "mcp":
return <McpPanel data={data.mcp} search={search} onToast={onToast} onChanged={onChanged} />;
return (
<McpPanel
data={data.mcp}
search={search}
cliError={cliError}
onToast={onToast}
onChanged={onChanged}
/>
);
case "plugins":
return (
<PluginsPanel
plugins={data.plugins}
marketplaces={data.marketplaces}
search={search}
cliError={cliError}
onToast={onToast}
onChanged={onChanged}
/>
@@ -978,7 +1030,6 @@ function ScopeFilePanel({
return (
<div className="space-y-3">
{present.length === 0 && missing.length === 2 ? <Empty /> : null}
{present.map((m) => {
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
return (
@@ -1072,10 +1123,32 @@ function useConfigAction(onToast: (t: NonNullable<Toast>) => void, onChanged: ()
}
/** Best-effort label/keys for the loosely-typed CLI JSON blobs
* (`CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are all `[key: string]: unknown`). */
function itemLabel(item: Record<string, unknown>, index: number, fallback: string): string {
* (`CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are all `[key: string]: unknown`).
* `real: false` means no stable identifier was found - the synthetic label
* (e.g. "MCP servers 1") can't be sent to remove/*, so callers must disable
* the remove action rather than let it 400. */
function itemLabel(
item: Record<string, unknown>,
index: number,
fallback: string
): { label: string; real: boolean } {
const v = item.name ?? item.id ?? item.spec;
return typeof v === "string" && v ? v : `${fallback} ${index + 1}`;
return typeof v === "string" && v
? { label: v, real: true }
: { label: `${fallback} ${index + 1}`, real: false };
}
/** Shown in place of a tab's content when its `codex`-CLI-backed read failed
* (most likely: `codex` isn't installed). Mirrors the "no plugins" warning
* banner style already used lower on this page. */
function CliUnavailable({ message }: { message: string }) {
const { t } = useTranslation("codexConfig");
return (
<div className="rounded-md border border-status-warning/30 bg-status-warning/5 px-3 py-2 text-[11px] text-status-warning/90 flex items-center gap-2">
<Info className="w-3.5 h-3.5 flex-shrink-0" />
{t("cli.unavailable", { message })}
</div>
);
}
function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[] }) {
@@ -1100,11 +1173,13 @@ function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[]
function McpPanel({
data,
search,
cliError,
onToast,
onChanged,
}: {
data: CodexMcpServer[] | null;
search: string;
cliError: string | null;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
@@ -1142,7 +1217,7 @@ function McpPanel({
}
}, [argsStr, bearerVar, command, mode, name, run, t, url]);
if (!data) return <SkeletonRows n={3} />;
if (!data) return cliError ? <CliUnavailable message={cliError} /> : <SkeletonRows n={3} />;
const needle = search.toLowerCase();
const filtered = data.filter((s) => !needle || JSON.stringify(s).toLowerCase().includes(needle));
@@ -1224,7 +1299,7 @@ function McpPanel({
<Empty />
) : (
filtered.map((s, i) => {
const label = itemLabel(s, i, t("tabs.mcp"));
const { label, real } = itemLabel(s, i, t("tabs.mcp"));
return (
<div key={label + i} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-start justify-between gap-3">
@@ -1240,7 +1315,8 @@ function McpPanel({
t("mcp.removed", { name: label })
)
}
disabled={busy === label}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
>
<Trash2 className="w-3 h-3" />
@@ -1259,12 +1335,14 @@ function PluginsPanel({
plugins,
marketplaces,
search,
cliError,
onToast,
onChanged,
}: {
plugins: CodexPlugin[] | null;
marketplaces: CodexMarketplace[] | null;
search: string;
cliError: string | null;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
@@ -1334,12 +1412,16 @@ function PluginsPanel({
</div>
{plugins === null ? (
<SkeletonRows n={3} />
cliError ? (
<CliUnavailable message={cliError} />
) : (
<SkeletonRows n={3} />
)
) : filteredPlugins.length === 0 ? (
<Empty />
) : (
filteredPlugins.map((p, i) => {
const label = itemLabel(p, i, t("tabs.plugins"));
const { label, real } = itemLabel(p, i, t("tabs.plugins"));
return (
<div
key={label + i}
@@ -1358,7 +1440,8 @@ function PluginsPanel({
t("plugins.removed", { name: label })
)
}
disabled={busy === label}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
>
<Trash2 className="w-3 h-3" />
@@ -1406,13 +1489,17 @@ function PluginsPanel({
</div>
{marketplaces === null ? (
<SkeletonRows n={2} />
cliError ? (
<CliUnavailable message={cliError} />
) : (
<SkeletonRows n={2} />
)
) : filteredMarketplaces.length === 0 ? (
<Empty />
) : (
<div className="space-y-2">
{filteredMarketplaces.map((m, i) => {
const label = itemLabel(m, i, t("plugins.marketplacesTitle"));
const { label, real } = itemLabel(m, i, t("plugins.marketplacesTitle"));
return (
<div
key={label + i}
@@ -1432,7 +1519,8 @@ function PluginsPanel({
t("plugins.marketplaceRemoved", { name: label })
)
}
disabled={busy === label}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
>
<Trash2 className="w-3 h-3" />
@@ -1455,3 +1543,184 @@ function PluginsPanel({
</div>
);
}
// ── Backups modal ─────────────────────────────────────────────────────
//
// Ported from CcConfig.tsx's BackupsModal. `CodexBackup` carries no `scope`
// (the server route doesn't report one - see server/lib/codex-mutate.js's
// listBackups), so scope and the restore path are both derived here from the
// backup file's own path against the known roots from the overview response.
function BackupsModal({
roots,
onClose,
}: {
roots: CodexOverview["roots"] | undefined;
onClose: () => void;
}) {
const { t } = useTranslation("codexConfig");
const [items, setItems] = useState<CodexBackup[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.codexConfig
.backups()
.then((r) => setItems(r.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "unknown"));
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-3xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<History className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("backups.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("backups.subtitle")}</p>
</div>
<div className="overflow-auto p-4 space-y-2">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{items === null && !error && <SkeletonRows n={4} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("backups.empty")}
</div>
)}
{items?.map((b) => (
<BackupRow key={b.file} backup={b} roots={roots} />
))}
</div>
</div>
</div>
);
}
function BackupRow({
backup,
roots,
}: {
backup: CodexBackup;
roots: CodexOverview["roots"] | undefined;
}) {
const { t } = useTranslation("codexConfig");
const scope = deriveScope(backup.file, roots);
const restoreCmd = `mv ${shellEscape(backup.file)} ${shellEscape(deriveActivePath(backup, roots))}`;
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div className="flex items-center gap-2 flex-wrap">
{scope && <ScopeBadge scope={scope} />}
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
{backup.type}
</span>
<span className="font-mono text-xs text-fg-primary truncate flex-1 min-w-0">
{backup.name}
</span>
<span className="text-[10px] text-fg-muted">{new Date(backup.mtime).toLocaleString()}</span>
{backup.size != null && (
<span className="text-[10px] text-fg-muted">{formatBytes(backup.size)}</span>
)}
</div>
<div className="mt-1.5 font-mono text-[10px] text-fg-muted truncate">{backup.file}</div>
<div className="mt-2">
<div className="text-[10px] text-fg-muted mb-1">{t("backups.restoreHint")}</div>
<CommandSnippet command={restoreCmd} />
</div>
</div>
);
}
function CommandSnippet({ command }: { command: string }) {
const { t } = useTranslation("codexConfig");
const [copied, setCopied] = useState(false);
return (
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable */
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
);
}
/** Best-effort: which scope a backup file belongs to, by prefix-matching it
* against the known roots. Returns null (badge omitted) if `roots` hasn't
* loaded yet or the path doesn't match either root. */
function deriveScope(
filePath: string,
roots: CodexOverview["roots"] | undefined
): "user" | "project" | null {
if (!roots) return null;
if (filePath.startsWith(roots.codexHome)) return "user";
if (filePath.startsWith(roots.projectCodexDir) || filePath.startsWith(roots.projectRoot)) {
return "project";
}
return null;
}
// Backups live at <root>/codex-config-backups/<type>/<name>.<ts>.bak (see
// backupRootFor in server/lib/codex-mutate.js). memory/config are fixed
// single files (AGENTS.md / config.toml) rather than a <type>/<name> subdir,
// so those two get their well-known active path instead.
function deriveActivePath(b: CodexBackup, roots: CodexOverview["roots"] | undefined): string {
const m = b.name.match(/^(.+?)\.[^.]+\.bak$/);
const baseName = m ? m[1] : b.name;
const scope = deriveScope(b.file, roots);
if (!scope || !roots) return "<active path>";
if (b.type === "memory") {
return scope === "user" ? `${roots.codexHome}/AGENTS.md` : `${roots.projectRoot}/AGENTS.md`;
}
if (b.type === "config") {
return scope === "user"
? `${roots.codexHome}/config.toml`
: `${roots.projectCodexDir}/config.toml`;
}
const root = scope === "user" ? roots.codexHome : roots.projectCodexDir;
return `${root}/${b.type}/${baseName}`;
}
// Quote for POSIX shells: wrap in single quotes, escape any embedded single quotes.
function shellEscape(s: string): string {
if (/^[A-Za-z0-9_/.@:=+,-]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}
@@ -42,6 +42,7 @@ const codexConfigMock = vi.hoisted(() => {
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAdd: r({ ok: true, stdout: "", stderr: "" }),
marketplaceAction: r({ ok: true, stdout: "", stderr: "" }),
backups: r({ items: [] }),
};
});
@@ -124,4 +125,63 @@ describe("CodexConfig", () => {
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
});
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
render(<CodexConfig />);
await settle();
// No page-wide error banner - the CLI-backed rejection is isolated.
expect(screen.queryByText(/failed to load/i)).not.toBeInTheDocument();
// Pure file reads (skills) still rendered their data.
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
expect(screen.getByText("my-skill")).toBeInTheDocument();
// The CLI-owned tab degrades gracefully instead of spinning forever.
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
expect(screen.getByText(/codex cli isn't available/i)).toBeInTheDocument();
});
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
stubCodexApi({ write });
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
const newButtons = screen.getAllByRole("button", { name: /new/i });
await userEvent.click(newButtons[0]!);
await settle();
// No name field, no client-side "Name" validation error blocking save.
expect(screen.queryByPlaceholderText(/name/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(write).toHaveBeenCalledWith(
expect.objectContaining({ type: "config", name: undefined })
);
});
it("opens the backups modal and lists what the codex API returns", async () => {
codexConfigMock.backups.mockResolvedValueOnce({
items: [
{
type: "rules",
name: "default.2026-09-08.bak",
file: "/r/default.bak",
mtime: 0,
size: 3,
},
],
});
render(<CodexConfig />);
await settle();
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
await settle();
expect(screen.getByText("default.2026-09-08.bak")).toBeInTheDocument();
});
});