Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/dashboard/src-tauri/tests/db_mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

// 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();
Expand Down
110 changes: 108 additions & 2 deletions packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,33 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) {
setSelectedIds(new Set<number>());
});

// 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<number, Memory> => {
const rows = new Map<number, Memory>();
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);
Expand Down Expand Up @@ -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");
Comment thread
ICEY16360 marked this conversation as resolved.
Resolved
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;
Expand Down Expand Up @@ -648,10 +728,36 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) {
</span>
</div>
<div class="bulk-action-right">
<button type="button" class="btn sm" onClick={handleBulkArchive}>
<button
type="button"
class="btn sm"
onClick={handleBulkArchive}
disabled={!listsSettled()}
>
Archive
</button>
<button type="button" class="btn sm danger" onClick={handleBulkDelete}>
<button
type="button"
class="btn sm"
onClick={handleBulkRestore}
disabled={!listsSettled()}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
>
Restore
</button>
<button
type="button"
class="btn sm"
onClick={handleBulkPermanent}
disabled={!listsSettled()}
>
Make permanent
</button>
<button
type="button"
class="btn sm danger"
onClick={handleBulkDelete}
disabled={!listsSettled()}
>
Delete
</button>
<button type="button" class="btn sm ghost" onClick={clearSelection}>
Expand Down
2 changes: 2 additions & 0 deletions packages/dashboard/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -893,6 +894,7 @@ body {
.bulk-action-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/reference/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading