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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user