/** * @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. Typed text is a draft: closing * asks first, and it is a real dialog (focus trap, Escape). * * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BookOpen, X, Save, AlertCircle } from "lucide-react"; import { api, type CwdSuggestion } from "../../lib/api"; import { confirmDiscard } from "../../lib/confirm"; import { useDialog } from "../../hooks/useDialog"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import { PathPickButton } from "../PathPickButton"; 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); }, []); // Typed text is a draft: closing (X, Cancel, backdrop, Escape) asks first, // and Escape inside the textarea only leaves the field. const dirty = dir.trim() !== "" || content !== template; useUnsavedGuard(dirty); const panelRef = useRef(null); const requestClose = useCallback(async () => { if (dirty && !(await confirmDiscard())) return; onClose(); }, [dirty, onClose]); useDialog(true, panelRef, () => { const el = document.activeElement; if (el instanceof HTMLTextAreaElement) { el.blur(); return; } void requestClose(); }); 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 (
void requestClose()} >
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 })}