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:
2026-06-08 13:28:27 +02:00
parent 2435857cbf
commit f3bc3f20d8
16 changed files with 1414 additions and 10 deletions

View 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&#10;&#10;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>
);
}