feat(memory): clôture du sujet mémoire — bascule adaptative live + panneau UI (§14.5.5)
Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall, piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché, zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent). Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué). Pièce 2 (frontend) : feature mémoire complète en miroir de skills — MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus. Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un follow-up (défaut none = pleinement fonctionnel sans dépendance). Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
265
frontend/src/features/memory/MemoryEditor.tsx
Normal file
265
frontend/src/features/memory/MemoryEditor.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* `MemoryEditor` — a fullscreen overlay for creating or editing memory notes
|
||||
* (L14). Rendered on top of the rest of the UI (`fixed inset-0`).
|
||||
*
|
||||
* Provides:
|
||||
* - Create-mode: name (slug source) + description + type selector + content
|
||||
* - Edit-mode: slug shown read-only (identity is immutable), description + type
|
||||
* + content editable, and a read-only list of resolved `[[wikilinks]]`.
|
||||
*
|
||||
* Pure presentation: all mutations are delegated to the callbacks supplied by
|
||||
* the parent (`MemoryPanel`). Styled with `@/shared`; no inline styles.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { MemoryIndexEntry, MemoryLink, MemoryType } from "@/domain";
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ["user", "feedback", "project", "reference"];
|
||||
|
||||
export interface MemoryEditorProps {
|
||||
/**
|
||||
* When set, the editor is in edit-mode for the given index entry; otherwise it
|
||||
* is in create-mode (all fields start empty). The slug is immutable in edit.
|
||||
*/
|
||||
entry?: MemoryIndexEntry | null;
|
||||
/** Loads the full content of the entry being edited (edit-mode only). */
|
||||
loadContent?: (slug: string) => Promise<string>;
|
||||
/** Resolves the entry's `[[wikilinks]]` to existing target slugs (edit-mode only). */
|
||||
resolveLinks?: (slug: string) => Promise<MemoryLink[]>;
|
||||
/** Create-mode submit: name + description + type + content. */
|
||||
onCreate: (
|
||||
name: string,
|
||||
description: string,
|
||||
type: MemoryType,
|
||||
content: string,
|
||||
) => Promise<void>;
|
||||
/** Edit-mode submit: description + type + content (slug stays fixed). */
|
||||
onUpdate: (
|
||||
slug: string,
|
||||
description: string,
|
||||
type: MemoryType,
|
||||
content: string,
|
||||
) => Promise<void>;
|
||||
/** Called when the user cancels / closes the overlay. */
|
||||
onClose: () => void;
|
||||
/** Whether a save operation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function MemoryEditor({
|
||||
entry,
|
||||
loadContent,
|
||||
resolveLinks,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onClose,
|
||||
busy = false,
|
||||
}: MemoryEditorProps) {
|
||||
const editing = entry != null;
|
||||
|
||||
const [name, setName] = useState(entry?.title ?? "");
|
||||
const [description, setDescription] = useState(entry?.hook ?? "");
|
||||
const [type, setType] = useState<MemoryType>(entry?.type ?? "project");
|
||||
const [content, setContent] = useState("");
|
||||
const [links, setLinks] = useState<MemoryLink[]>([]);
|
||||
|
||||
// In edit-mode, hydrate the editable content and the resolved links lazily.
|
||||
useEffect(() => {
|
||||
if (!entry) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
if (loadContent) {
|
||||
const loaded = await loadContent(entry.slug);
|
||||
if (!cancelled) setContent(loaded);
|
||||
}
|
||||
if (resolveLinks) {
|
||||
const resolved = await resolveLinks(entry.slug);
|
||||
if (!cancelled) setLinks(resolved);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort hydration: leave fields as-is on failure.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [entry, loadContent, resolveLinks]);
|
||||
|
||||
const canSave =
|
||||
description.trim().length > 0 &&
|
||||
content.trim().length > 0 &&
|
||||
!busy &&
|
||||
(editing || name.trim().length > 0);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSave) return;
|
||||
if (editing) {
|
||||
await onUpdate(entry.slug, description.trim(), type, content);
|
||||
} else {
|
||||
await onCreate(name.trim(), description.trim(), type, content);
|
||||
}
|
||||
}
|
||||
|
||||
const selectClass = cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="memory editor"
|
||||
>
|
||||
{/* ── Header bar ── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-content">
|
||||
{editing ? `Edit note — ${entry.title}` : "New note"}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="close memory editor"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Form body ── */}
|
||||
<form
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
className="flex flex-1 flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
{/* ── Meta fields ── */}
|
||||
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
{editing ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Slug</span>
|
||||
<code className="truncate rounded-md border border-border bg-raised px-3 py-2 text-sm text-muted">
|
||||
{entry.slug}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label htmlFor="me-name" className="text-xs font-medium text-muted">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="me-name"
|
||||
aria-label="memory name"
|
||||
placeholder="My note"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={busy}
|
||||
className="min-w-48"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label htmlFor="me-type" className="text-xs font-medium text-muted">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="me-type"
|
||||
aria-label="memory type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as MemoryType)}
|
||||
disabled={busy}
|
||||
className={selectClass}
|
||||
>
|
||||
{MEMORY_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Description ── */}
|
||||
<div className="flex shrink-0 flex-col gap-1 border-b border-border px-4 py-3">
|
||||
<label htmlFor="me-description" className="text-xs font-medium text-muted">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
id="me-description"
|
||||
aria-label="memory description"
|
||||
placeholder="One-line hook"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
|
||||
<label htmlFor="me-content" className="mb-1 text-xs font-medium text-muted">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="me-content"
|
||||
aria-label="memory content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
|
||||
"border border-border outline-none transition-colors resize-none",
|
||||
"focus:border-primary placeholder:text-faint",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
placeholder="# Note Use [[other-slug]] to link…"
|
||||
/>
|
||||
|
||||
{/* ── Resolved links (edit-mode, read-only) ── */}
|
||||
{editing && (
|
||||
<div className="mt-3 shrink-0">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Links
|
||||
</h4>
|
||||
{links.length === 0 ? (
|
||||
<p className="mt-1 text-sm text-muted">No resolved links.</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{links.map((slug) => (
|
||||
<li
|
||||
key={slug}
|
||||
className="rounded-md border border-border bg-raised px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
{slug}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
aria-label="Save note"
|
||||
disabled={!canSave}
|
||||
loading={busy}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
frontend/src/features/memory/MemoryPanel.tsx
Normal file
169
frontend/src/features/memory/MemoryPanel.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* `MemoryPanel` — feature component for project memory management (L14).
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useMemory}. Styled with
|
||||
* `@/shared` design system tokens; no inline styles.
|
||||
*
|
||||
* Provides:
|
||||
* - The recall-oriented memory index (title + hook + type badge)
|
||||
* - A "New note" button that opens {@link MemoryEditor} in create-mode
|
||||
* - Per-entry "Edit" (opens the editor in edit-mode) and "Delete"
|
||||
*
|
||||
* The editor needs the full note content (the index only carries the hook), so
|
||||
* it loads it lazily through the memory gateway's `getMemory`.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { MemoryIndexEntry } from "@/domain";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { MemoryEditor } from "./MemoryEditor";
|
||||
import { useMemory } from "./useMemory";
|
||||
|
||||
export interface MemoryPanelProps {
|
||||
/** The project whose memory notes to manage. */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Editor open-state: "create" or edit-mode for a specific index entry. */
|
||||
type EditorState = { mode: "create" } | { mode: "edit"; entry: MemoryIndexEntry };
|
||||
|
||||
export function MemoryPanel({ projectId }: MemoryPanelProps) {
|
||||
const vm = useMemory(projectId);
|
||||
const { memory } = useGateways();
|
||||
|
||||
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||
const [editorBusy, setEditorBusy] = useState(false);
|
||||
|
||||
const loadContent = useCallback(
|
||||
async (slug: string) => (await memory.getMemory(projectId, slug)).content,
|
||||
[memory, projectId],
|
||||
);
|
||||
|
||||
async function handleCreate(
|
||||
name: string,
|
||||
description: string,
|
||||
type: MemoryIndexEntry["type"],
|
||||
content: string,
|
||||
) {
|
||||
setEditorBusy(true);
|
||||
try {
|
||||
await vm.createMemory({ name, description, type, content });
|
||||
setEditorState(null);
|
||||
} finally {
|
||||
setEditorBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(
|
||||
slug: string,
|
||||
description: string,
|
||||
type: MemoryIndexEntry["type"],
|
||||
content: string,
|
||||
) {
|
||||
setEditorBusy(true);
|
||||
try {
|
||||
await vm.updateMemory(slug, description, type, content);
|
||||
setEditorState(null);
|
||||
} finally {
|
||||
setEditorBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Fullscreen memory editor overlay ── */}
|
||||
{editorState !== null && (
|
||||
<MemoryEditor
|
||||
entry={editorState.mode === "edit" ? editorState.entry : null}
|
||||
loadContent={loadContent}
|
||||
resolveLinks={vm.resolveLinks}
|
||||
onCreate={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onClose={() => setEditorState(null)}
|
||||
busy={editorBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel title="Memory" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Create button ── */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Notes
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
aria-label="create memory"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setEditorState({ mode: "create" })}
|
||||
>
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Index list ── */}
|
||||
<div className="p-4">
|
||||
{vm.entries.length === 0 ? (
|
||||
<p className="text-sm text-muted">No memory notes yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.entries.map((e) => (
|
||||
<li
|
||||
key={e.slug}
|
||||
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-content">
|
||||
{e.title}
|
||||
</span>
|
||||
<span className="shrink-0 rounded border border-border bg-raised px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||
{e.type}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted">{e.hook}</span>
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`edit memory ${e.title}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => setEditorState({ mode: "edit", entry: e })}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`delete memory ${e.title}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.deleteMemory(e.slug)}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
frontend/src/features/memory/index.ts
Normal file
7
frontend/src/features/memory/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/** Public surface of the memory feature (L14). */
|
||||
export { MemoryPanel } from "./MemoryPanel";
|
||||
export type { MemoryPanelProps } from "./MemoryPanel";
|
||||
export { MemoryEditor } from "./MemoryEditor";
|
||||
export type { MemoryEditorProps } from "./MemoryEditor";
|
||||
export { useMemory } from "./useMemory";
|
||||
export type { MemoryViewModel } from "./useMemory";
|
||||
334
frontend/src/features/memory/memory.test.tsx
Normal file
334
frontend/src/features/memory/memory.test.tsx
Normal file
@ -0,0 +1,334 @@
|
||||
/**
|
||||
* L14 — memory feature wired to the stateful `MockMemoryGateway` via the real
|
||||
* `DIProvider` (same harness as `skills.test.tsx`).
|
||||
*
|
||||
* Covers:
|
||||
* - index rendering (title + hook + type badge) on mount
|
||||
* - create a note (New → fill name/description/type/content → Save) → appears in
|
||||
* the list, and `createMemory` receives the right payload
|
||||
* - edit a note (slug read-only; description/type/content editable) → persisted
|
||||
* - delete a note → removed from the list
|
||||
* - `[[slug]]` link resolution (existing target listed; broken link ignored)
|
||||
* - basic error state (gateway rejects → alert message shown)
|
||||
*
|
||||
* Also includes `MockMemoryGateway` unit tests (CRUD + index derivation +
|
||||
* `resolveLinks` ignoring broken links).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { MockMemoryGateway } from "@/adapters/mock";
|
||||
import type { GatewayError } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MemoryPanel } from "./MemoryPanel";
|
||||
|
||||
const PROJECT_ID = "proj-memory-test";
|
||||
|
||||
function renderMemoryPanel(memory?: MockMemoryGateway) {
|
||||
const mem = memory ?? new MockMemoryGateway();
|
||||
const gateways = { memory: mem } as unknown as Gateways;
|
||||
return {
|
||||
memory: mem,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<MemoryPanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForMemoryIdle() {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", { name: "create memory" }) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the editor in create-mode, fills it, and saves. */
|
||||
async function createMemory(
|
||||
name: string,
|
||||
description: string,
|
||||
content: string,
|
||||
type: "user" | "feedback" | "project" | "reference" = "project",
|
||||
) {
|
||||
await waitForMemoryIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("memory name")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("memory name"), {
|
||||
target: { value: name },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||
target: { value: description },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory type"), {
|
||||
target: { value: type },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||
target: { value: content },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryPanel feature tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
it("shows the empty state initially", async () => {
|
||||
renderMemoryPanel();
|
||||
await waitForMemoryIdle();
|
||||
expect(screen.getByText("No memory notes yet.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the index (title + hook + type) on mount", async () => {
|
||||
const memory = new MockMemoryGateway();
|
||||
await memory.createMemory({
|
||||
projectId: PROJECT_ID,
|
||||
name: "Build AppImage",
|
||||
description: "use APPIMAGE_EXTRACT_AND_RUN=1",
|
||||
type: "reference",
|
||||
content: "# notes",
|
||||
});
|
||||
renderMemoryPanel(memory);
|
||||
|
||||
await screen.findByText("Build AppImage");
|
||||
expect(screen.getByText("use APPIMAGE_EXTRACT_AND_RUN=1")).toBeTruthy();
|
||||
// Type badge.
|
||||
expect(screen.getByText("reference")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating a note adds it to the list with the right payload", async () => {
|
||||
const { memory } = renderMemoryPanel();
|
||||
const spy = vi.spyOn(memory, "createMemory");
|
||||
|
||||
await createMemory("My Note", "a one-line hook", "# body", "user");
|
||||
await screen.findByText("My Note");
|
||||
|
||||
// Payload received by the gateway.
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
projectId: PROJECT_ID,
|
||||
name: "My Note",
|
||||
description: "a one-line hook",
|
||||
type: "user",
|
||||
content: "# body",
|
||||
});
|
||||
|
||||
// Persisted in the gateway under the derived slug.
|
||||
const note = await memory.getMemory(PROJECT_ID, "my-note");
|
||||
expect(note.name).toBe("My Note");
|
||||
expect(note.description).toBe("a one-line hook");
|
||||
expect(note.type).toBe("user");
|
||||
expect(note.content).toBe("# body");
|
||||
});
|
||||
|
||||
it("Save is disabled until description and content are filled", async () => {
|
||||
renderMemoryPanel();
|
||||
await waitForMemoryIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Save note" })).toBeTruthy(),
|
||||
);
|
||||
const save = screen.getByRole("button", {
|
||||
name: "Save note",
|
||||
}) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
// Filling everything enables Save.
|
||||
fireEvent.change(screen.getByLabelText("memory name"), {
|
||||
target: { value: "X" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||
target: { value: "h" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||
target: { value: "c" },
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Save note" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("editing a note keeps the slug read-only and persists the changes", async () => {
|
||||
const { memory } = renderMemoryPanel();
|
||||
await createMemory("Editable Note", "old hook", "old body", "project");
|
||||
await screen.findByText("Editable Note");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "edit memory Editable Note" }),
|
||||
);
|
||||
|
||||
// Slug shown read-only — there's no editable "memory name" input in edit-mode.
|
||||
await waitFor(() => expect(screen.getByText("editable-note")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("memory name")).toBeNull();
|
||||
|
||||
// Description / type / content are editable.
|
||||
fireEvent.change(screen.getByLabelText("memory description"), {
|
||||
target: { value: "new hook" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory type"), {
|
||||
target: { value: "feedback" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("memory content"), {
|
||||
target: { value: "new body" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const note = await memory.getMemory(PROJECT_ID, "editable-note");
|
||||
expect(note.description).toBe("new hook");
|
||||
expect(note.type).toBe("feedback");
|
||||
expect(note.content).toBe("new body");
|
||||
});
|
||||
});
|
||||
|
||||
it("deleting a note removes it from the list", async () => {
|
||||
renderMemoryPanel();
|
||||
await createMemory("To Delete", "hook", "body", "project");
|
||||
await screen.findByText("To Delete");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "delete memory To Delete" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("To Delete")).toBeNull());
|
||||
});
|
||||
|
||||
it("resolves [[slug]] links: existing target listed, broken link ignored", async () => {
|
||||
const memory = new MockMemoryGateway();
|
||||
// Target note that the linker will reference.
|
||||
await memory.createMemory({
|
||||
projectId: PROJECT_ID,
|
||||
name: "Target Note",
|
||||
description: "the target",
|
||||
type: "project",
|
||||
content: "# target",
|
||||
});
|
||||
// Source note links to the existing target and to a non-existent slug.
|
||||
await memory.createMemory({
|
||||
projectId: PROJECT_ID,
|
||||
name: "Source Note",
|
||||
description: "the source",
|
||||
type: "project",
|
||||
content: "See [[target-note]] and [[ghost-slug]].",
|
||||
});
|
||||
renderMemoryPanel(memory);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "edit memory Source Note" }),
|
||||
);
|
||||
|
||||
// The resolved link to the existing note appears; the broken one does not.
|
||||
await waitFor(() => expect(screen.getByText("target-note")).toBeTruthy());
|
||||
expect(screen.queryByText("ghost-slug")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an error message when the gateway rejects", async () => {
|
||||
const memory = new MockMemoryGateway();
|
||||
const err: GatewayError = { code: "INTERNAL", message: "boom from gateway" };
|
||||
vi.spyOn(memory, "readIndex").mockRejectedValue(err);
|
||||
|
||||
renderMemoryPanel(memory);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/boom from gateway/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockMemoryGateway unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MockMemoryGateway (unit)", () => {
|
||||
it("CRUD: create → get → update → delete by immutable slug", async () => {
|
||||
const gw = new MockMemoryGateway();
|
||||
await gw.createMemory({
|
||||
projectId: "p",
|
||||
name: "Hello World",
|
||||
description: "d",
|
||||
type: "project",
|
||||
content: "c",
|
||||
});
|
||||
|
||||
const got = await gw.getMemory("p", "hello-world");
|
||||
expect(got.name).toBe("Hello World");
|
||||
|
||||
await gw.updateMemory("p", "hello-world", "d2", "user", "c2");
|
||||
const updated = await gw.getMemory("p", "hello-world");
|
||||
expect(updated.description).toBe("d2");
|
||||
expect(updated.type).toBe("user");
|
||||
expect(updated.content).toBe("c2");
|
||||
// Name (and therefore slug) is immutable.
|
||||
expect(updated.name).toBe("Hello World");
|
||||
|
||||
await gw.deleteMemory("p", "hello-world");
|
||||
await expect(gw.getMemory("p", "hello-world")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("getMemory / updateMemory / deleteMemory throw NOT_FOUND for unknown slug", async () => {
|
||||
const gw = new MockMemoryGateway();
|
||||
await expect(gw.getMemory("p", "ghost")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await expect(
|
||||
gw.updateMemory("p", "ghost", "d", "project", "c"),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(gw.deleteMemory("p", "ghost")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("readIndex derives the recall index from the notes", async () => {
|
||||
const gw = new MockMemoryGateway();
|
||||
await gw.createMemory({
|
||||
projectId: "p",
|
||||
name: "Indexed Note",
|
||||
description: "hooky",
|
||||
type: "reference",
|
||||
content: "x",
|
||||
});
|
||||
const index = await gw.readIndex("p");
|
||||
expect(index).toEqual([
|
||||
{ slug: "indexed-note", title: "Indexed Note", hook: "hooky", type: "reference" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("notes are partitioned by project", async () => {
|
||||
const gw = new MockMemoryGateway();
|
||||
await gw.createMemory({
|
||||
projectId: "p1",
|
||||
name: "A",
|
||||
description: "d",
|
||||
type: "project",
|
||||
content: "c",
|
||||
});
|
||||
expect(await gw.readIndex("p1")).toHaveLength(1);
|
||||
expect(await gw.readIndex("p2")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolveLinks keeps existing targets and ignores broken / self links", async () => {
|
||||
const gw = new MockMemoryGateway();
|
||||
await gw.createMemory({
|
||||
projectId: "p",
|
||||
name: "Alpha",
|
||||
description: "d",
|
||||
type: "project",
|
||||
content: "c",
|
||||
});
|
||||
await gw.createMemory({
|
||||
projectId: "p",
|
||||
name: "Beta",
|
||||
description: "d",
|
||||
type: "project",
|
||||
content: "Links [[alpha]], [[ghost]] and self [[beta]].",
|
||||
});
|
||||
const links = await gw.resolveLinks("p", "beta");
|
||||
expect(links).toEqual(["alpha"]);
|
||||
});
|
||||
});
|
||||
137
frontend/src/features/memory/useMemory.ts
Normal file
137
frontend/src/features/memory/useMemory.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* `useMemory` — view-model hook for the memory feature (L14).
|
||||
*
|
||||
* Owns the recall-oriented memory index of one project and the CRUD actions.
|
||||
* Consumes {@link MemoryGateway} exclusively; never touches `invoke()` or
|
||||
* `@tauri-apps/api`, keeping the component layer testable with mock gateways
|
||||
* (ARCHITECTURE §1.3). Note identity is the immutable **slug**.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, MemoryIndexEntry, MemoryLink, MemoryType } from "@/domain";
|
||||
import type { CreateMemoryInput } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the memory UI needs from this hook. */
|
||||
export interface MemoryViewModel {
|
||||
/** The recall-oriented memory index entries. */
|
||||
entries: MemoryIndexEntry[];
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Reloads the index. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Creates a new note and refreshes the index. */
|
||||
createMemory: (input: Omit<CreateMemoryInput, "projectId">) => Promise<void>;
|
||||
/** Updates a note's mutable fields (slug is immutable) and refreshes. */
|
||||
updateMemory: (
|
||||
slug: string,
|
||||
description: string,
|
||||
type: MemoryType,
|
||||
content: string,
|
||||
) => Promise<void>;
|
||||
/** Deletes a note by slug and refreshes. */
|
||||
deleteMemory: (slug: string) => Promise<void>;
|
||||
/** Resolves a note's `[[wikilinks]]` to existing target slugs. */
|
||||
resolveLinks: (slug: string) => Promise<MemoryLink[]>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useMemory(projectId: string): MemoryViewModel {
|
||||
const { memory } = useGateways();
|
||||
|
||||
const [entries, setEntries] = useState<MemoryIndexEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setEntries(await memory.readIndex(projectId));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [memory, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const createMemory = useCallback(
|
||||
async (input: Omit<CreateMemoryInput, "projectId">) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await memory.createMemory({ ...input, projectId });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[memory, projectId, refresh],
|
||||
);
|
||||
|
||||
const updateMemory = useCallback(
|
||||
async (
|
||||
slug: string,
|
||||
description: string,
|
||||
type: MemoryType,
|
||||
content: string,
|
||||
) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await memory.updateMemory(projectId, slug, description, type, content);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[memory, projectId, refresh],
|
||||
);
|
||||
|
||||
const deleteMemory = useCallback(
|
||||
async (slug: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await memory.deleteMemory(projectId, slug);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[memory, projectId, refresh],
|
||||
);
|
||||
|
||||
const resolveLinks = useCallback(
|
||||
(slug: string) => memory.resolveLinks(projectId, slug),
|
||||
[memory, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
entries,
|
||||
error,
|
||||
busy,
|
||||
refresh,
|
||||
createMemory,
|
||||
updateMemory,
|
||||
deleteMemory,
|
||||
resolveLinks,
|
||||
};
|
||||
}
|
||||
@ -35,18 +35,26 @@ import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjects } from "./useProjects";
|
||||
|
||||
type SidebarTab = "projects" | "agents" | "templates" | "skills" | "git";
|
||||
type SidebarTab =
|
||||
| "projects"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "memory"
|
||||
| "git";
|
||||
|
||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "projects", label: "Projects" },
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "git", label: "Git" },
|
||||
];
|
||||
|
||||
@ -269,6 +277,14 @@ export function ProjectsView() {
|
||||
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||
)}
|
||||
|
||||
{/* Memory panel */}
|
||||
{sidebarTab === "memory" && active && (
|
||||
<MemoryPanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "memory" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage memory.</p>
|
||||
)}
|
||||
|
||||
{/* Git panel */}
|
||||
{sidebarTab === "git" && active && (
|
||||
<GitPanel projectId={active.id} />
|
||||
|
||||
Reference in New Issue
Block a user