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>
266 lines
8.8 KiB
TypeScript
266 lines
8.8 KiB
TypeScript
/**
|
|
* `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>
|
|
);
|
|
}
|