diff --git a/packages/dashboard/src-tauri/tests/db_mutations.rs b/packages/dashboard/src-tauri/tests/db_mutations.rs index 6636fb8fb..c171f3752 100644 --- a/packages/dashboard/src-tauri/tests/db_mutations.rs +++ b/packages/dashboard/src-tauri/tests/db_mutations.rs @@ -298,6 +298,83 @@ fn bulk_archive_memory_status_queues_one_mutation_per_memory_without_epoch_bumps ); } +#[test] +fn bulk_restore_mixed_selection_changes_and_bumps_only_actually_changed_rows() { + let mut conn = make_db(); + // a1: no-op (active -> active); a2: archived -> active (changes). + let a1 = insert_memory(&conn, "git:project-a", "active"); + let a2 = insert_memory(&conn, "git:project-a", "archived"); + // b1: permanent -> active (changes). + let b1 = insert_memory(&conn, "git:project-b", "permanent"); + // c1: no-op, and the only selected row on its path (path must not bump). + let c1 = insert_memory(&conn, "git:project-c", "active"); + seed_project_state(&conn, "git:project-a", 4, 0); + seed_project_state(&conn, "git:project-b", 6, 0); + seed_project_state(&conn, "git:project-c", 3, 0); + + let affected = + db::bulk_update_memory_status(&mut conn, &[a1, a2, b1, c1], "active").expect("restore"); + + // affected counts matched rows, not changed ones: the UPDATE has no status + // predicate, so all four selected rows are written even though two of them + // keep their status. + assert_eq!(affected, 4); + for id in [a1, a2, b1, c1] { + let status: String = conn + .query_row( + "SELECT status FROM memories WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .expect("status"); + assert_eq!(status, "active"); + } + // Only the paths whose rows actually transitioned bump the epoch; the + // all-no-op path (git:project-c) must not bust it. + assert_eq!(memory_epoch(&conn, "git:project-a"), 5); + assert_eq!(memory_epoch(&conn, "git:project-b"), 7); + assert_eq!(memory_epoch(&conn, "git:project-c"), 3); + // A restore never queues memory mutations (unlike archive). + assert!(mutation_log_rows(&conn).is_empty()); +} + +#[test] +fn bulk_make_permanent_mixed_selection_changes_and_bumps_only_actually_changed_rows() { + let mut conn = make_db(); + // a1: active -> permanent (changes); a2: permanent -> permanent (no-op). + let a1 = insert_memory(&conn, "git:project-a", "active"); + let a2 = insert_memory(&conn, "git:project-a", "permanent"); + // b1: archived -> permanent (changes). + let b1 = insert_memory(&conn, "git:project-b", "archived"); + // c1: permanent -> permanent (no-op), and the only selected row on its path. + let c1 = insert_memory(&conn, "git:project-c", "permanent"); + seed_project_state(&conn, "git:project-a", 4, 0); + seed_project_state(&conn, "git:project-b", 6, 0); + seed_project_state(&conn, "git:project-c", 3, 0); + let ids = [a1, a2, b1, c1]; + + let affected = + db::bulk_update_memory_status(&mut conn, &ids, "permanent").expect("bulk permanent"); + + assert_eq!(affected, 4); + for id in ids { + let status: String = conn + .query_row( + "SELECT status FROM memories WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .expect("status"); + assert_eq!(status, "permanent"); + } + // Same no-op guard as the restore-to-active path: only the paths whose + // rows actually transitioned bump the epoch. + assert_eq!(memory_epoch(&conn, "git:project-a"), 5); + assert_eq!(memory_epoch(&conn, "git:project-b"), 7); + assert_eq!(memory_epoch(&conn, "git:project-c"), 3); + assert!(mutation_log_rows(&conn).is_empty()); +} + #[test] fn bulk_delete_memories_queues_one_delete_per_memory_without_epoch_bumps() { let mut conn = make_db(); diff --git a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx index e88cc0564..14854117a 100644 --- a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx +++ b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx @@ -147,6 +147,20 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { setSelectedIds(new Set()); }); + // A row can also leave the current list without any filter changing: the + // detail panel's status/category edits refetch under the same filters. Drop + // selected ids the refreshed list no longer contains, so bulk actions — + // including Restore's permanent-downgrade warning — never act on rows the + // user can no longer see. createResource keeps the previous list while a + // refetch is in flight, so a reload never prunes a still-visible selection. + createEffect(() => { + const visibleIds = new Set((memories() ?? []).map((m) => m.id)); + setSelectedIds((prev) => { + const next = new Set([...prev].filter((id) => visibleIds.has(id))); + return next.size === prev.size ? prev : next; + }); + }); + const [error, setError] = createSignal(null); const handleStatusChange = async (memoryId: number, newStatus: string) => { @@ -303,6 +317,57 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { } }; + const handleBulkRestore = async () => { + const ids = [...selectedIds()]; + if (ids.length === 0) return; + // A permanent row is already injected, so restoring it would flatten it + // to active and silently drop its never-expire status. Selection is reset + // whenever a filter changes, so every selected row's status is known + // here: name the permanent rows in the confirm dialog and let the user + // decide, instead of skipping them or downgrading them unnoticed. + const permanentCount = ids.filter( + (id) => memories()?.find((m) => m.id === id)?.status === "permanent", + ).length; + const permanentClause = + permanentCount > 0 + ? ` ${permanentCount} permanent memor${permanentCount === 1 ? "y" : "ies"} in this selection will become active and lose never-expire.` + : ""; + const prompt = + permanentCount === ids.length + ? `Set ${ids.length} memor${ids.length === 1 ? "y" : "ies"} to active?${permanentClause}` + : `Restore ${ids.length} memor${ids.length === 1 ? "y" : "ies"}? Restored memories re-enter injection as active.${permanentClause}`; + const confirmed = await ask(prompt, { title: "Confirm Restore", kind: "warning" }); + if (!confirmed) return; + try { + setError(null); + await bulkUpdateMemoryStatus(ids, "active"); + clearSelection(); + refetchMemories(); + refetchStats(); + } catch (e: unknown) { + setError(`Failed to restore memories: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const handleBulkPermanent = async () => { + const ids = [...selectedIds()]; + if (ids.length === 0) return; + const confirmed = await ask( + `Make ${ids.length} memor${ids.length === 1 ? "y" : "ies"} permanent? Permanent memories always stay in injection and never expire.`, + { title: "Confirm Permanent", kind: "warning" }, + ); + if (!confirmed) return; + try { + setError(null); + await bulkUpdateMemoryStatus(ids, "permanent"); + clearSelection(); + refetchMemories(); + refetchStats(); + } catch (e: unknown) { + setError(`Failed to make memories permanent: ${e instanceof Error ? e.message : String(e)}`); + } + }; + const handleBulkDelete = async () => { const ids = [...selectedIds()]; if (ids.length === 0) return; @@ -566,6 +631,12 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { + + diff --git a/packages/dashboard/src/styles.css b/packages/dashboard/src/styles.css index 39a09a78f..1d5fcbdcd 100644 --- a/packages/dashboard/src/styles.css +++ b/packages/dashboard/src/styles.css @@ -855,6 +855,7 @@ body { /* ── Multi-select bulk action bar ─────────────────────── */ .bulk-action-bar { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; @@ -885,6 +886,7 @@ body { .bulk-action-right { display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; } diff --git a/packages/docs/src/content/docs/reference/dashboard.md b/packages/docs/src/content/docs/reference/dashboard.md index 424916e25..770f0974f 100644 --- a/packages/docs/src/content/docs/reference/dashboard.md +++ b/packages/docs/src/content/docs/reference/dashboard.md @@ -83,7 +83,7 @@ The project's **project memories**: search, filter by category/status, and open - **Edit** content and change status (`active`, `permanent`, `archived`). - **Archive** or **permanently delete** individual memories (delete is confirmed in the UI). -- **Bulk select** rows and **bulk archive** or **bulk delete** with confirmation. +- **Bulk select** rows and **bulk archive**, **restore**, **make permanent**, or **bulk delete** with confirmation. - View metadata: category, source (`historian`, `agent`, `dreamer`, `user`), classify columns (importance / scope / shareable), merge lineage, embeddings flag. Changes write to the shared database; **running sessions pick up memory updates automatically** on subsequent turns without restarting the harness.