diff --git a/marm-console/artifacts/marm-console/src/components/memory/MemoriesTab.tsx b/marm-console/artifacts/marm-console/src/components/memory/MemoriesTab.tsx new file mode 100644 index 00000000..ce868bfd --- /dev/null +++ b/marm-console/artifacts/marm-console/src/components/memory/MemoriesTab.tsx @@ -0,0 +1,425 @@ +import { useState, useEffect } from 'react'; +import { useMemories, useFilters, useCreateMemory, useUpdateMemory, useDeleteMemory, useBulkDeleteMemories } from '@/hooks/use-marm-queries'; +import { Badge, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Table, TableHeader, TableRow, TableHead, TableBody, TableCell, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Textarea, Label } from '@/components/ui/core'; +import { format } from 'date-fns'; +import { Search, Trash2, Plus, Edit2 } from 'lucide-react'; +import type { Memory, MemoryId, MemoryListParams } from '@/lib/marm-types'; +import { type ActionNotice, mutationErrorMessage, deleteNotice, ActionNoticePanel } from './shared'; + +function MemoryRow({ + memory, + onSelect, + selected, + onToggleSelect +}: { + memory: Memory, + onSelect: (m: Memory) => void, + selected: boolean, + onToggleSelect: (id: MemoryId) => void +}) { + return ( + onSelect(memory)}> + e.stopPropagation()}> + onToggleSelect(memory.id)} + className="rounded border-input bg-background" + /> + + {format(new Date(memory.created_at), 'MMM d, HH:mm')} + +
+ {memory.session_name} + {memory.project && {memory.project}} +
+
{memory.content}
+
+ + {memory.compaction_role !== 'none' && ( + + {memory.compaction_role} + + )} + +
+ ); +} + +export function MemoriesTab() { + const [params, setParams] = useState({ limit: 50 }); + const { data, isLoading } = useMemories(params); + const { data: filters } = useFilters(); + const [selectedMemory, setSelectedMemory] = useState(null); + const [actionNotice, setActionNotice] = useState(null); + + const [selectedIds, setSelectedIds] = useState>(new Set()); + const bulkDelete = useBulkDeleteMemories(); + + // Drop selections that are no longer in the visible result set (search, + // filter, or pagination changed) so bulk delete can't act on hidden rows. + useEffect(() => { + if (!data?.items) return; + const visibleIds = new Set(data.items.map(m => m.id)); + setSelectedIds(prev => { + let changed = false; + const next = new Set(); + for (const id of prev) { + if (visibleIds.has(id)) next.add(id); + else changed = true; + } + return changed ? next : prev; + }); + }, [data?.items]); + + const allVisibleSelected = !!data?.items.length && data.items.every(m => selectedIds.has(m.id)); + + const toggleSelect = (id: MemoryId) => { + const newSet = new Set(selectedIds); + if (newSet.has(id)) newSet.delete(id); + else newSet.add(id); + setSelectedIds(newSet); + }; + + const toggleAll = () => { + if (allVisibleSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(data?.items.map(m => m.id))); + } + }; + + const handleBulkDelete = () => { + // Recompute against the current visible items instead of trusting + // selectedIds directly -- the cleanup effect runs after render, so a + // stale ID could still be present in selectedIds at click time if + // data changed on this same render. + const visibleIds = new Set(data?.items.map(m => m.id)); + const targetIds = Array.from(selectedIds).filter(id => visibleIds.has(id)); + if (targetIds.length === 0) return; + const typed = prompt(`Type DELETE to delete ${targetIds.length} selected memories.`); + if (typed === 'DELETE') { + bulkDelete.mutate(targetIds, { + onSuccess: (result) => { + setSelectedIds(new Set()); + setActionNotice(deleteNotice(result, 'Selected memory deleted.')); + }, + onError: (error) => setActionNotice({ kind: 'error', message: mutationErrorMessage(error) }), + }); + } + }; + + const [editMode, setEditMode] = useState(false); + const [editContent, setEditContent] = useState(''); + const [editProject, setEditProject] = useState(''); + const [editPlatform, setEditPlatform] = useState(''); + const [editContextType, setEditContextType] = useState(''); + const [createMode, setCreateMode] = useState(false); + const [newSession, setNewSession] = useState(''); + const [newProject, setNewProject] = useState(''); + const [newPlatform, setNewPlatform] = useState(''); + const [newContextType, setNewContextType] = useState(''); + + const createMemory = useCreateMemory(); + const updateMemory = useUpdateMemory(); + const deleteMemory = useDeleteMemory(); + + const handleCreate = () => { + createMemory.mutate({ + content: editContent, + session_name: newSession.trim(), + project: newProject.trim() || null, + platform: newPlatform.trim() || null, + context_type: newContextType.trim() || 'general', + }, { + onSuccess: () => { + setCreateMode(false); + setEditContent(''); + setNewSession(''); + setNewProject(''); + setNewPlatform(''); + setNewContextType(''); + setActionNotice({ kind: 'success', message: 'Memory created.' }); + }, + onError: (error) => setActionNotice({ kind: 'error', message: mutationErrorMessage(error) }), + }); + }; + + const handleUpdate = () => { + if (!selectedMemory) return; + updateMemory.mutate({ + id: selectedMemory.id, + data: { + content: editContent, + session_name: selectedMemory.session_name, + project: editProject.trim() || null, + platform: editPlatform.trim() || null, + context_type: editContextType.trim() || null, + metadata: selectedMemory.metadata, + } + }, { + onSuccess: () => { + setEditMode(false); + setSelectedMemory({ + ...selectedMemory, + content: editContent, + project: editProject.trim() || null, + platform: editPlatform.trim() || null, + context_type: editContextType.trim() || null, + }); + setActionNotice({ kind: 'success', message: 'Memory updated.' }); + }, + onError: (error) => setActionNotice({ kind: 'error', message: mutationErrorMessage(error) }), + }); + }; + + const handleDelete = () => { + if (!selectedMemory) return; + const warnings: string[] = []; + if (selectedMemory.compaction_role === 'source') { + warnings.push('This memory is a compaction source — its linked summary may reference content that no longer exists.'); + } else if (selectedMemory.compaction_role === 'summary') { + warnings.push('This memory is a compaction summary — deleting it removes the compacted record entirely.'); + } + if (selectedMemory.concept_link_count > 0) { + warnings.push(`It has ${selectedMemory.concept_link_count} concept link(s); provenance cleanup may fail silently on the server if concepts are shared with other memories.`); + } + const msg = ['Delete this memory?', ...warnings].join('\n\n'); + if (confirm(msg)) { + deleteMemory.mutate(selectedMemory.id, { + onSuccess: (result) => { + setSelectedMemory(null); + setActionNotice(deleteNotice(result, 'Memory deleted.')); + }, + onError: (error) => setActionNotice({ kind: 'error', message: mutationErrorMessage(error) }), + }); + } + }; + + return ( +
+
+
+ + setParams(p => ({ ...p, q: e.target.value || undefined }))} + /> +
+ + + {selectedIds.size > 0 ? ( + + ) : ( + + )} +
+ + +
+ + + + + + + Time + Content & Context + Role + + + + {isLoading ? ( + Loading memories... + ) : data?.items.length === 0 ? ( + No memories found + ) : ( + data?.items.map(m => ( + { + setSelectedMemory(m); + setEditMode(false); + }} + selected={selectedIds.has(m.id)} + onToggleSelect={toggleSelect} + /> + )) + )} + +
+
+ + + + + New Memory + Manually inject context into MARM. Blank scope fields are stored as null. + +
+
+
+ + setNewSession(e.target.value)} className="font-mono text-xs" list="session-suggestions" /> + + {filters?.sessions.map(s => +
+
+ + setNewProject(e.target.value)} className="text-xs" list="project-suggestions" /> + + {filters?.projects.map(p => +
+
+ + setNewPlatform(e.target.value)} className="text-xs" list="platform-suggestions" /> + + {filters?.platforms.map(p => +
+
+
+ + setNewContextType(e.target.value)} className="text-xs" list="context-type-suggestions" /> + + {filters?.context_types.map(c => +
+