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.
This commit is contained in:
@@ -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ĩ <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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file ProjectMemoryModal.test.tsx
|
||||
* @description Unit tests for `ProjectMemoryModal`: it must send the directory
|
||||
* the user typed (not a suggestion it picked for them), must refuse to submit
|
||||
* an empty one, and must render its labels from whichever namespace the
|
||||
* calling page passes — the Codex page shares this component with the Claude
|
||||
* page, and a key group that only exists in `ccConfig` would render raw.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ProjectMemoryModal } from "../ProjectMemoryModal";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { cwds: vi.fn() } },
|
||||
}));
|
||||
|
||||
const cwds = vi.mocked(api.run.cwds);
|
||||
|
||||
beforeEach(() => {
|
||||
cwds.mockResolvedValue({
|
||||
items: [{ kind: "recent", path: "/home/me/known-project", label: "known-project" }],
|
||||
});
|
||||
});
|
||||
|
||||
function renderModal(over: Partial<Parameters<typeof ProjectMemoryModal>[0]> = {}) {
|
||||
const onCreate = vi.fn().mockResolvedValue("/home/me/typed/CLAUDE.md");
|
||||
render(
|
||||
<ProjectMemoryModal
|
||||
ns="ccConfig"
|
||||
filename="CLAUDE.md"
|
||||
template="# Project memory"
|
||||
onClose={() => {}}
|
||||
onCreate={onCreate}
|
||||
{...over}
|
||||
/>
|
||||
);
|
||||
return { onCreate };
|
||||
}
|
||||
|
||||
describe("ProjectMemoryModal", () => {
|
||||
it("sends the typed directory and the edited content", async () => {
|
||||
const { onCreate } = renderModal();
|
||||
await userEvent.type(screen.getByLabelText(/project directory/i), "/home/me/typed");
|
||||
const body = screen.getByLabelText(/content/i);
|
||||
await userEvent.clear(body);
|
||||
await userEvent.type(body, "hello");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(onCreate).toHaveBeenCalledWith({ dir: "/home/me/typed", content: "hello" });
|
||||
});
|
||||
|
||||
it("refuses to submit without a directory", async () => {
|
||||
const { onCreate } = renderModal();
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(onCreate).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/directory is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the dashboard's known projects as suggestions, not as the only choice", async () => {
|
||||
renderModal();
|
||||
// A datalist suggests; it does not constrain. A project the dashboard has
|
||||
// never run in is precisely this modal's reason to exist, so the input
|
||||
// stays free text.
|
||||
const input = screen.getByLabelText(/project directory/i);
|
||||
expect(input.tagName).toBe("INPUT");
|
||||
// jsdom exposes no accessible role for datalist options, so query the DOM.
|
||||
await vi.waitFor(() => {
|
||||
const list = document.getElementById(input.getAttribute("list")!);
|
||||
expect(list?.querySelector('option[value="/home/me/known-project"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders from the Codex namespace too", async () => {
|
||||
renderModal({ ns: "codexConfig", filename: "AGENTS.md" });
|
||||
expect(await screen.findByText(/Create AGENTS\.md for another project/i)).toBeInTheDocument();
|
||||
// A missing key renders as the bare key path — catch that directly.
|
||||
expect(screen.queryByText(/projectMemory\./)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server's rejection instead of closing", async () => {
|
||||
const onCreate = vi.fn().mockRejectedValue(new Error("CLAUDE.md already exists in /p"));
|
||||
render(
|
||||
<ProjectMemoryModal
|
||||
ns="ccConfig"
|
||||
filename="CLAUDE.md"
|
||||
template=""
|
||||
onClose={() => {}}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText(/project directory/i), "/p");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(await screen.findByText(/already exists in \/p/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,20 @@
|
||||
"projectLabel": "Project",
|
||||
"projectHelp": "Created in this project's memory dir."
|
||||
},
|
||||
"projectMemory": {
|
||||
"newButton": "New project memory",
|
||||
"title": "Create {{file}} for another project",
|
||||
"dirLabel": "Project directory",
|
||||
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
|
||||
"dirRequired": "A project directory is required",
|
||||
"createButton": "Create",
|
||||
"created": "Created {{path}}",
|
||||
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
|
||||
"contentLabel": "Content",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Creating…",
|
||||
"close": "Close"
|
||||
},
|
||||
"edit": {
|
||||
"newButton": "New",
|
||||
"editButton": "Edit",
|
||||
|
||||
@@ -56,6 +56,20 @@
|
||||
"memory": {
|
||||
"missing": "No AGENTS.md found."
|
||||
},
|
||||
"projectMemory": {
|
||||
"newButton": "New project memory",
|
||||
"title": "Create {{file}} for another project",
|
||||
"dirLabel": "Project directory",
|
||||
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
|
||||
"dirRequired": "A project directory is required",
|
||||
"createButton": "Create",
|
||||
"created": "Created {{path}}",
|
||||
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
|
||||
"contentLabel": "Content",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Creating…",
|
||||
"close": "Close"
|
||||
},
|
||||
"config": {
|
||||
"missing": "No config.toml found."
|
||||
},
|
||||
|
||||
@@ -169,6 +169,20 @@
|
||||
"projectLabel": "Dự án",
|
||||
"projectHelp": "Được tạo trong thư mục bộ nhớ của dự án này."
|
||||
},
|
||||
"projectMemory": {
|
||||
"newButton": "Memory cho project khác",
|
||||
"title": "Tạo {{file}} cho một project khác",
|
||||
"dirLabel": "Thư mục project",
|
||||
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
|
||||
"dirRequired": "Cần nhập thư mục project",
|
||||
"createButton": "Tạo",
|
||||
"created": "Đã tạo {{path}}",
|
||||
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
|
||||
"contentLabel": "Nội dung",
|
||||
"cancel": "Huỷ",
|
||||
"saving": "Đang tạo…",
|
||||
"close": "Đóng"
|
||||
},
|
||||
"edit": {
|
||||
"newButton": "Tạo mới",
|
||||
"editButton": "Sửa",
|
||||
|
||||
@@ -56,6 +56,20 @@
|
||||
"memory": {
|
||||
"missing": "Không tìm thấy AGENTS.md."
|
||||
},
|
||||
"projectMemory": {
|
||||
"newButton": "Memory cho project khác",
|
||||
"title": "Tạo {{file}} cho một project khác",
|
||||
"dirLabel": "Thư mục project",
|
||||
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
|
||||
"dirRequired": "Cần nhập thư mục project",
|
||||
"createButton": "Tạo",
|
||||
"created": "Đã tạo {{path}}",
|
||||
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
|
||||
"contentLabel": "Nội dung",
|
||||
"cancel": "Huỷ",
|
||||
"saving": "Đang tạo…",
|
||||
"close": "Đóng"
|
||||
},
|
||||
"config": {
|
||||
"missing": "Không tìm thấy config.toml."
|
||||
},
|
||||
|
||||
@@ -1470,6 +1470,22 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* POST /api/cc-config/project-memory - create `CLAUDE.md` in a project the
|
||||
* dashboard has never run in.
|
||||
*
|
||||
* Unlike {@link api.ccConfig.write}, whose "project" scope always means the
|
||||
* dashboard server's own directory, this one writes into the directory you
|
||||
* name. It only ever creates: an existing CLAUDE.md comes back as a 409.
|
||||
*
|
||||
* @param args `dir` (absolute, must already exist) and the file's `content`.
|
||||
* @returns {@link ProjectMemoryResult} — the written path and its size.
|
||||
*/
|
||||
createProjectMemory: (args: { dir: string; content: string }) =>
|
||||
request<ProjectMemoryResult>("/cc-config/project-memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/cc-config/file - remove a config artifact (also backed up).
|
||||
*
|
||||
@@ -1702,6 +1718,19 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* POST /api/codex-config/project-memory - create `AGENTS.md` in a project
|
||||
* the dashboard has never run in. The Codex twin of
|
||||
* {@link api.ccConfig.createProjectMemory}, same create-only rule.
|
||||
*
|
||||
* @param args `dir` (absolute, must already exist) and the file's `content`.
|
||||
* @returns {@link ProjectMemoryResult} — the written path and its size.
|
||||
*/
|
||||
createProjectMemory: (args: { dir: string; content: string }) =>
|
||||
request<ProjectMemoryResult>("/codex-config/project-memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/codex-config/file - remove a config artifact (also backed up).
|
||||
*
|
||||
@@ -2956,6 +2985,17 @@ export interface CodexMutationResult {
|
||||
backup?: { [key: string]: unknown } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a project's memory file (CLAUDE.md / AGENTS.md) via
|
||||
* POST /api/{cc,codex}-config/project-memory. `file` is the resolved path —
|
||||
* worth showing back, since the server realpaths the directory it was given.
|
||||
*/
|
||||
export interface ProjectMemoryResult {
|
||||
file: string;
|
||||
size: number;
|
||||
created: true;
|
||||
}
|
||||
|
||||
/** One timestamped backup of a config artifact, from GET /api/codex-config/backups. */
|
||||
export interface CodexBackup {
|
||||
type: string;
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
||||
import { MdItemList } from "../components/config/MdItemList";
|
||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||
import {
|
||||
Empty,
|
||||
SkeletonRows,
|
||||
@@ -246,6 +247,7 @@ export function CcConfig() {
|
||||
const [toast, setToast] = useState<Toast>(null);
|
||||
const [backupsOpen, setBackupsOpen] = useState(false);
|
||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
|
||||
|
||||
// Auto-dismiss toasts after 5s
|
||||
useEffect(() => {
|
||||
@@ -453,6 +455,20 @@ export function CcConfig() {
|
||||
[fetchAll, t]
|
||||
);
|
||||
|
||||
// Create CLAUDE.md in ANOTHER project's directory. Deliberately not part of
|
||||
// `handleSave`: that one writes this machine's user/project layers through
|
||||
// the artifact writer, while this names its own target directory.
|
||||
const handleCreateProjectMemory = useCallback(
|
||||
async (args: { dir: string; content: string }) => {
|
||||
const result = await api.ccConfig.createProjectMemory(args);
|
||||
setProjectMemoryOpen(false);
|
||||
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
|
||||
void fetchAll();
|
||||
return result.file;
|
||||
},
|
||||
[fetchAll, t]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
@@ -527,6 +543,18 @@ export function CcConfig() {
|
||||
{t("edit.newButton")}
|
||||
</button>
|
||||
)}
|
||||
{/* The memory tab's own "new" is a different thing: the two
|
||||
CLAUDE.md cards below are this machine's user + current-project
|
||||
files, while this creates one in ANOTHER project's directory. */}
|
||||
{tab === "memory" && (
|
||||
<button
|
||||
onClick={() => setProjectMemoryOpen(true)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("projectMemory.newButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
@@ -563,6 +591,15 @@ export function CcConfig() {
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{projectMemoryOpen && (
|
||||
<ProjectMemoryModal
|
||||
ns="ccConfig"
|
||||
filename="CLAUDE.md"
|
||||
template={t("projectMemory.template")}
|
||||
onClose={() => setProjectMemoryOpen(false)}
|
||||
onCreate={handleCreateProjectMemory}
|
||||
/>
|
||||
)}
|
||||
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
|
||||
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
||||
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "../components/config/ConfirmDeleteModal";
|
||||
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||
import {
|
||||
Empty,
|
||||
SkeletonRows,
|
||||
@@ -152,6 +153,7 @@ export function CodexConfig() {
|
||||
// (mirrors CcConfig.tsx's PluginsPanel: "a failure here is a normal state").
|
||||
const [cliError, setCliError] = useState<string | null>(null);
|
||||
const [backupsOpen, setBackupsOpen] = useState(false);
|
||||
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewer, setViewer] = useState<{
|
||||
@@ -322,6 +324,20 @@ export function CodexConfig() {
|
||||
[fetchAll, t]
|
||||
);
|
||||
|
||||
// Create AGENTS.md in ANOTHER project's directory — the Codex twin of
|
||||
// CcConfig's handler; `handleSave` above only ever writes this machine's
|
||||
// user/project layers.
|
||||
const handleCreateProjectMemory = useCallback(
|
||||
async (args: { dir: string; content: string }) => {
|
||||
const result = await api.codexConfig.createProjectMemory(args);
|
||||
setProjectMemoryOpen(false);
|
||||
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
|
||||
void fetchAll();
|
||||
return result.file;
|
||||
},
|
||||
[fetchAll, t]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
@@ -393,6 +409,20 @@ export function CodexConfig() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The memory tab hides the search bar, but still needs its own
|
||||
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
|
||||
the user/project files listed below. */}
|
||||
{tab === "memory" && (
|
||||
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => setProjectMemoryOpen(true)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t("projectMemory.newButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<TabPanel
|
||||
tab={tab}
|
||||
@@ -429,6 +459,15 @@ export function CodexConfig() {
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{projectMemoryOpen && (
|
||||
<ProjectMemoryModal
|
||||
ns="codexConfig"
|
||||
filename="AGENTS.md"
|
||||
template={t("projectMemory.template")}
|
||||
onClose={() => setProjectMemoryOpen(false)}
|
||||
onCreate={handleCreateProjectMemory}
|
||||
/>
|
||||
)}
|
||||
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user