Files
AgentHub/client/src/components/config/ProjectMemoryModal.tsx
T
nntrivi2001 85187dfc78 feat(config): create a new project's memory file from Settings
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.
2026-09-09 01:45:00 +07:00

181 lines
6.7 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<string>;
}
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<CwdSuggestion[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
className="relative w-full max-w-3xl max-h-[90vh] 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">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
<div>
<label
htmlFor={`${listId}-dir`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => 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"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
{s.label}
</option>
))}
</datalist>
<p className="mt-1 text-[10px] text-fg-muted">
{t("projectMemory.dirHelp", { file: filename })}
</p>
</div>
<div>
<label
htmlFor={`${listId}-content`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.contentLabel")}
</label>
<textarea
id={`${listId}-content`}
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
/>
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
</button>
</div>
</div>
</div>
);
}