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:
@@ -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