From 224ac2895c1c4d1d41134dc3d7ac549ea575fa6b Mon Sep 17 00:00:00 2001 From: SRnChito <95471816+ICEY16360@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:52:47 +0800 Subject: [PATCH 1/2] feat(dashboard): bulk restore and make permanent for memories (issue 676) The Memories bulk bar could archive or delete a selection but not bring rows back, even though bulk_update_memory_status already accepts active and permanent and only bumps the epoch for rows whose status actually changes. Archived entries could be restored one at a time through the detail Status dropdown, but not in bulk. - MemoryBrowser: Restore and Make permanent buttons next to Archive, with confirm dialogs mirroring the Archive one - db_mutations: a mixed-selection test asserting only the rows that actually transition change status, only their project paths bump the epoch, and a restore queues no memory mutations - docs: the bulk-select line now lists restore and make permanent --- .../dashboard/src-tauri/tests/db_mutations.rs | 77 +++++++++++++++ .../MemoryBrowser/MemoryBrowser.tsx | 96 +++++++++++++++++++ packages/dashboard/src/styles.css | 2 + .../src/content/docs/reference/dashboard.md | 2 +- 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/src-tauri/tests/db_mutations.rs b/packages/dashboard/src-tauri/tests/db_mutations.rs index 6636fb8fb5..c171f37520 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 9b93266793..6f5e39d555 100644 --- a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx +++ b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx @@ -222,6 +222,33 @@ 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 edit reloads the same scope. Once the category + // pages settle, drop selected ids they no longer contain, so bulk actions — + // including Restore's permanent-downgrade warning — never act on rows the + // user can no longer see. A reload empties the pages first, so pruning waits + // for it to settle instead of clearing the whole selection; and because the + // reloaded pages only cover each category's first page, a selection made + // deeper in a category or under a collapsed category is dropped too. That + // costs a few clicks, never an unintended write. + const listsSettled = () => !stats.loading && loadingCategories().size === 0; + const loadedRowsById = (): Map => { + const rows = new Map(); + for (const page of Object.values(pages())) { + for (const row of page.rows) rows.set(row.id, row); + } + return rows; + }; + + createEffect(() => { + if (!listsSettled()) return; + const loaded = loadedRowsById(); + setSelectedIds((prev) => { + const next = new Set([...prev].filter((id) => loaded.has(id))); + return next.size === prev.size ? prev : next; + }); + }); + const handleStatusChange = async (memoryId: number, newStatus: string) => { try { setError(null); @@ -381,6 +408,59 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { } }; + const handleBulkRestore = async () => { + const ids = [...selectedIds()]; + if (ids.length === 0) return; + // While a reload is in flight the pages are empty, so the statuses that + // decide the permanent warning cannot be read; the buttons are disabled + // for that window, and this guard keeps a stray call from acting blind. + if (!listsSettled()) 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 a loaded + // row here: name the permanent rows in the confirm dialog and let the + // user decide, instead of skipping them or downgrading them unnoticed. + const loaded = loadedRowsById(); + const permanentCount = ids.filter((id) => loaded.get(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(); + reloadLists(); + } 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; + if (!listsSettled()) 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(); + reloadLists(); + } 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; @@ -651,6 +731,22 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { + + diff --git a/packages/dashboard/src/styles.css b/packages/dashboard/src/styles.css index 6b437339e3..590590bfed 100644 --- a/packages/dashboard/src/styles.css +++ b/packages/dashboard/src/styles.css @@ -863,6 +863,7 @@ body { /* ── Multi-select bulk action bar ─────────────────────── */ .bulk-action-bar { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; @@ -893,6 +894,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 424916e254..770f0974f7 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. From 32bada924898b2bda3b83c80a31faf91931290da Mon Sep 17 00:00:00 2001 From: SRnChito <95471816+ICEY16360@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:04:54 +0800 Subject: [PATCH 2/2] fix(dashboard): gate archive and delete while lists reload (issue 676 review) --- .../src/components/MemoryBrowser/MemoryBrowser.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx index 6f5e39d555..9a4bdf9c84 100644 --- a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx +++ b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx @@ -728,7 +728,12 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) {
- -