/** * @file EditorModal.tsx * @description Create/edit modal for text-file config artifacts (skills, * agents, commands, output styles, memory). Tool-agnostic: it takes a * `fetchContent` callback instead of reaching for any particular tool's API, * so the same modal serves both the Claude Code and Codex config pages. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, X, Save, AlertCircle } from "lucide-react"; import type { CcArtifactType } from "../../lib/api"; /** Claude Code's own scope union, kept as the default so existing callers * (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope — * its caller instantiates `TScope` as just `"user" | "project"` instead of * casting its own narrower type into this one. */ type DefaultScope = "user" | "project" | "auto-memory"; export type EditorState< TType extends string = CcArtifactType, TScope extends string = DefaultScope, > = | { mode: "create"; type: TType; defaultScope: "user" | "project"; template: string; project?: string; // set for type === "auto-memory" } | { mode: "edit"; type: TType; scope: TScope; name: string; filePath: string; project?: string; // set for type === "auto-memory" } | 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, TScope extends string = DefaultScope, > { state: NonNullable>; fetchContent: (filePath: string) => Promise; onClose: () => void; onSave: (args: { type: TType; targetScope: TScope; name: string | undefined; content: string; project?: string; }) => Promise; /** 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, TScope extends string = DefaultScope, >({ state, fetchContent, onClose, onSave, namelessTypes = DEFAULT_NAMELESS_TYPES, }: EditorModalProps) { 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(isCreate ? state.template : ""); const [name, setName] = useState(""); const [targetScope, setTargetScope] = useState<"user" | "project">( isCreate ? state.defaultScope : state.scope === "auto-memory" ? "user" : (state.scope as "user" | "project") ); const [loading, setLoading] = useState(!isCreate); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // For edit mode, fetch the actual file content useEffect(() => { if (state.mode === "edit") { setLoading(true); fetchContent(state.filePath) .then((text) => { setContent(text); setLoading(false); }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : "unknown"; setError(msg); setLoading(false); }); } }, [state, fetchContent]); // Esc to close useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const handleSave = useCallback(async () => { setSaving(true); setError(null); try { if (!isNameless && state.mode === "create" && !name) { setError(t("edit.nameLabel")); setSaving(false); return; } // 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 : isNameless ? undefined : createName; await onSave({ type: state.type, // Widening back to TScope: this component is the one place that // legitimately knows about all three scope shapes across both // callers; a Codex caller's TScope ("user" | "project") never // actually receives "auto-memory" since isAutoMemory can only be // true when TType includes "auto-memory" (Claude-only). targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope, name: effectiveName, content, project: state.project, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "unknown"; setError(t("edit.writeError", { message: msg })); } finally { setSaving(false); } }, [state, targetScope, name, content, isAutoMemory, onSave, t]); const titleText = isCreate ? isAutoMemory ? t("memory.newFileTitle", { project: state.project ?? "" }) : t("edit.newTitle", { type: state.type }) : t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" }); return (
e.stopPropagation()} >
{titleText}
{isCreate && !isNameless && (
setName(e.target.value)} placeholder={ isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder") } pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"} className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50" />

{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}

{isAutoMemory ? (
{state.project}

{t("memory.projectHelp")}

) : (
{(["user", "project"] as const).map((s) => ( ))}
)}
)}
{loading ? (
) : (