refactor(ui/memory): wrap write helpers with react-query useMutation

Previously the Memory view read via `useQuery` but called the raw
create/update/delete fetch helpers directly in handlers, tracking
loading state with a local `submitting` flag and invalidating state
via `refetch()`. That mixes two concerns:

- it skips react-query's mutation state (isPending / isError / isSuccess)
- `refetch()` only retouches the currently-mounted query instance, not
  other cached pages, so navigating back to an older page could show
  stale rows

Switch the three write paths to `useMutation`:

- `createMutation`, `updateMutation`, `deleteMutation` — each owns
  the mutation fn, success toast, and error toast.
- Success handlers invalidate the whole `["memoryList", ...]` prefix
  via `queryClient.invalidateQueries`, so every cached page refetches
  (pagination + filter-aware).
- Refresh button now invalidates instead of `refetch()`, keeping all
  behavior consistent.
- handleSave/handleDelete become thin adapters that call `.mutateAsync`;
  their errors are swallowed locally since the mutation's onError has
  already surfaced the toast.

Also tightened the edit modal's key-field tooltip to reflect the
actual global-unique semantics (was "Unique per user/team scope").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-04-24 14:56:50 -07:00
parent c5fafe56bb
commit 4a4b1123fb
2 changed files with 77 additions and 23 deletions

View file

@ -80,7 +80,7 @@ export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({
label="Key"
name="key"
rules={[{ required: true, message: "Key is required" }]}
tooltip="Unique per user/team scope. Acts as the namespace in /v1/memory/{key}."
tooltip="Globally unique — two memories cannot share a key. Namespace your own keys if you need per-user isolation (e.g. user:123:notes)."
>
<Input
placeholder="e.g. user_role"

View file

@ -1,7 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Card,
@ -73,13 +73,18 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
setCurrentPage(1);
}, [appliedSearch]);
const queryClient = useQueryClient();
// React Query key prefix for all memory-list variants (paged + filtered).
// Mutations invalidate the whole prefix so the next render refetches the
// currently-visible page without us needing a manual refetch().
const MEMORY_LIST_KEY = "memoryList" as const;
const {
data,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["memoryList", appliedSearch, currentPage],
queryKey: [MEMORY_LIST_KEY, appliedSearch, currentPage],
queryFn: () => {
if (!accessToken) throw new Error("Access token required");
// Prefix search matches the Redis-style mental model (namespace scan):
@ -96,7 +101,67 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
const rows = useMemo(() => data?.memories ?? [], [data]);
const total = data?.total ?? 0;
const handleDelete = async (row: MemoryRow) => {
// -- Mutations --------------------------------------------------------
// All three write endpoints share the same success/error plumbing:
// - on success: invalidate the list query so every cached page
// refetches from scratch (pagination + filter-aware).
// - on error: surface the message via antd `message.error`.
const invalidateList = () =>
queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] });
const createMutation = useMutation({
mutationFn: (args: {
key: string;
value: string;
metadata: unknown;
}) => {
if (!accessToken) throw new Error("Access token required");
return createMemory(accessToken, args);
},
onSuccess: (row) => {
message.success(`Created ${row.key}`);
invalidateList();
},
onError: (err: Error) => {
message.error(`Save failed: ${err.message}`);
},
});
const updateMutation = useMutation({
mutationFn: (args: {
key: string;
value?: string;
metadata: unknown;
}) => {
if (!accessToken) throw new Error("Access token required");
const { key, ...payload } = args;
return updateMemory(accessToken, key, payload);
},
onSuccess: (row) => {
message.success(`Updated ${row.key}`);
invalidateList();
},
onError: (err: Error) => {
message.error(`Save failed: ${err.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: (key: string) => {
if (!accessToken) throw new Error("Access token required");
return deleteMemory(accessToken, key).then(() => key);
},
onSuccess: (key) => {
message.success(`Deleted ${key}`);
invalidateList();
},
onError: (err: Error) => {
message.error(`Delete failed: ${err.message}`);
},
});
const handleDelete = (row: MemoryRow) => {
Modal.confirm({
title: "Delete memory",
content: (
@ -107,16 +172,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: async () => {
if (!accessToken) return;
try {
await deleteMemory(accessToken, row.key);
message.success(`Deleted ${row.key}`);
refetch();
} catch (err: any) {
message.error(`Delete failed: ${err?.message ?? err}`);
}
},
onOk: () => deleteMutation.mutateAsync(row.key).catch(() => {}),
});
};
@ -150,23 +206,21 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
try {
if (isCreate) {
await createMemory(accessToken, {
await createMutation.mutateAsync({
key,
value,
metadata: metadataPayload,
});
message.success(`Created ${key}`);
} else {
await updateMemory(accessToken, key, {
await updateMutation.mutateAsync({
key,
value,
metadata: metadataPayload,
});
message.success(`Updated ${key}`);
}
refetch();
return true;
} catch (err: any) {
message.error(`Save failed: ${err?.message ?? err}`);
} catch {
// error already surfaced by mutation's onError handler
return false;
}
};
@ -327,7 +381,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => refetch()}
onClick={() => invalidateList()}
loading={isFetching && !isLoading}
>
Refresh