"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; import { ArrowLeft, BrainCircuit, Check, Copy, Eye, FileText, Loader2, Pencil, Plus, RotateCw, Save, Trash2, } from "lucide-react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { MarkdownContent } from "@/app/components/MarkdownContent"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { copyText } from "@/lib/clipboard"; interface MemoryEntry { path: string; size: number; mtime: number; editable: boolean; } interface MemoryListing { dir: string; exists: boolean; entries: MemoryEntry[]; truncated: boolean; } interface MemoryFile { path: string; content: string; size: number; mtime: number; } const LANGUAGE_MAP: Record = { json: "json", yaml: "yaml", yml: "yaml", csv: "text", tsv: "text", log: "text", txt: "text", tex: "latex", bib: "latex", rst: "markdown", }; // Friendly, ordered groups derived from a memory file's relative path. const GROUP_ORDER = [ "Core memory", "Profile", "Projects", "Evolution reports", ] as const; function groupOf(path: string): string { const parts = path.split("/"); if (parts[0] === "profile") { return parts[1] === "projects" ? "Projects" : "Profile"; } if (parts[0] === "evolution-reports") return "Evolution reports"; if (parts.length === 1) return "Core memory"; return parts[0]; } /** A short subtitle shown under the file name (the containing dir, if any). */ function subPathOf(path: string): string { const i = path.lastIndexOf("/"); return i >= 0 ? path.slice(0, i) : ""; } function fileNameOf(path: string): string { return path.split("/").pop() || path; } function extOf(name: string): string { const i = name.lastIndexOf("."); return i >= 0 ? name.slice(i + 1).toLowerCase() : ""; } function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function formatTime(ms: number): string { if (!ms) return ""; try { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).format(new Date(ms)); } catch { return ""; } } const MEMORY_LIST_MIN_WIDTH = 200; const MEMORY_LIST_DEFAULT_WIDTH = 256; // matches the old fixed `md:w-64` const MEMORY_LIST_WIDTH_KEY = "evoscientist-memory-list-width"; /** Clamp the list width to [min, container − 360] so the viewer always has room. * Falls back to a fixed cap when the container isn't measured yet. */ function clampListWidth(w: number, container: HTMLElement | null): number { const max = container ? Math.max(MEMORY_LIST_MIN_WIDTH + 80, container.clientWidth - 360) : 640; return Math.min(Math.max(w, MEMORY_LIST_MIN_WIDTH), max); } /** True on md+ viewports (matches Tailwind's `md:` breakpoint = 768px). Starts * false (SSR-safe) and corrects on mount. */ function useIsDesktop(): boolean { const [isDesktop, setIsDesktop] = useState(false); useEffect(() => { const mq = window.matchMedia("(min-width: 768px)"); const update = () => setIsDesktop(mq.matches); update(); mq.addEventListener("change", update); return () => mq.removeEventListener("change", update); }, []); return isDesktop; } export function MemoryPanel() { const [listing, setListing] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [file, setFile] = useState(null); const [fileLoading, setFileLoading] = useState(false); const [fileError, setFileError] = useState(null); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const [saving, setSaving] = useState(false); const [copied, setCopied] = useState(false); const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(""); const [newBusy, setNewBusy] = useState(false); const [newError, setNewError] = useState(null); const [deleteOpen, setDeleteOpen] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const didAutoSelect = useRef(false); // Monotonic id so a slow file fetch can't overwrite a newer selection. const reqRef = useRef(0); // --- Resizable file-list / viewer split (desktop only) ------------------- const isDesktop = useIsDesktop(); const splitContainerRef = useRef(null); const dragCleanupRef = useRef<(() => void) | null>(null); const roRef = useRef(null); const [listWidth, setListWidth] = useState(() => { if (typeof window === "undefined") return MEMORY_LIST_DEFAULT_WIDTH; const saved = Number(window.localStorage.getItem(MEMORY_LIST_WIDTH_KEY)); return Number.isFinite(saved) && saved >= MEMORY_LIST_MIN_WIDTH ? saved : MEMORY_LIST_DEFAULT_WIDTH; }); useEffect(() => { try { window.localStorage.setItem(MEMORY_LIST_WIDTH_KEY, String(listWidth)); } catch { // localStorage unavailable (e.g. private mode) — width just won't persist. } }, [listWidth]); // Callback ref for the split container: attach a ResizeObserver the moment it // mounts (the list renders only after data loads, so a plain effect could run // before the node exists). The observer fires once on observe — clamping a // persisted width too wide for the current window — and again on every resize. // Desktop-only (the width isn't applied on mobile); disconnects on unmount. const setSplitContainer = useCallback((node: HTMLDivElement | null) => { splitContainerRef.current = node; roRef.current?.disconnect(); roRef.current = null; if (node && typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { if (window.matchMedia("(min-width: 768px)").matches) { setListWidth((w) => clampListWidth(w, node)); } }); ro.observe(node); roRef.current = ro; } }, []); // Tear down an in-progress drag if the component unmounts mid-drag. useEffect(() => () => dragCleanupRef.current?.(), []); const onDividerPointerDown = useCallback( (e: ReactPointerEvent) => { e.preventDefault(); dragCleanupRef.current?.(); // defensive: end any stray prior drag const startX = e.clientX; const startW = listWidth; const controller = new AbortController(); const cleanup = () => { controller.abort(); // removes every listener added with this signal document.body.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); dragCleanupRef.current = null; }; const move = (ev: PointerEvent) => { setListWidth( clampListWidth( startW + (ev.clientX - startX), splitContainerRef.current ) ); }; const opts = { signal: controller.signal }; // Track on window so the drag continues even if the pointer leaves the thin // handle; pointercancel / blur also end it so the global cursor + selection // lock and the listeners never get stuck. window.addEventListener("pointermove", move, opts); window.addEventListener("pointerup", cleanup, opts); window.addEventListener("pointercancel", cleanup, opts); window.addEventListener("blur", cleanup, opts); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; dragCleanupRef.current = cleanup; }, [listWidth] ); const load = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch("/api/memory"); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to load memory."); setListing(data as MemoryListing); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load memory."); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); // Group + sort the file list for the sidebar. const groups = useMemo(() => { const entries = listing?.entries ?? []; const byGroup = new Map(); for (const e of entries) { const g = groupOf(e.path); (byGroup.get(g) ?? byGroup.set(g, []).get(g)!).push(e); } const names = [...byGroup.keys()].sort((a, b) => { const ia = GROUP_ORDER.indexOf(a as (typeof GROUP_ORDER)[number]); const ib = GROUP_ORDER.indexOf(b as (typeof GROUP_ORDER)[number]); if (ia !== -1 || ib !== -1) { return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); } return a.localeCompare(b); }); return names.map((name) => ({ name, files: byGroup.get(name)!.sort((a, b) => a.path.localeCompare(b.path)), })); }, [listing]); const openFile = useCallback(async (path: string) => { const reqId = ++reqRef.current; setSelected(path); setEditing(false); setFile(null); setFileError(null); setFileLoading(true); try { const res = await fetch(`/api/memory?path=${encodeURIComponent(path)}`); const data = await res.json(); // A newer openFile started while this was in flight — drop the stale result. if (reqId !== reqRef.current) return; if (!res.ok) throw new Error(data.error || "Failed to open file."); setFile(data as MemoryFile); } catch (e) { if (reqId !== reqRef.current) return; setFileError(e instanceof Error ? e.message : "Failed to open file."); } finally { if (reqId === reqRef.current) setFileLoading(false); } }, []); // Auto-open the first file once, so the panel isn't empty on first view. useEffect(() => { if (didAutoSelect.current || selected) return; const first = groups[0]?.files[0]; if (first) { didAutoSelect.current = true; openFile(first.path); } }, [groups, selected, openFile]); const startEdit = () => { if (!file) return; setDraft(file.content); setEditing(true); }; const save = async () => { if (!selected) return; const reqId = reqRef.current; // detect a file switch during the await setSaving(true); setFileError(null); try { const res = await fetch("/api/memory", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: selected, content: draft }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to save."); // If the user switched files mid-save, don't clobber the now-current // file's view (the save itself still persisted to disk). if (reqId === reqRef.current) { setFile(data as MemoryFile); setEditing(false); } // Reflect the new size/mtime in the list. load(); } catch (e) { if (reqId === reqRef.current) { setFileError(e instanceof Error ? e.message : "Failed to save."); } } finally { setSaving(false); } }; const confirmDelete = async () => { if (!selected) return; const reqId = reqRef.current; // detect a file switch during the await setDeleteBusy(true); try { const res = await fetch( `/api/memory?path=${encodeURIComponent(selected)}`, { method: "DELETE" } ); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(data?.error || "Failed to delete."); } // Only reset the view if the user is still on the file they deleted. if (reqId === reqRef.current) { setSelected(null); setFile(null); setEditing(false); } setDeleteOpen(false); load(); } catch (e) { if (reqId === reqRef.current) { setFileError(e instanceof Error ? e.message : "Failed to delete."); } } finally { setDeleteBusy(false); } }; const copy = async () => { if (!file) return; if (await copyText(file.content)) { setCopied(true); setTimeout(() => setCopied(false), 1500); } }; const createNew = async () => { let name = newName.trim(); if (!name) { setNewError("Enter a file name."); return; } if (!/\.[A-Za-z0-9]+$/.test(name)) name += ".md"; setNewBusy(true); setNewError(null); try { const res = await fetch("/api/memory", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: name, content: `# ${fileNameOf(name).replace(/\.[^.]+$/, "")}\n\n`, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to create file."); setNewOpen(false); setNewName(""); await load(); await openFile((data as MemoryFile).path); setDraft((data as MemoryFile).content); setEditing(true); } catch (e) { setNewError(e instanceof Error ? e.message : "Failed to create file."); } finally { setNewBusy(false); } }; // Unsaved-edit guard: confirm before navigating away from dirty edits. const dirty = editing && file != null && draft !== file.content; const confirmDiscard = () => !dirty || window.confirm("Discard your unsaved changes?"); const ext = selected ? extOf(fileNameOf(selected)) : ""; const isMarkdown = ext === "md" || ext === "markdown"; return (

EvoScientist's long-term memory — what it knows about you, your research taste, and lessons from past work. View or edit it directly.

{error ? (

{error}

) : loading && !listing ? (
) : listing && !listing.exists ? ( ) : listing && listing.entries.length === 0 ? ( ) : (
{/* File list */} {/* Draggable divider between list and viewer (desktop only). */}
{/* Viewer / editor */}
{!selected ? (

Select a memory file to view or edit.

) : ( <>

{fileNameOf(selected)}

{file && (

{subPathOf(selected) && `${subPathOf(selected)} · `} {formatBytes(file.size)} {file.mtime ? ` · ${formatTime(file.mtime)}` : ""}

)}
{!editing ? ( <> ) : ( <> )}
{fileError && (

{fileError}

)}
{fileLoading ? (
) : editing ? (