diff --git a/packages/dashboard/src-tauri/src/commands.rs b/packages/dashboard/src-tauri/src/commands.rs index ff88932f6..5b434e4c4 100644 --- a/packages/dashboard/src-tauri/src/commands.rs +++ b/packages/dashboard/src-tauri/src/commands.rs @@ -1505,6 +1505,13 @@ pub fn dismiss_user_memory(state: State<'_, AppState>, id: i64) -> Result<(), St db::dismiss_user_memory(&mut conn, id).map_err(|e| e.to_string()) } +#[tauri::command(async)] +pub fn restore_user_memory(state: State<'_, AppState>, id: i64) -> Result<(), String> { + let path = state.get_db_path()?; + let mut conn = db::open_readwrite(&path).map_err(|e| e.to_string())?; + db::restore_user_memory(&mut conn, id).map_err(|e| e.to_string()) +} + #[tauri::command(async)] pub fn delete_user_memory(state: State<'_, AppState>, id: i64) -> Result<(), String> { let path = state.get_db_path()?; diff --git a/packages/dashboard/src-tauri/src/db.rs b/packages/dashboard/src-tauri/src/db.rs index 394abc21a..fbd22e3b6 100644 --- a/packages/dashboard/src-tauri/src/db.rs +++ b/packages/dashboard/src-tauri/src/db.rs @@ -8405,6 +8405,25 @@ pub fn dismiss_user_memory(conn: &mut Connection, id: i64) -> Result<(), rusqlit Ok(()) } +pub fn restore_user_memory(conn: &mut Connection, id: i64) -> Result<(), rusqlite::Error> { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let affected = tx.execute( + "UPDATE user_memories SET status = 'active', updated_at = ?1 WHERE id = ?2 AND status = 'dismissed'", + params![now_millis(), id], + )?; + // Same invariant as dismiss/delete: user_memories render into the cached + // m[0] block, so a restore must bump the global + // user-profile version or running sessions keep serving the profile + // without the restored entry. The status predicate keeps affected at 0 + // for a row that is already active, so a no-op restore cannot + // invalidate every running session's cached profile. + if affected > 0 { + bump_project_user_profile_version(&tx)?; + } + tx.commit()?; + Ok(()) +} + pub fn delete_user_memory(conn: &mut Connection, id: i64) -> Result<(), rusqlite::Error> { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let affected = tx.execute("DELETE FROM user_memories WHERE id = ?1", params![id])?; diff --git a/packages/dashboard/src-tauri/src/main.rs b/packages/dashboard/src-tauri/src/main.rs index d6d5eb882..0d4d16cbc 100644 --- a/packages/dashboard/src-tauri/src/main.rs +++ b/packages/dashboard/src-tauri/src/main.rs @@ -106,6 +106,7 @@ fn main() { commands::get_user_memories, commands::get_user_memory_candidates, commands::dismiss_user_memory, + commands::restore_user_memory, commands::delete_user_memory, commands::update_user_memory_content, commands::delete_user_memory_candidate, diff --git a/packages/dashboard/src-tauri/src/serve/dispatch.rs b/packages/dashboard/src-tauri/src/serve/dispatch.rs index 44030d9aa..1229d2972 100644 --- a/packages/dashboard/src-tauri/src/serve/dispatch.rs +++ b/packages/dashboard/src-tauri/src/serve/dispatch.rs @@ -731,6 +731,11 @@ pub async fn dispatch(state: &AppState, cmd: &str, args: Value) -> Result { + let a: IdArgs = parse_args(args)?; + let mut conn = open_readwrite(state)?; + json(db::restore_user_memory(&mut conn, a.id).map_err(to_command)?) + } "delete_user_memory" => { let a: IdArgs = parse_args(args)?; let mut conn = open_readwrite(state)?; diff --git a/packages/dashboard/src-tauri/tests/db_mutations.rs b/packages/dashboard/src-tauri/tests/db_mutations.rs index 193b8235e..6636fb8fb 100644 --- a/packages/dashboard/src-tauri/tests/db_mutations.rs +++ b/packages/dashboard/src-tauri/tests/db_mutations.rs @@ -371,6 +371,60 @@ fn dismiss_user_memory_bumps_global_user_profile_not_project_memory_epoch() { assert_eq!(user_profile_version(&conn, "git:project-a"), 0); } +#[test] +fn restore_user_memory_returns_row_to_active_set_and_bumps_global_profile() { + let mut conn = make_db(); + conn.execute( + "INSERT INTO user_memories (content, status, promoted_at, source_candidate_ids, created_at, updated_at) + VALUES ('remember me', 'dismissed', 1, '[]', 1, 1)", + [], + ) + .expect("insert user memory"); + let id = conn.last_insert_rowid(); + seed_project_state(&conn, "git:project-a", 4, 0); + + db::restore_user_memory(&mut conn, id).expect("restore"); + + // The restored row is back in the active set the plugin injects from. + let active = db::get_user_memories(&conn, Some("active")).expect("active set"); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, id); + assert_eq!(memory_epoch(&conn, "__global__"), 0); + assert_eq!(user_profile_version(&conn, "__global__"), 1); + assert_eq!(memory_epoch(&conn, "git:project-a"), 4); + assert_eq!(user_profile_version(&conn, "git:project-a"), 0); +} + +#[test] +fn restore_user_memory_of_active_row_is_a_no_op_without_bump() { + let mut conn = make_db(); + conn.execute( + "INSERT INTO user_memories (content, status, promoted_at, source_candidate_ids, created_at, updated_at) + VALUES ('remember me', 'active', 1, '[]', 1, 1)", + [], + ) + .expect("insert user memory"); + let id = conn.last_insert_rowid(); + seed_project_state(&conn, "git:project-a", 4, 0); + + // A row that is already active must not invalidate the cached profile: + // the status predicate keeps affected at 0, so no bump fires. + db::restore_user_memory(&mut conn, id).expect("restore active row"); + + let active = db::get_user_memories(&conn, Some("active")).expect("active set"); + assert_eq!(active.len(), 1); + assert_eq!(user_profile_version(&conn, "__global__"), 0); + assert_eq!(memory_epoch(&conn, "__global__"), 0); + let updated_at: i64 = conn + .query_row( + "SELECT updated_at FROM user_memories WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .expect("updated_at"); + assert_eq!(updated_at, 1); +} + #[test] fn promote_user_memory_candidate_maps_columns_and_bumps_global_profile() { let mut conn = make_db(); diff --git a/packages/dashboard/src/components/UserMemories/UserMemories.tsx b/packages/dashboard/src/components/UserMemories/UserMemories.tsx index 461652c7a..b3f5f5e38 100644 --- a/packages/dashboard/src/components/UserMemories/UserMemories.tsx +++ b/packages/dashboard/src/components/UserMemories/UserMemories.tsx @@ -7,6 +7,7 @@ import { getUserMemories, getUserMemoryCandidates, promoteUserMemoryCandidate, + restoreUserMemory, updateUserMemoryContent, } from "../../lib/api"; import FilterSelect from "../shared/FilterSelect"; @@ -79,6 +80,16 @@ export default function UserMemories() { } }; + const handleRestore = async (id: number) => { + try { + setError(null); + await restoreUserMemory(id); + refetchMemories(); + } catch (e: unknown) { + setError(`Failed to restore memory: ${e instanceof Error ? e.message : String(e)}`); + } + }; + const handleDeleteMemory = async (id: number) => { try { setError(null); @@ -274,6 +285,15 @@ export default function UserMemories() { Dismiss + + +