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:
@@ -30,6 +30,10 @@ export type EditorState<TType extends string = CcArtifactType> =
|
||||
}
|
||||
| null;
|
||||
|
||||
/** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's
|
||||
* AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */
|
||||
const DEFAULT_NAMELESS_TYPES = ["memory"];
|
||||
|
||||
interface EditorModalProps<TType extends string = CcArtifactType> {
|
||||
state: NonNullable<EditorState<TType>>;
|
||||
fetchContent: (filePath: string) => Promise<string>;
|
||||
@@ -41,6 +45,9 @@ interface EditorModalProps<TType extends string = CcArtifactType> {
|
||||
content: string;
|
||||
project?: string;
|
||||
}) => Promise<void>;
|
||||
/** Which `state.type` values are nameless singleton files. Defaults to
|
||||
* Claude Code's own `["memory"]` so existing callers are unaffected. */
|
||||
namelessTypes?: readonly string[];
|
||||
}
|
||||
|
||||
export function EditorModal<TType extends string = CcArtifactType>({
|
||||
@@ -48,9 +55,11 @@ export function EditorModal<TType extends string = CcArtifactType>({
|
||||
fetchContent,
|
||||
onClose,
|
||||
onSave,
|
||||
namelessTypes = DEFAULT_NAMELESS_TYPES,
|
||||
}: EditorModalProps<TType>) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const isCreate = state.mode === "create";
|
||||
const isNameless = namelessTypes.includes(state.type);
|
||||
const isAutoMemory = state.type === "auto-memory";
|
||||
const [content, setContent] = useState<string>(isCreate ? state.template : "");
|
||||
const [name, setName] = useState<string>("");
|
||||
@@ -91,7 +100,7 @@ export function EditorModal<TType extends string = CcArtifactType>({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (state.type !== "memory" && state.mode === "create" && !name) {
|
||||
if (!isNameless && state.mode === "create" && !name) {
|
||||
setError(t("edit.nameLabel"));
|
||||
setSaving(false);
|
||||
return;
|
||||
@@ -99,7 +108,7 @@ export function EditorModal<TType extends string = CcArtifactType>({
|
||||
// For auto-memory creates, append a .md extension when the user omits it.
|
||||
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
|
||||
const effectiveName =
|
||||
state.mode === "edit" ? state.name : state.type === "memory" ? undefined : createName;
|
||||
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
|
||||
await onSave({
|
||||
type: state.type,
|
||||
targetScope: isAutoMemory ? "auto-memory" : targetScope,
|
||||
@@ -143,7 +152,7 @@ export function EditorModal<TType extends string = CcArtifactType>({
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
{isCreate && state.type !== "memory" && (
|
||||
{isCreate && !isNameless && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
"saveSuccessNew": "Created.",
|
||||
"deleteSuccess": "Deleted · backup at {{path}}",
|
||||
"confirmDelete": "Delete this item?",
|
||||
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories Claude Code scans, so the entry will disappear from your CLI.",
|
||||
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories the CLI scans, so the entry will disappear from your CLI.",
|
||||
"confirmDeletePath": "Path: {{path}}",
|
||||
"confirmDeleteAction": "Yes, back up and delete",
|
||||
"templates": {
|
||||
|
||||
@@ -54,13 +54,9 @@
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"userScope": "User memory",
|
||||
"projectScope": "Project memory",
|
||||
"missing": "No AGENTS.md found."
|
||||
},
|
||||
"config": {
|
||||
"userScope": "User config",
|
||||
"projectScope": "Project config",
|
||||
"missing": "No config.toml found."
|
||||
},
|
||||
"mcp": {
|
||||
@@ -74,7 +70,6 @@
|
||||
"bearerLabel": "Bearer token env var (optional)",
|
||||
"addButton": "Add",
|
||||
"removeButton": "Remove",
|
||||
"noServers": "No MCP servers found.",
|
||||
"added": "Added {{name}}.",
|
||||
"removed": "Removed {{name}}."
|
||||
},
|
||||
@@ -91,10 +86,24 @@
|
||||
"addMarketplacePlaceholder": "https://github.com/owner/repo",
|
||||
"addMarketplaceButton": "Add",
|
||||
"marketplaceAdded": "Added marketplace.",
|
||||
"marketplaceRemoved": "Removed marketplace {{name}}.",
|
||||
"noMarketplaces": "No marketplaces registered."
|
||||
"marketplaceRemoved": "Removed marketplace {{name}}."
|
||||
},
|
||||
"manage": {
|
||||
"working": "Working…"
|
||||
"working": "Working…",
|
||||
"noIdentifier": "No stable identifier for this entry - remove it from the CLI directly."
|
||||
},
|
||||
"cli": {
|
||||
"unavailable": "The codex CLI isn't available on this machine ({{message}}) - install it or add it to PATH to manage this."
|
||||
},
|
||||
"backups": {
|
||||
"openButton": "Backups",
|
||||
"title": "Backups",
|
||||
"subtitle": "Every save / delete writes a copy here first. Backup folders live outside the directories the CLI scans, so they don't reappear as ghosts in your CLI.",
|
||||
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
|
||||
"restoreHint": "Restore by renaming the backup back into place:"
|
||||
},
|
||||
"snippet": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"saveSuccessNew": "Đã tạo.",
|
||||
"deleteSuccess": "Đã xoá · bản sao tại {{path}}",
|
||||
"confirmDelete": "Xoá mục này?",
|
||||
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục Claude Code quét, nên mục này sẽ biến mất khỏi CLI.",
|
||||
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục CLI quét, nên mục này sẽ biến mất khỏi CLI.",
|
||||
"confirmDeletePath": "Đường dẫn: {{path}}",
|
||||
"confirmDeleteAction": "Xác nhận sao lưu và xoá",
|
||||
"templates": {
|
||||
|
||||
@@ -54,13 +54,9 @@
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"userScope": "Bộ nhớ người dùng",
|
||||
"projectScope": "Bộ nhớ dự án",
|
||||
"missing": "Không tìm thấy AGENTS.md."
|
||||
},
|
||||
"config": {
|
||||
"userScope": "Cấu hình người dùng",
|
||||
"projectScope": "Cấu hình dự án",
|
||||
"missing": "Không tìm thấy config.toml."
|
||||
},
|
||||
"mcp": {
|
||||
@@ -74,7 +70,6 @@
|
||||
"bearerLabel": "Biến môi trường bearer token (tùy chọn)",
|
||||
"addButton": "Thêm",
|
||||
"removeButton": "Gỡ bỏ",
|
||||
"noServers": "Không tìm thấy máy chủ MCP nào.",
|
||||
"added": "Đã thêm {{name}}.",
|
||||
"removed": "Đã gỡ bỏ {{name}}."
|
||||
},
|
||||
@@ -91,10 +86,24 @@
|
||||
"addMarketplacePlaceholder": "https://github.com/owner/repo",
|
||||
"addMarketplaceButton": "Thêm",
|
||||
"marketplaceAdded": "Đã thêm marketplace.",
|
||||
"marketplaceRemoved": "Đã gỡ bỏ marketplace {{name}}.",
|
||||
"noMarketplaces": "Chưa đăng ký marketplace nào."
|
||||
"marketplaceRemoved": "Đã gỡ bỏ marketplace {{name}}."
|
||||
},
|
||||
"manage": {
|
||||
"working": "Đang xử lý…"
|
||||
"working": "Đang xử lý…",
|
||||
"noIdentifier": "Mục này không có định danh ổn định - hãy gỡ trực tiếp từ CLI."
|
||||
},
|
||||
"cli": {
|
||||
"unavailable": "CLI codex không có sẵn trên máy này ({{message}}) - cài đặt hoặc thêm vào PATH để quản lý mục này."
|
||||
},
|
||||
"backups": {
|
||||
"openButton": "Bản sao lưu",
|
||||
"title": "Bản sao lưu",
|
||||
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí CLI quét, nên không xuất hiện như mục thừa trong CLI.",
|
||||
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
|
||||
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:"
|
||||
},
|
||||
"snippet": {
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1342,10 +1342,13 @@ export interface CcConfigChangedPayload {
|
||||
* unlike {@link CcConfigChangedPayload} this is always dashboard-sourced. */
|
||||
export interface CodexConfigChangedPayload {
|
||||
source: "dashboard";
|
||||
action?: "write" | "delete";
|
||||
/** "write"/"delete" for file mutations; "add"/"remove" for the CLI-driven
|
||||
* mcp/plugin/marketplace routes. */
|
||||
action?: "write" | "delete" | "add" | "remove";
|
||||
/** "user" or "project" - Codex has no "project-local" scope. */
|
||||
scope?: "user" | "project";
|
||||
/** Artifact kind, e.g. "skills", "agents", "rules", "memory", "config". */
|
||||
/** Artifact kind, e.g. "skills", "agents", "rules", "memory", "config",
|
||||
* or "mcp"/"plugin"/"marketplace" for the CLI-driven routes. */
|
||||
type?: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user