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.

// 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
71 changes: 71 additions & 0 deletions packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ 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/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<string | null>(null);

const handleStatusChange = async (memoryId: number, newStatus: string) => {
Expand Down Expand Up @@ -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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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.
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;
Expand Down Expand Up @@ -566,6 +631,12 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) {
<button type="button" class="btn sm" onClick={handleBulkArchive}>
Archive
</button>
<button type="button" class="btn sm" onClick={handleBulkRestore}>
Restore
</button>
<button type="button" class="btn sm" onClick={handleBulkPermanent}>
Make permanent
</button>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
<button type="button" class="btn sm danger" onClick={handleBulkDelete}>
Delete
</button>
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 @@ -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;
Expand Down Expand Up @@ -885,6 +886,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