Skip to content

Commit 49d4982

Browse files
committed
feat(dashboard): bulk memory selection with collapsible categories (closes #28)
Issue #28 requested a selectable table in the Memory page with select/delete/archive and select-all operations. This extends that request with collapsible category groups (navigation for users with 100+ memories across many categories). Multi-select model: - Checkbox per memory row - Tri-state checkbox per category (none / some / all) - Tri-state "select all visible" in the bulk-action bar - Selection resets automatically when filters change so bulk actions can't silently target hidden memories. Bulk actions: - Archive (reversible, flips status to 'archived') - Delete (destructive, confirms count) Both run in one SQLite transaction per action, not a frontend loop, so 100+ memories clear in a single round-trip without partial-failure states. Count-based ask() confirmation. Collapsible categories: - Chevron on the right side of each category header rotates when collapsed - Entire header is clickable; tri-state checkbox is a nested stopPropagation region - Collapse state persisted per-dashboard in localStorage (mc.memory.collapsedCategories.v1) so navigation focus survives reload. Versioned key so future schema changes can't poison old clients. - Expand all / Collapse all links above the list. Rust commands (packages/dashboard/src-tauri/src): - db::bulk_update_memory_status - IN (?,...) under one transaction - db::bulk_delete_memory - explicit memory_embeddings DELETE first, then memories; documents intent even though the FK would cascade - commands::bulk_update_memory_status / commands::bulk_delete_memory - thin Tauri wrappers - main.rs registers both invoke handlers Frontend (packages/dashboard/src): - lib/api.ts: bulkUpdateMemoryStatus / bulkDeleteMemory - components/MemoryBrowser/MemoryBrowser.tsx: full rewrite with selection + collapse state. Local TriStateCheckbox uses a ref + createEffect to set indeterminate (Solid doesn't bind it declaratively). - styles.css: chevron rotation, checkbox column, sticky bulk-action bar (only rendered when >=1 selected), memory card layout with checkbox gutter, tri-state checkbox styling with explicit check and dash marks. Verified: cargo check clean, frontend build clean, bunx tsc clean, plugin test suite still passes (no plugin changes).
1 parent e7c818c commit 49d4982

6 files changed

Lines changed: 744 additions & 57 deletions

File tree

packages/dashboard/src-tauri/src/commands.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,27 @@ pub fn delete_memory(state: State<'_, AppState>, memory_id: i64) -> Result<(), S
7373
db::delete_memory(&conn, memory_id).map_err(|e| e.to_string())
7474
}
7575

76+
#[tauri::command]
77+
pub fn bulk_update_memory_status(
78+
state: State<'_, AppState>,
79+
memory_ids: Vec<i64>,
80+
status: String,
81+
) -> Result<usize, String> {
82+
let path = state.get_db_path()?;
83+
let mut conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
84+
db::bulk_update_memory_status(&mut conn, &memory_ids, &status).map_err(|e| e.to_string())
85+
}
86+
87+
#[tauri::command]
88+
pub fn bulk_delete_memory(
89+
state: State<'_, AppState>,
90+
memory_ids: Vec<i64>,
91+
) -> Result<usize, String> {
92+
let path = state.get_db_path()?;
93+
let mut conn = db::open_readwrite(&path).map_err(|e| e.to_string())?;
94+
db::bulk_delete_memory(&mut conn, &memory_ids).map_err(|e| e.to_string())
95+
}
96+
7697
// ── Session commands ────────────────────────────────────────
7798

7899
#[tauri::command]

packages/dashboard/src-tauri/src/db.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,6 +1281,81 @@ pub fn delete_memory(conn: &Connection, memory_id: i64) -> Result<(), rusqlite::
12811281
Ok(())
12821282
}
12831283

1284+
/// Bulk-update status for a set of memory IDs in one transaction.
1285+
///
1286+
/// We assemble an `IN (?,?,...)` clause dynamically because rusqlite has no
1287+
/// first-class array binding. IDs are integers so SQL-injection is not a
1288+
/// concern; each ID is still bound as a parameter.
1289+
///
1290+
/// Empty input is a no-op and returns 0 (affected rows).
1291+
pub fn bulk_update_memory_status(
1292+
conn: &mut Connection,
1293+
memory_ids: &[i64],
1294+
new_status: &str,
1295+
) -> Result<usize, rusqlite::Error> {
1296+
if memory_ids.is_empty() {
1297+
return Ok(0);
1298+
}
1299+
1300+
let now = chrono::Utc::now().timestamp_millis();
1301+
let placeholders = memory_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1302+
let sql = format!(
1303+
"UPDATE memories SET status = ?1, updated_at = ?2 WHERE id IN ({})",
1304+
placeholders,
1305+
);
1306+
1307+
let tx = conn.transaction()?;
1308+
let affected = {
1309+
let mut stmt = tx.prepare(&sql)?;
1310+
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(2 + memory_ids.len());
1311+
params.push(&new_status as &dyn rusqlite::ToSql);
1312+
params.push(&now as &dyn rusqlite::ToSql);
1313+
for id in memory_ids {
1314+
params.push(id as &dyn rusqlite::ToSql);
1315+
}
1316+
stmt.execute(&params[..])?
1317+
};
1318+
tx.commit()?;
1319+
Ok(affected)
1320+
}
1321+
1322+
/// Bulk-delete memories and their embeddings in one transaction.
1323+
///
1324+
/// Deletes from `memory_embeddings` first, then `memories`. Though
1325+
/// `memory_embeddings.memory_id` has a foreign key, we don't rely on
1326+
/// cascade delete here — explicit delete keeps the intent readable and
1327+
/// matches what single `delete_memory` implicitly gets via the FK.
1328+
pub fn bulk_delete_memory(
1329+
conn: &mut Connection,
1330+
memory_ids: &[i64],
1331+
) -> Result<usize, rusqlite::Error> {
1332+
if memory_ids.is_empty() {
1333+
return Ok(0);
1334+
}
1335+
1336+
let placeholders = memory_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1337+
1338+
let tx = conn.transaction()?;
1339+
// Explicitly clear embeddings first; the FK would handle this too but
1340+
// being explicit documents the intent and matches single-row delete logs.
1341+
{
1342+
let sql = format!("DELETE FROM memory_embeddings WHERE memory_id IN ({})", placeholders);
1343+
let mut stmt = tx.prepare(&sql)?;
1344+
let params: Vec<&dyn rusqlite::ToSql> =
1345+
memory_ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect();
1346+
stmt.execute(&params[..])?;
1347+
}
1348+
let affected = {
1349+
let sql = format!("DELETE FROM memories WHERE id IN ({})", placeholders);
1350+
let mut stmt = tx.prepare(&sql)?;
1351+
let params: Vec<&dyn rusqlite::ToSql> =
1352+
memory_ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect();
1353+
stmt.execute(&params[..])?
1354+
};
1355+
tx.commit()?;
1356+
Ok(affected)
1357+
}
1358+
12841359
// ── Session queries ─────────────────────────────────────────
12851360

12861361
pub fn get_sessions(conn: &Connection) -> Result<Vec<SessionSummary>, rusqlite::Error> {

packages/dashboard/src-tauri/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ fn main() {
3434
commands::update_memory_status,
3535
commands::update_memory_content,
3636
commands::delete_memory,
37+
commands::bulk_update_memory_status,
38+
commands::bulk_delete_memory,
3739
// Sessions
3840
commands::get_sessions,
3941
commands::get_compartments,

0 commit comments

Comments
 (0)