diff --git a/client/src/components/config/ProjectMemoryModal.tsx b/client/src/components/config/ProjectMemoryModal.tsx new file mode 100644 index 00000000..2752e067 --- /dev/null +++ b/client/src/components/config/ProjectMemoryModal.tsx @@ -0,0 +1,180 @@ +/** + * @file ProjectMemoryModal.tsx + * @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a + * directory that is not the dashboard's own. Shared by the Claude Code and + * Codex config pages; the caller supplies its i18n namespace and the create + * call, so this component knows about neither tool. + * + * The directory list is `GET /api/run/cwds` (the dashboard's known projects), + * offered as suggestions on a plain text input rather than a closed dropdown: + * a project you have never run through the dashboard is exactly the case this + * modal exists for, so it must stay typeable. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BookOpen, X, Save, AlertCircle } from "lucide-react"; +import { api, type CwdSuggestion } from "../../lib/api"; + +interface ProjectMemoryModalProps { + /** i18n namespace of the calling page — "ccConfig" or "codexConfig". */ + ns: string; + /** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */ + filename: string; + /** Starting body text, so the user edits a skeleton instead of a blank box. */ + template: string; + onClose: () => void; + /** Resolves with the path actually written (the server realpaths the dir). */ + onCreate: (args: { dir: string; content: string }) => Promise; +} + +export function ProjectMemoryModal({ + ns, + filename, + template, + onClose, + onCreate, +}: ProjectMemoryModalProps) { + // Every key lives under `projectMemory.*` in BOTH namespaces, so this + // component never depends on the calling page's other key groups lining up. + const { t } = useTranslation(ns); + const listId = useId(); + const [dir, setDir] = useState(""); + const [content, setContent] = useState(template); + const [suggestions, setSuggestions] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.run + .cwds() + .then((r) => setSuggestions(r.items)) + // Suggestions are a convenience; the input still works without them. + .catch(() => undefined); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const handleCreate = useCallback(async () => { + const target = dir.trim(); + if (!target) { + setError(t("projectMemory.dirRequired")); + return; + } + setSaving(true); + setError(null); + try { + await onCreate({ dir: target, content }); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "unknown"); + } finally { + setSaving(false); + } + }, [dir, content, onCreate, t]); + + return ( +
+
e.stopPropagation()} + > +
+ + + {t("projectMemory.title", { file: filename })} + + +
+ +
+
+ + setDir(e.target.value)} + placeholder="/home/you/projects/my-app" + spellCheck={false} + 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" + /> + + {suggestions.map((s) => ( + + ))} + +

+ {t("projectMemory.dirHelp", { file: filename })} +

+
+ +
+ +