From 85187dfc78c59247654907aa50549e233e7a50fa Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 9 Sep 2026 01:45:00 +0700 Subject: [PATCH] feat(config): create a new project's memory file from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Memory tab could only show this machine's user memory and the dashboard's own project file. Setting up a project you had not run through the dashboard meant opening a terminal — `PUT /file`'s "project" scope always resolves against the server's cwd, on purpose. Add one narrow route per tool that writes into the directory you name: CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same `project-memory.js`. Being the only write path that honours a caller-supplied directory, it pays for that: - the directory must be absolute and already exist — nothing is created for you; - the filename comes from the route, never from the body; - an existing file, symlink included, is a 409 — this can only create, never overwrite; - it sits behind the loopback-Origin guard /api/run uses, so a page in the user's browser cannot plant agent instructions in a project directory. That guard moves to lib/same-origin.js to be shared. The modal takes the dashboard's known project dirs as suggestions on a free-text input rather than a closed list: a project it has never seen is exactly the case this exists for. Mutation-checked: dropping the origin guard, and swapping lstat for stat, each fail a test. --- .../components/config/ProjectMemoryModal.tsx | 180 ++++++++++++++++++ .../__tests__/ProjectMemoryModal.test.tsx | 98 ++++++++++ client/src/i18n/locales/en/ccConfig.json | 14 ++ client/src/i18n/locales/en/codexConfig.json | 14 ++ client/src/i18n/locales/vi/ccConfig.json | 14 ++ client/src/i18n/locales/vi/codexConfig.json | 14 ++ client/src/lib/api.ts | 40 ++++ client/src/pages/CcConfig.tsx | 37 ++++ client/src/pages/CodexConfig.tsx | 39 ++++ docs/API.md | 4 + server/__tests__/project-memory.test.js | 179 +++++++++++++++++ server/lib/project-memory.js | 98 ++++++++++ server/lib/same-origin.js | 50 +++++ server/routes/cc-config.js | 27 +++ server/routes/codex-config.js | 24 +++ server/routes/run.js | 37 +--- 16 files changed, 833 insertions(+), 36 deletions(-) create mode 100644 client/src/components/config/ProjectMemoryModal.tsx create mode 100644 client/src/components/config/__tests__/ProjectMemoryModal.test.tsx create mode 100644 server/__tests__/project-memory.test.js create mode 100644 server/lib/project-memory.js create mode 100644 server/lib/same-origin.js 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 })} +

+
+ +
+ +