From 5c3bf59f88dcf337520310b3ff1988f30ceae65e Mon Sep 17 00:00:00 2001 From: Olayinka Adelakun Date: Mon, 27 Apr 2026 09:45:37 -0400 Subject: [PATCH 1/2] added refresh logic and fixed prior issues from CR --- .../memories-mutation-hooks-cache.test.ts | 4 +- .../__tests__/use-get-memory-sessions.test.ts | 140 ++++++++++++ .../queries/memories/memoriesQueryConfig.ts | 12 ++ .../controllers/API/queries/memories/types.ts | 4 +- .../memories/use-add-messages-to-memory.ts | 10 +- .../API/queries/memories/use-create-memory.ts | 11 +- .../API/queries/memories/use-delete-memory.ts | 34 +-- .../API/queries/memories/use-get-memories.ts | 15 +- .../use-get-memory-session-messages.ts | 13 +- .../memories/use-get-memory-sessions.ts | 110 +++++++--- .../API/queries/memories/use-get-memory.ts | 8 +- .../API/queries/memories/use-update-memory.ts | 12 +- .../src/modals/createMemoryModal/index.tsx | 4 +- .../MemoriesMainContent.constants.ts | 6 - .../__tests__/index.test.tsx | 33 ++- .../components/MemoriesSidebar.tsx | 14 +- .../components/MemoryDetails.tsx | 35 +-- .../components/MemoryDetailsHeader.tsx | 116 ++++++++-- .../components/MemoryKnowledgeBaseSection.tsx | 35 +-- .../__tests__/MemoryDetailsHeader.test.tsx | 197 +++++++++++++++-- .../MemoryKnowledgeBaseSection.test.tsx | 8 +- .../hooks/__tests__/useMemoriesData.test.tsx | 91 ++++++-- .../__tests__/useMemoryDocuments.test.ts | 172 +++++++++++++++ .../useMemorySessionResolver.test.ts | 200 ++++++++++++++++++ .../hooks/useAutoCaptureDebouncedToggle.ts | 20 +- .../hooks/useMemoriesData.ts | 38 ++-- .../hooks/useMemoryDocuments.ts | 24 ++- .../hooks/useMemorySessionResolver.ts | 30 ++- .../components/MemoriesMainContent/index.tsx | 12 +- .../components/MemoriesMainContent/types.ts | 10 +- 30 files changed, 1192 insertions(+), 226 deletions(-) create mode 100644 src/frontend/src/controllers/API/queries/memories/__tests__/use-get-memory-sessions.test.ts create mode 100644 src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts create mode 100644 src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoryDocuments.test.ts create mode 100644 src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemorySessionResolver.test.ts diff --git a/src/frontend/src/controllers/API/queries/memories/__tests__/memories-mutation-hooks-cache.test.ts b/src/frontend/src/controllers/API/queries/memories/__tests__/memories-mutation-hooks-cache.test.ts index 8030109bde..e33de28425 100644 --- a/src/frontend/src/controllers/API/queries/memories/__tests__/memories-mutation-hooks-cache.test.ts +++ b/src/frontend/src/controllers/API/queries/memories/__tests__/memories-mutation-hooks-cache.test.ts @@ -73,11 +73,11 @@ jest.mock("@tanstack/react-query", () => ({ }, })); +import { mapMemoryApiToMemoryInfo } from "../mappers"; +import type { MemoryApiDTO } from "../types"; import { useCreateMemory } from "../use-create-memory"; import { useDeleteMemory } from "../use-delete-memory"; import { useUpdateMemory } from "../use-update-memory"; -import { mapMemoryApiToMemoryInfo } from "../mappers"; -import type { MemoryApiDTO } from "../types"; describe("memories mutation hooks cache wiring", () => { const buildMemoryDto = (overrides?: Partial): MemoryApiDTO => ({ diff --git a/src/frontend/src/controllers/API/queries/memories/__tests__/use-get-memory-sessions.test.ts b/src/frontend/src/controllers/API/queries/memories/__tests__/use-get-memory-sessions.test.ts new file mode 100644 index 0000000000..645ea695ef --- /dev/null +++ b/src/frontend/src/controllers/API/queries/memories/__tests__/use-get-memory-sessions.test.ts @@ -0,0 +1,140 @@ +import { renderHook } from "@testing-library/react"; + +type QueryFn = (ctx: { pageParam: number }) => Promise; + +type InfiniteQueryOptions = { + enabled?: boolean; + queryKey: readonly unknown[]; + queryFn: QueryFn; + initialPageParam?: number; + getNextPageParam?: (lastPage: any) => number | undefined; +}; + +const useInfiniteQueryMock = jest.fn(); + +jest.mock("@tanstack/react-query", () => ({ + useInfiniteQuery: (options: InfiniteQueryOptions) => + useInfiniteQueryMock(options), +})); + +const apiGetMock = jest.fn(); + +jest.mock("@/controllers/API/api", () => ({ + api: { + get: (...args: unknown[]) => apiGetMock(...args), + }, +})); + +jest.mock("@/controllers/API/helpers/constants", () => ({ + getURL: () => "/api/v1/memories", +})); + +import { useGetMemorySessions } from "../use-get-memory-sessions"; + +describe("useGetMemorySessions", () => { + beforeEach(() => { + jest.clearAllMocks(); + useInfiniteQueryMock.mockReturnValue({}); + }); + + it("disables the query when memoryId is empty", () => { + renderHook(() => useGetMemorySessions({ memoryId: "" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + expect(opts.enabled).toBe(false); + expect(apiGetMock).not.toHaveBeenCalled(); + }); + + it("enables the query when memoryId is provided", () => { + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + expect(opts.enabled).toBe(true); + }); + + it("sets initialPageParam to 1", () => { + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + expect(opts.initialPageParam).toBe(1); + }); + + it("includes memoryId and default size in the query key", () => { + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + expect(opts.queryKey).toContain("m1"); + expect(opts.queryKey).toContain(50); + }); + + it("uses custom size in the query key when provided", () => { + renderHook(() => useGetMemorySessions({ memoryId: "m1", size: 10 })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + expect(opts.queryKey).toContain(10); + }); + + it("getNextPageParam returns next page when pages remain", () => { + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + const result = opts.getNextPageParam?.({ page: 1, pages: 3 }); + expect(result).toBe(2); + }); + + it("getNextPageParam returns undefined when on the last page", () => { + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + const result = opts.getNextPageParam?.({ page: 3, pages: 3 }); + expect(result).toBeUndefined(); + }); + + it("throws when memoryId is missing but query is forced enabled", async () => { + renderHook(() => useGetMemorySessions({ memoryId: "" }, { enabled: true })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + await expect(opts.queryFn({ pageParam: 1 })).rejects.toThrow( + "memoryId is required", + ); + }); + + it("builds the correct URL with page and size query params", async () => { + apiGetMock.mockResolvedValue({ + data: { items: [], total: 0, page: 1, size: 50, pages: 1 }, + }); + + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + await opts.queryFn({ pageParam: 2 }); + + const calledUrl: string = apiGetMock.mock.calls[0]?.[0]; + expect(calledUrl).toContain("/m1/sessions"); + expect(calledUrl).toContain("page=2"); + expect(calledUrl).toContain("size=50"); + }); + + it("normalises the API response with safe defaults", async () => { + apiGetMock.mockResolvedValue({ data: {} }); + + renderHook(() => useGetMemorySessions({ memoryId: "m1" })); + + const opts = useInfiniteQueryMock.mock + .calls[0]?.[0] as InfiniteQueryOptions; + const result: any = await opts.queryFn({ pageParam: 1 }); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + expect(result.pages).toBe(1); + }); +}); diff --git a/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts b/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts new file mode 100644 index 0000000000..5dfc84600b --- /dev/null +++ b/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts @@ -0,0 +1,12 @@ +/** Maximum retry attempts for memory read queries. */ +export const MEMORIES_RETRY_MAX_ATTEMPTS = 2; + +const MEMORIES_RETRY_DELAY_MS = 1000; +const MEMORIES_RETRY_MAX_DELAY_MS = 10_000; + +/** Exponential backoff capped at 10 s, shared by all memory query and mutation hooks. */ +export const memoriesRetryDelay = (attemptIndex: number): number => + Math.min(MEMORIES_RETRY_DELAY_MS * 2 ** attemptIndex, MEMORIES_RETRY_MAX_DELAY_MS); + +/** Default page size for all memory infinite queries. */ +export const MEMORIES_PAGE_SIZE = 50; diff --git a/src/frontend/src/controllers/API/queries/memories/types.ts b/src/frontend/src/controllers/API/queries/memories/types.ts index cebf15b0c1..ae32dec530 100644 --- a/src/frontend/src/controllers/API/queries/memories/types.ts +++ b/src/frontend/src/controllers/API/queries/memories/types.ts @@ -27,7 +27,7 @@ export interface MemoryInfo { description?: string; kb_name: string; embedding_model: string; - embedding_provider: string; + embedding_provider?: string; is_active: boolean; total_messages_processed: number; sessions_count: number; @@ -47,7 +47,7 @@ export interface MemoryDocumentItem { sender: string; session_id: string; timestamp: string; - ingestion_job_id?: string; + job_id?: string; ingestion_timestamp?: string; message_id: string; } diff --git a/src/frontend/src/controllers/API/queries/memories/use-add-messages-to-memory.ts b/src/frontend/src/controllers/API/queries/memories/use-add-messages-to-memory.ts index eb3c25e02e..c326686eb3 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-add-messages-to-memory.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-add-messages-to-memory.ts @@ -3,12 +3,13 @@ import type { useMutationFunctionType } from "@/types/api"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; import { UseRequestProcessor } from "../../services/request-processor"; +import { mapMemoryApiToMemoryInfo } from "./mappers"; +import { memoriesRetryDelay } from "./memoriesQueryConfig"; import type { AddMessagesToMemoryParams, MemoryApiDTO, MemoryInfo, } from "./types"; -import { mapMemoryApiToMemoryInfo } from "./mappers"; import { ensureRequiredParam } from "./validation"; export const useAddMessagesToMemory: useMutationFunctionType< @@ -45,7 +46,12 @@ export const useAddMessagesToMemory: useMutationFunctionType< MemoryInfo, Error, AddMessagesToMemoryParams - > = mutate(["useAddMessagesToMemory"], addMessagesFn, options); + > = mutate(["useAddMessagesToMemory"], addMessagesFn, { + // POST is not safe to retry by default (risk of duplicates). + retry: false, + retryDelay: memoriesRetryDelay, + ...options, + }); return mutation; }; diff --git a/src/frontend/src/controllers/API/queries/memories/use-create-memory.ts b/src/frontend/src/controllers/API/queries/memories/use-create-memory.ts index c4cf0b5b14..e93aefa67e 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-create-memory.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-create-memory.ts @@ -1,15 +1,16 @@ import { - useMutation, - useQueryClient, type UseMutationOptions, type UseMutationResult, + useMutation, + useQueryClient, } from "@tanstack/react-query"; import type { useMutationFunctionType } from "@/types/api"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; -import type { CreateMemoryPayload, MemoryApiDTO, MemoryInfo } from "./types"; import { mapMemoryApiToMemoryInfo } from "./mappers"; import { addMemoryToMemoriesCache } from "./memories-cache-helpers"; +import { memoriesRetryDelay } from "./memoriesQueryConfig"; +import type { CreateMemoryPayload, MemoryApiDTO, MemoryInfo } from "./types"; export const useCreateMemory: useMutationFunctionType< undefined, @@ -57,8 +58,8 @@ export const useCreateMemory: useMutationFunctionType< userOnSettled?.(data, error, variables, onMutateResult, context); }, // POST is not safe to retry by default (risk of duplicates). - retry: restOptions.retry ?? false, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + retry: false, + retryDelay: memoriesRetryDelay, }); return mutation; diff --git a/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts b/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts index 92b76358f4..cc7d1318ef 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts @@ -1,13 +1,14 @@ import { + type UseMutationOptions, useMutation, useQueryClient, - type UseMutationOptions, } from "@tanstack/react-query"; import type { useMutationFunctionType } from "@/types/api"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; -import type { DeleteMemoryParams } from "./types"; import { removeMemoryFromMemoriesCache } from "./memories-cache-helpers"; +import { memoriesRetryDelay } from "./memoriesQueryConfig"; +import type { DeleteMemoryParams } from "./types"; export const useDeleteMemory: useMutationFunctionType< undefined, @@ -22,31 +23,36 @@ export const useDeleteMemory: useMutationFunctionType< "mutationFn" | "mutationKey" > | undefined; - const { onSettled: userOnSettled, ...restOptions } = typedOptions ?? {}; + const { + onSuccess: userOnSuccess, + onSettled: userOnSettled, + ...restOptions + } = typedOptions ?? {}; const deleteMemoryFn = async (params: DeleteMemoryParams): Promise => { await api.delete(`${getURL("MEMORIES")}/${params.memoryId}`); - - // Avoid refetching a resource that no longer exists. - await queryClient.cancelQueries({ - queryKey: ["useGetMemory", params.memoryId], - }); - queryClient.removeQueries({ queryKey: ["useGetMemory", params.memoryId] }); - - // Keep cached lists consistent without forcing a refetch. - removeMemoryFromMemoriesCache(queryClient, params.memoryId); }; const mutation = useMutation({ mutationKey: ["useDeleteMemory"], mutationFn: deleteMemoryFn, ...restOptions, + onSuccess: (data, variables, onMutateResult, context) => { + // Cancel in-flight fetches for the deleted resource so a 404 doesn't land after removal. + queryClient.cancelQueries({ queryKey: ["useGetMemory", variables.memoryId] }); + queryClient.removeQueries({ queryKey: ["useGetMemory", variables.memoryId] }); + + // Keep cached lists consistent without forcing a refetch. + removeMemoryFromMemoriesCache(queryClient, variables.memoryId); + + userOnSuccess?.(data, variables, onMutateResult, context); + }, onSettled: (data, error, variables, onMutateResult, context) => { userOnSettled?.(data, error, variables, onMutateResult, context); }, // DELETE is not safe to retry by default (may produce confusing 404s). - retry: restOptions.retry ?? false, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + retry: false, + retryDelay: memoriesRetryDelay, }); return mutation; diff --git a/src/frontend/src/controllers/API/queries/memories/use-get-memories.ts b/src/frontend/src/controllers/API/queries/memories/use-get-memories.ts index f61cebdf1c..4e340c62c3 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-get-memories.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-get-memories.ts @@ -1,19 +1,18 @@ import { - useInfiniteQuery, type InfiniteData, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, + useInfiniteQuery, } from "@tanstack/react-query"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; -import type { GetMemoriesApiResponse, GetMemoriesParams } from "./types"; import { mapGetMemoriesApiResponse } from "./mappers"; import { MEMORIES_PAGE_SIZE, - MEMORIES_RETRY_BASE_DELAY_MS, MEMORIES_RETRY_MAX_ATTEMPTS, - MEMORIES_RETRY_MAX_DELAY_MS, -} from "@/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants"; + memoriesRetryDelay, +} from "./memoriesQueryConfig"; +import type { GetMemoriesApiResponse, GetMemoriesParams } from "./types"; type MemoriesPage = ReturnType; @@ -70,11 +69,7 @@ export const useGetMemories = ( lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined, refetchOnWindowFocus: false, retry: MEMORIES_RETRY_MAX_ATTEMPTS, - retryDelay: (attemptIndex) => - Math.min( - MEMORIES_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, - MEMORIES_RETRY_MAX_DELAY_MS, - ), + retryDelay: memoriesRetryDelay, ...(options ?? {}), }); }; diff --git a/src/frontend/src/controllers/API/queries/memories/use-get-memory-session-messages.ts b/src/frontend/src/controllers/API/queries/memories/use-get-memory-session-messages.ts index ede020a155..946ea70aef 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-get-memory-session-messages.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-get-memory-session-messages.ts @@ -1,18 +1,22 @@ import { - useInfiniteQuery, type InfiniteData, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, + useInfiniteQuery, } from "@tanstack/react-query"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; -import { MEMORIES_PAGE_SIZE } from "@/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants"; +import { + MEMORIES_PAGE_SIZE, + MEMORIES_RETRY_MAX_ATTEMPTS, + memoriesRetryDelay, +} from "./memoriesQueryConfig"; export type MemorySessionMessageApiItem = { timestamp: string; sender: string; sender_name?: string; - ingestion_job_id?: string; + job_id?: string; ingestion_timestamp?: string; session_id: string; text: string; @@ -104,7 +108,8 @@ export const useGetMemorySessionMessages = ( getNextPageParam: (lastPage) => lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined, refetchOnWindowFocus: false, - retry: false, + retry: MEMORIES_RETRY_MAX_ATTEMPTS, + retryDelay: memoriesRetryDelay, enabled: !!memoryId && !!sessionId, ...(options ?? {}), }); diff --git a/src/frontend/src/controllers/API/queries/memories/use-get-memory-sessions.ts b/src/frontend/src/controllers/API/queries/memories/use-get-memory-sessions.ts index 6dc9518e66..0c1905d832 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-get-memory-sessions.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-get-memory-sessions.ts @@ -1,40 +1,98 @@ -import type { UseQueryResult } from "@tanstack/react-query"; -import type { useQueryFunctionType } from "@/types/api"; +import { + type InfiniteData, + type UseInfiniteQueryOptions, + type UseInfiniteQueryResult, + useInfiniteQuery, +} from "@tanstack/react-query"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; -import { UseRequestProcessor } from "../../services/request-processor"; import type { MemorySessionInfo } from "./types"; -import { ensureRequiredParam } from "./validation"; +import { + MEMORIES_PAGE_SIZE, + MEMORIES_RETRY_MAX_ATTEMPTS, + memoriesRetryDelay, +} from "./memoriesQueryConfig"; export interface GetMemorySessionsParams { memoryId: string; + size?: number; } -export const useGetMemorySessions: useQueryFunctionType< - GetMemorySessionsParams, - MemorySessionInfo[] -> = (params, options?) => { - const { query } = UseRequestProcessor(); +export type GetMemorySessionsApiResponse = { + items: MemorySessionInfo[]; + total: number; + page: number; + size: number; + pages: number; +}; - const getSessionsFn = async (): Promise => { - ensureRequiredParam(params?.memoryId, "memoryId"); +type SessionsPage = GetMemorySessionsApiResponse; - const { data } = await api.get<{ items: MemorySessionInfo[] }>( - `${getURL("MEMORIES")}/${params.memoryId}/sessions`, - ); +const SESSIONS_INFINITE_QUERY_KEY = "useGetMemorySessionsInfinite"; - return Array.isArray(data.items) ? data.items : []; +type SessionsQueryKey = readonly [ + typeof SESSIONS_INFINITE_QUERY_KEY, + string, + number, +]; + +export const useGetMemorySessions = ( + params: GetMemorySessionsParams, + options?: Omit< + UseInfiniteQueryOptions< + SessionsPage, + unknown, + InfiniteData, + SessionsQueryKey, + number + >, + "queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam" + >, +): UseInfiniteQueryResult, unknown> => { + const memoryId = params?.memoryId; + const size = params?.size ?? MEMORIES_PAGE_SIZE; + + const getSessionsPage = async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!memoryId) { + throw new Error("memoryId is required"); + } + + const baseUrl = `${getURL("MEMORIES")}/${memoryId}/sessions`; + const url = new URL(baseUrl, window.location.origin); + url.searchParams.set("page", String(pageParam)); + url.searchParams.set("size", String(size)); + + const res = await api.get(url.toString()); + + return { + items: Array.isArray(res.data?.items) ? res.data.items : [], + total: Number(res.data?.total ?? 0), + page: Number(res.data?.page ?? pageParam), + size: Number(res.data?.size ?? size), + pages: Number(res.data?.pages ?? 1), + }; }; - const queryResult: UseQueryResult = query( - ["useGetMemorySessions", params?.memoryId], - getSessionsFn, - { - enabled: !!params?.memoryId, - refetchOnWindowFocus: false, - ...options, - }, - ); - - return queryResult; + return useInfiniteQuery< + SessionsPage, + unknown, + InfiniteData, + SessionsQueryKey, + number + >({ + queryKey: [SESSIONS_INFINITE_QUERY_KEY, memoryId, size] as const, + queryFn: getSessionsPage, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.pages ? lastPage.page + 1 : undefined, + refetchOnWindowFocus: false, + retry: MEMORIES_RETRY_MAX_ATTEMPTS, + retryDelay: memoriesRetryDelay, + enabled: !!memoryId, + ...(options ?? {}), + }); }; diff --git a/src/frontend/src/controllers/API/queries/memories/use-get-memory.ts b/src/frontend/src/controllers/API/queries/memories/use-get-memory.ts index 2ed183bed6..c9f452bbf3 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-get-memory.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-get-memory.ts @@ -3,9 +3,13 @@ import type { useQueryFunctionType } from "@/types/api"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; import { UseRequestProcessor } from "../../services/request-processor"; -import type { GetMemoryParams, MemoryApiDTO, MemoryInfo } from "./types"; import { mapMemoryApiToMemoryInfo } from "./mappers"; +import { + MEMORIES_RETRY_MAX_ATTEMPTS, + memoriesRetryDelay, +} from "./memoriesQueryConfig"; import { ensureRequiredParam } from "./validation"; +import type { GetMemoryParams, MemoryApiDTO, MemoryInfo } from "./types"; export const useGetMemory: useQueryFunctionType = ( params, @@ -28,6 +32,8 @@ export const useGetMemory: useQueryFunctionType = ( { enabled: !!params?.memoryId, refetchOnWindowFocus: false, + retry: MEMORIES_RETRY_MAX_ATTEMPTS, + retryDelay: memoriesRetryDelay, ...options, }, ); diff --git a/src/frontend/src/controllers/API/queries/memories/use-update-memory.ts b/src/frontend/src/controllers/API/queries/memories/use-update-memory.ts index 21bc17a125..7331935118 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-update-memory.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-update-memory.ts @@ -1,14 +1,18 @@ import { + type UseMutationOptions, useMutation, useQueryClient, - type UseMutationOptions, } from "@tanstack/react-query"; import type { useMutationFunctionType } from "@/types/api"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; -import type { MemoryApiDTO, MemoryInfo, UpdateMemoryParams } from "./types"; import { mapMemoryApiToMemoryInfo } from "./mappers"; +import { + MEMORIES_RETRY_MAX_ATTEMPTS, + memoriesRetryDelay, +} from "./memoriesQueryConfig"; import { updateMemoryInMemoriesCache } from "./memories-cache-helpers"; +import type { MemoryApiDTO, MemoryInfo, UpdateMemoryParams } from "./types"; export const useUpdateMemory: useMutationFunctionType< undefined, @@ -52,12 +56,12 @@ export const useUpdateMemory: useMutationFunctionType< >({ mutationKey: ["useUpdateMemory"], mutationFn: updateMemoryFn, + retry: MEMORIES_RETRY_MAX_ATTEMPTS, + retryDelay: memoriesRetryDelay, ...restOptions, onSettled: (data, error, variables, onMutateResult, context) => { userOnSettled?.(data, error, variables, onMutateResult, context); }, - retry: restOptions.retry ?? 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }); return mutation; diff --git a/src/frontend/src/modals/createMemoryModal/index.tsx b/src/frontend/src/modals/createMemoryModal/index.tsx index 4dad87ccd1..32d926a210 100644 --- a/src/frontend/src/modals/createMemoryModal/index.tsx +++ b/src/frontend/src/modals/createMemoryModal/index.tsx @@ -102,7 +102,7 @@ export default function CreateMemoryModal({
- Messages to accumulate before ingestion triggers (min 1) + Messages per ingestion batch (min 1) , + icon: , loading: createMemoryMutation.isPending, disabled: !name.trim() || diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants.ts index eafc0696e0..fdd994f94b 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/MemoriesMainContent.constants.ts @@ -1,9 +1,3 @@ export const SIDEBAR_SCROLL_THRESHOLD_PX = 160; export const KNOWLEDGE_BASE_SCROLL_THRESHOLD_PX = 240; export const AUTO_CAPTURE_DEBOUNCE_MS = 300; -export const MEMORIES_PAGE_SIZE = 50; - -/** Retry policy for memory infinite queries */ -export const MEMORIES_RETRY_BASE_DELAY_MS = 1000; -export const MEMORIES_RETRY_MAX_DELAY_MS = 30000; -export const MEMORIES_RETRY_MAX_ATTEMPTS = 2; diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/__tests__/index.test.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/__tests__/index.test.tsx index 2d9903166b..8e754281c8 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/__tests__/index.test.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/__tests__/index.test.tsx @@ -44,8 +44,12 @@ jest.mock("../components/MemoriesSidebar", () => ({ ), })); +const capturedMemoryDetailsProps: Record = {}; jest.mock("../components/MemoryDetails", () => ({ - MemoryDetails: () =>
MemoryDetails
, + MemoryDetails: (props: any) => { + Object.assign(capturedMemoryDetailsProps, props); + return
MemoryDetails
; + }, })); jest.mock("../components/MemoryDocumentPanel", () => ({ @@ -64,6 +68,9 @@ jest.mock("@/modals/createMemoryModal", () => ({ describe("MemoriesMainContent", () => { beforeEach(() => { jest.clearAllMocks(); + Object.keys(capturedMemoryDetailsProps).forEach( + (k) => delete capturedMemoryDetailsProps[k], + ); mockedHookValue = { memories: [], filteredMemories: [], @@ -84,6 +91,10 @@ describe("MemoriesMainContent", () => { deleteMutation: { mutate: jest.fn(), isPending: false }, updateMemoryMutation: { isPending: false }, handleToggleActive: jest.fn(), + onRefresh: jest.fn(), + fetchNextSessionsPage: jest.fn(), + hasNextSessionsPage: false, + isFetchingNextSessionsPage: false, createModalOpen: false, setCreateModalOpen: mockSetCreateModalOpen, fetchNextMessagesPage: jest.fn(), @@ -134,4 +145,24 @@ describe("MemoriesMainContent", () => { fireEvent.click(screen.getByText("create-success")); expect(screen.getByText("selected:m-created")).toBeInTheDocument(); }); + + it("passes onRefresh, fetchNextSessionsPage and session pagination flags to MemoryDetails", () => { + const onRefresh = jest.fn(); + const fetchNextSessionsPage = jest.fn(); + mockedHookValue.memory = { id: "m1" }; + mockedHookValue.onRefresh = onRefresh; + mockedHookValue.fetchNextSessionsPage = fetchNextSessionsPage; + mockedHookValue.hasNextSessionsPage = true; + mockedHookValue.isFetchingNextSessionsPage = true; + + render(); + fireEvent.click(screen.getByText("select-memory")); + + expect(capturedMemoryDetailsProps.onRefresh).toBe(onRefresh); + expect(capturedMemoryDetailsProps.fetchNextSessionsPage).toBe( + fetchNextSessionsPage, + ); + expect(capturedMemoryDetailsProps.hasNextSessionsPage).toBe(true); + expect(capturedMemoryDetailsProps.isFetchingNextSessionsPage).toBe(true); + }); }); diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoriesSidebar.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoriesSidebar.tsx index aa6ffc650c..d030216dc3 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoriesSidebar.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoriesSidebar.tsx @@ -45,7 +45,7 @@ export function MemoriesSidebar({ onClick={onCreateMemory} disabled={!currentFlowId} > - + Create
@@ -75,18 +75,6 @@ export function MemoriesSidebar({

{memoriesSearch.trim() ? "No memories found" : "No memories yet"}

- {!memoriesSearch.trim() && ( - - )} ) : (
diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetails.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetails.tsx index a61958cbdb..79158c9f58 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetails.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetails.tsx @@ -1,8 +1,8 @@ -import { SummaryCard } from "./SummaryCard"; import { formatDate } from "../helpers"; import { MemoryDetailsProps } from "../types"; import { MemoryDetailsHeader } from "./MemoryDetailsHeader"; import { MemoryKnowledgeBaseSection } from "./MemoryKnowledgeBaseSection"; +import { SummaryCard } from "./SummaryCard"; export function MemoryDetails({ memory, @@ -17,7 +17,18 @@ export function MemoryDetails({ handleOpenDocumentPanel, deleteMutation, handleToggleActive, + onRefresh, + fetchNextSessionsPage, + hasNextSessionsPage, + isFetchingNextSessionsPage, }: MemoryDetailsProps) { + const pendingLabel = + memory.batch_size > 1 ? "Pending (this batch)" : "Pending Messages"; + const pendingValue = + memory.batch_size > 1 + ? `${memory.pending_messages_count}/${memory.batch_size}` + : memory.pending_messages_count; + return ( <>
- 1 - ? `${memory.pending_messages_count}/${memory.batch_size}` - : memory.pending_messages_count - } - icon="Timer" - /> + · - Batch Size:{" "} + + Messages per batch: + {" "} {memory.batch_size} @@ -94,8 +103,6 @@ export function MemoryDetails({ fetchNextMessagesPage={fetchNextMessagesPage} hasNextMessagesPage={hasNextMessagesPage} isFetchingNextMessagesPage={isFetchingNextMessagesPage} - selectedSession={selectedSession} - setSelectedSession={setSelectedSession} groupedBySession={groupedBySession} handleOpenDocumentPanel={handleOpenDocumentPanel} /> diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetailsHeader.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetailsHeader.tsx index f47ea328d1..b291f6280e 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetailsHeader.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetailsHeader.tsx @@ -1,5 +1,11 @@ import IconComponent from "@/components/common/genericIconComponent"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import DeleteConfirmationModal from "@/modals/deleteConfirmationModal"; import type { MemoryDetailsHeaderProps } from "../types"; @@ -10,7 +16,26 @@ export function MemoryDetailsHeader({ setSelectedSession, deleteMutation, handleToggleActive, + onRefresh, + fetchNextSessionsPage, + hasNextSessionsPage, + isFetchingNextSessionsPage, }: MemoryDetailsHeaderProps) { + const effectiveSession = (selectedSession ?? sessions?.[0] ?? "") as + | string + | null; + + const handleSessionsScroll = (e: React.UIEvent) => { + const el = e.currentTarget; + if ( + el.scrollHeight - el.scrollTop <= el.clientHeight * 2 && + hasNextSessionsPage && + !isFetchingNextSessionsPage + ) { + fetchNextSessionsPage(); + } + }; + return (
@@ -26,30 +51,89 @@ export function MemoryDetailsHeader({
+ + {sessions && sessions.length > 0 && ( - + + + + + +
+ {sessions.map((sid) => { + const isSelected = sid === effectiveSession; + return ( + { + e.preventDefault(); + setSelectedSession(sid); + }} + > + {sid} + + + ); + })} + {isFetchingNextSessionsPage && ( +
+ + Loading… + +
+ )} +
+
+
)} - + Delete diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryKnowledgeBaseSection.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryKnowledgeBaseSection.tsx index 9ca7c8344f..b2f3607a5f 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryKnowledgeBaseSection.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryKnowledgeBaseSection.tsx @@ -20,7 +20,6 @@ export function MemoryKnowledgeBaseSection({ fetchNextMessagesPage, hasNextMessagesPage, isFetchingNextMessagesPage, - setSelectedSession, groupedBySession, handleOpenDocumentPanel, }: MemoryKnowledgeBaseSectionProps) { @@ -61,9 +60,8 @@ export function MemoryKnowledgeBaseSection({ - Session Sender - Ingestion Job + Job ID Content Ingestion Timestamp @@ -72,43 +70,22 @@ export function MemoryKnowledgeBaseSection({ {Array.from(groupedBySession.entries()).map(([sessionId, docs]) => - docs.map((doc, index) => ( + docs.map((doc, idx) => ( handleOpenDocumentPanel(doc)} > - - {index === 0 ? ( - { - e.preventDefault(); - e.stopPropagation(); - setSelectedSession(sessionId); - }} - > - {sessionId === "(no session)" - ? sessionId - : sessionId.length > 12 - ? `${sessionId.slice(0, 12)}...` - : sessionId} - - ) : ( - "" - )} - {doc.sender || "-"} - {doc.ingestion_job_id || "-"} + {doc.job_id || "-"}
@@ -126,7 +103,7 @@ export function MemoryKnowledgeBaseSection({ {isFetchingNextMessagesPage && ( - +
Loading more... diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryDetailsHeader.test.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryDetailsHeader.test.tsx index d1d5728ec6..83f02d88e3 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryDetailsHeader.test.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryDetailsHeader.test.tsx @@ -1,13 +1,46 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { MemoryDetailsHeader } from "../MemoryDetailsHeader"; -import type { MemoryDetailsHeaderProps } from "../../types"; import type { MemoryInfo } from "@/controllers/API/queries/memories/types"; +import type { MemoryDetailsHeaderProps } from "../../types"; +import { MemoryDetailsHeader } from "../MemoryDetailsHeader"; jest.mock("@/components/common/genericIconComponent", () => ({ __esModule: true, default: ({ name }: { name: string }) => {name}, })); +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + DropdownMenuContent: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + className, + }: { + children: React.ReactNode; + onSelect?: (e: Event) => void; + className?: string; + }) => ( +
onSelect?.(e as unknown as Event)} + > + {children} +
+ ), +})); + jest.mock("@/modals/deleteConfirmationModal", () => ({ __esModule: true, default: ({ @@ -52,6 +85,10 @@ describe("MemoryDetailsHeader", () => { setSelectedSession: jest.fn(), deleteMutation: { mutate: jest.fn(), isPending: false }, handleToggleActive: jest.fn(), + onRefresh: jest.fn(), + fetchNextSessionsPage: jest.fn(), + hasNextSessionsPage: false, + isFetchingNextSessionsPage: false, }; return { ...base, ...(overrides ?? {}) }; }; @@ -64,7 +101,7 @@ describe("MemoryDetailsHeader", () => { expect(screen.getByText("Memory One")).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Toggle auto-capture" }), - ).toHaveTextContent("Auto-capture: Enabled"); + ).toHaveTextContent("Auto-capture"); }); it("calls mutate handlers for actions", () => { @@ -94,19 +131,151 @@ describe("MemoryDetailsHeader", () => { const props = makeProps({ sessions: ["session-1", "session-2"] }); render(); - expect(screen.getByLabelText("Session filter")).toBeInTheDocument(); - expect( - screen.getByRole("option", { name: "session-1" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("option", { name: "session-2" }), - ).toBeInTheDocument(); - expect(screen.getByLabelText("Session filter")).not.toBeDisabled(); + const sessionTrigger = screen.getByRole("button", { + name: "Session filter", + }); + expect(sessionTrigger).toBeInTheDocument(); + expect(sessionTrigger).not.toBeDisabled(); }); - it("disables the session selector when only one session exists", () => { - const props = makeProps({ sessions: ["session-1"] }); + it("disables the session selector when only one session and no more pages", () => { + const props = makeProps({ + sessions: ["session-1"], + hasNextSessionsPage: false, + }); render(); - expect(screen.getByLabelText("Session filter")).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Session filter" }), + ).toBeDisabled(); + }); + + it("renders the reload button with correct aria-label", () => { + const props = makeProps(); + render(); + expect( + screen.getByRole("button", { name: "Reload sessions and messages" }), + ).toBeInTheDocument(); + }); + + it("calls onRefresh when the reload button is clicked", () => { + const onRefresh = jest.fn(); + const props = makeProps({ onRefresh }); + render(); + fireEvent.click( + screen.getByRole("button", { name: "Reload sessions and messages" }), + ); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("renders the RefreshCw icon inside the reload button", () => { + const props = makeProps(); + render(); + const reloadBtn = screen.getByRole("button", { + name: "Reload sessions and messages", + }); + expect(reloadBtn).toHaveTextContent("RefreshCw"); + }); + + it("calls fetchNextSessionsPage on scroll when near bottom and more pages exist", () => { + const fetchNextSessionsPage = jest.fn(); + const props = makeProps({ + sessions: ["s1", "s2", "s3"], + hasNextSessionsPage: true, + isFetchingNextSessionsPage: false, + fetchNextSessionsPage, + }); + const { container } = render(); + + const scrollDiv = container.querySelector(".overflow-y-auto"); + if (!scrollDiv) return; + + Object.defineProperty(scrollDiv, "scrollHeight", { + value: 400, + configurable: true, + }); + Object.defineProperty(scrollDiv, "scrollTop", { + value: 200, + configurable: true, + }); + Object.defineProperty(scrollDiv, "clientHeight", { + value: 240, + configurable: true, + }); + + fireEvent.scroll(scrollDiv); + expect(fetchNextSessionsPage).toHaveBeenCalledTimes(1); + }); + + it("does not call fetchNextSessionsPage when already fetching next page", () => { + const fetchNextSessionsPage = jest.fn(); + const props = makeProps({ + sessions: ["s1", "s2", "s3"], + hasNextSessionsPage: true, + isFetchingNextSessionsPage: true, + fetchNextSessionsPage, + }); + const { container } = render(); + + const scrollDiv = container.querySelector(".overflow-y-auto"); + if (!scrollDiv) return; + + Object.defineProperty(scrollDiv, "scrollHeight", { + value: 400, + configurable: true, + }); + Object.defineProperty(scrollDiv, "scrollTop", { + value: 200, + configurable: true, + }); + Object.defineProperty(scrollDiv, "clientHeight", { + value: 240, + configurable: true, + }); + + fireEvent.scroll(scrollDiv); + expect(fetchNextSessionsPage).not.toHaveBeenCalled(); + }); + + it("does not call fetchNextSessionsPage when no more pages exist", () => { + const fetchNextSessionsPage = jest.fn(); + const props = makeProps({ + sessions: ["s1", "s2"], + hasNextSessionsPage: false, + isFetchingNextSessionsPage: false, + fetchNextSessionsPage, + }); + const { container } = render(); + + const scrollDiv = container.querySelector(".overflow-y-auto"); + if (!scrollDiv) return; + + Object.defineProperty(scrollDiv, "scrollHeight", { + value: 400, + configurable: true, + }); + Object.defineProperty(scrollDiv, "scrollTop", { + value: 300, + configurable: true, + }); + Object.defineProperty(scrollDiv, "clientHeight", { + value: 240, + configurable: true, + }); + + fireEvent.scroll(scrollDiv); + expect(fetchNextSessionsPage).not.toHaveBeenCalled(); + }); + + it("shows a loading indicator while fetching next page", () => { + const props = makeProps({ + sessions: ["s1", "s2"], + hasNextSessionsPage: true, + isFetchingNextSessionsPage: true, + }); + const { container } = render(); + + const scrollDiv = container.querySelector(".overflow-y-auto"); + expect(scrollDiv).not.toBeNull(); + expect(scrollDiv).toHaveTextContent("Loading"); }); }); diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryKnowledgeBaseSection.test.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryKnowledgeBaseSection.test.tsx index 912701e4f5..d905ff203b 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryKnowledgeBaseSection.test.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/__tests__/MemoryKnowledgeBaseSection.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { MemoryKnowledgeBaseSection } from "../MemoryKnowledgeBaseSection"; -import type { MemoryKnowledgeBaseSectionProps } from "../../types"; import type { MemoryDocumentItem } from "@/controllers/API/queries/memories/types"; +import type { MemoryKnowledgeBaseSectionProps } from "../../types"; +import { MemoryKnowledgeBaseSection } from "../MemoryKnowledgeBaseSection"; jest.mock("@/components/common/genericIconComponent", () => ({ __esModule: true, @@ -20,7 +20,7 @@ describe("MemoryKnowledgeBaseSection", () => { message_id: "msg-1", session_id: "session-1", sender: "user", - ingestion_job_id: "job-1", + job_id: "job-1", ingestion_timestamp: "2025-01-01T10:00:01.000Z", content: "hello", timestamp: "2025-01-01T10:00:00.000Z", @@ -37,8 +37,6 @@ describe("MemoryKnowledgeBaseSection", () => { fetchNextMessagesPage: jest.fn(), hasNextMessagesPage: false, isFetchingNextMessagesPage: false, - selectedSession: null, - setSelectedSession: jest.fn(), groupedBySession: new Map([["session-1", documents]]), handleOpenDocumentPanel: jest.fn(), }; diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx index d66139ea43..006c5cf165 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx @@ -23,8 +23,7 @@ type MemoriesPageFixture = { }; // --------------------------------------------------------------------------- -// Fixture factories — provide required-field defaults so tests only specify -// the fields they actually exercise. +// Fixture factories // --------------------------------------------------------------------------- function makeMemoryInfo( @@ -76,7 +75,7 @@ jest.mock("@/stores/alertStore", () => ({ })); // --------------------------------------------------------------------------- -// Mutable fixture state (reset in beforeEach; mutated in individual tests) +// Mutable fixture state (reset in beforeEach) // --------------------------------------------------------------------------- let memories: MemoryInfo[] = [ @@ -89,9 +88,10 @@ let memories: MemoryInfo[] = [ }), ]; -// Override to simulate multiple pages; undefined means use the default single-page shape. +// Set in individual tests to simulate multiple pages; undefined = single page. let memoriesPages: MemoriesPageFixture[] | undefined; +const mockRefetchMemories = jest.fn(); jest.mock("@/controllers/API/queries/memories/use-get-memories", () => ({ useGetMemories: () => { const pages: MemoriesPageFixture[] = memoriesPages ?? [ @@ -102,6 +102,7 @@ jest.mock("@/controllers/API/queries/memories/use-get-memories", () => ({ fetchNextPage: jest.fn(), hasNextPage: false, isFetchingNextPage: false, + refetch: mockRefetchMemories, }; }, })); @@ -139,12 +140,26 @@ let memorySessionsData: MemorySessionInfo[] = [ }, ]; +const mockRefetchSessions = jest.fn(); +const mockFetchNextSessionsPage = jest.fn(); jest.mock("@/controllers/API/queries/memories/use-get-memory-sessions", () => ({ useGetMemorySessions: () => ({ - data: memorySessionsData, - isLoading: false, - isError: false, - refetch: jest.fn(), + data: { + pages: [ + { + items: memorySessionsData, + total: memorySessionsData.length, + page: 1, + size: 50, + pages: 1, + }, + ], + pageParams: [1], + }, + refetch: mockRefetchSessions, + fetchNextPage: mockFetchNextSessionsPage, + hasNextPage: false, + isFetchingNextPage: false, }), })); @@ -154,7 +169,7 @@ let messagesBySession: Record = { timestamp: "2026-04-01T19:29:07", sender: "User", sender_name: "User", - ingestion_job_id: "job-1", + job_id: "job-1", ingestion_timestamp: "2026-04-02T20:51:06.951803", session_id: "s1", text: "Hello.", @@ -166,7 +181,7 @@ let messagesBySession: Record = { timestamp: "2026-04-01T19:29:08", sender: "Machine", sender_name: "AI", - ingestion_job_id: "job-2", + job_id: "job-2", ingestion_timestamp: "2026-04-02T20:51:06.951803", session_id: "s2", text: "Hi.", @@ -175,6 +190,7 @@ let messagesBySession: Record = { ], }; +const mockRefetchMessages = jest.fn(); jest.mock( "@/controllers/API/queries/memories/use-get-memory-session-messages", () => ({ @@ -190,6 +206,7 @@ jest.mock( fetchNextPage: jest.fn(), hasNextPage: false, isFetchingNextPage: false, + refetch: mockRefetchMessages, }; }, }), @@ -248,7 +265,7 @@ describe("useMemoriesData", () => { timestamp: "2026-04-01T19:29:07", sender: "User", sender_name: "User", - ingestion_job_id: "job-1", + job_id: "job-1", ingestion_timestamp: "2026-04-02T20:51:06.951803", session_id: "s1", text: "Hello.", @@ -260,7 +277,7 @@ describe("useMemoriesData", () => { timestamp: "2026-04-01T19:29:08", sender: "Machine", sender_name: "AI", - ingestion_job_id: "job-2", + job_id: "job-2", ingestion_timestamp: "2026-04-02T20:51:06.951803", session_id: "s2", text: "Hi.", @@ -402,8 +419,6 @@ describe("useMemoriesData", () => { expect(Array.from(result.current.groupedBySession.keys())).toEqual(["s2"]); }); - // --- adversarial --- - it("deselects stale memory when the list comes back empty", () => { memories = []; const onSelectMemory = jest.fn(); @@ -455,12 +470,41 @@ describe("useMemoriesData", () => { result.current.setSelectedSession("ghost-session-xyz"); }); - // effectiveSessionId must resolve to one of the real sessions, never the ghost const keys = Array.from(result.current.groupedBySession.keys()); expect(keys).not.toContain("ghost-session-xyz"); expect(result.current.groupedBySession.size).toBeGreaterThan(0); }); + it("exposes onRefresh as a function", () => { + const { result } = renderHook(() => + useMemoriesData({ + currentFlowId: "flow-1", + selectedMemoryId: "m1", + onSelectMemory: jest.fn(), + }), + ); + + expect(typeof result.current.onRefresh).toBe("function"); + }); + + it("onRefresh calls refetchMemories, refetchMemorySessions and refetchMessages", () => { + const { result } = renderHook(() => + useMemoriesData({ + currentFlowId: "flow-1", + selectedMemoryId: "m1", + onSelectMemory: jest.fn(), + }), + ); + + act(() => { + result.current.onRefresh(); + }); + + expect(mockRefetchMemories).toHaveBeenCalledTimes(1); + expect(mockRefetchSessions).toHaveBeenCalled(); + expect(mockRefetchMessages).toHaveBeenCalledTimes(1); + }); + it("flattens memories across multiple API pages into a single array", () => { memoriesPages = [ { @@ -488,6 +532,21 @@ describe("useMemoriesData", () => { ); expect(result.current.memories).toHaveLength(2); - expect(result.current.memories.map((m) => m.id)).toEqual(["m1", "m2"]); + expect(result.current.memories[0].id).toBe("m1"); + expect(result.current.memories[1].id).toBe("m2"); + }); + + it("exposes fetchNextSessionsPage, hasNextSessionsPage and isFetchingNextSessionsPage", () => { + const { result } = renderHook(() => + useMemoriesData({ + currentFlowId: "flow-1", + selectedMemoryId: "m1", + onSelectMemory: jest.fn(), + }), + ); + + expect(result.current.fetchNextSessionsPage).toBe(mockFetchNextSessionsPage); + expect(result.current.hasNextSessionsPage).toBe(false); + expect(result.current.isFetchingNextSessionsPage).toBe(false); }); }); diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoryDocuments.test.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoryDocuments.test.ts new file mode 100644 index 0000000000..65df872233 --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoryDocuments.test.ts @@ -0,0 +1,172 @@ +import { renderHook } from "@testing-library/react"; +import { useMemoryDocuments } from "../useMemoryDocuments"; + +const mockFetchNextPage = jest.fn(); +const mockRefetch = jest.fn(); + +let mockPages: any[] = []; + +jest.mock( + "@/controllers/API/queries/memories/use-get-memory-session-messages", + () => ({ + useGetMemorySessionMessages: () => ({ + data: { pages: mockPages, pageParams: [1] }, + isLoading: false, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + refetch: mockRefetch, + }), + }), +); + +const memorySessions = [ + { + session_id: "s1", + total_processed: 1, + pending_count: 0, + last_sync_at: "2026-04-01", + id: "s1", + memory_base_id: "m1", + cursor_id: null, + }, + { + session_id: "s2", + total_processed: 2, + pending_count: 0, + last_sync_at: "2026-03-01", + id: "s2", + memory_base_id: "m1", + cursor_id: null, + }, +]; + +describe("useMemoryDocuments", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPages = [ + { + items: [ + { + timestamp: "2026-04-01T10:00:00", + sender: "User", + job_id: "job-1", + ingestion_timestamp: "2026-04-01T11:00:00", + session_id: "s1", + text: "Hello world", + }, + { + timestamp: "2026-04-01T10:01:00", + sender: "AI", + job_id: "job-2", + ingestion_timestamp: "2026-04-01T11:01:00", + session_id: "s1", + text: "Hi there", + }, + ], + total: 2, + page: 1, + size: 50, + pages: 1, + }, + ]; + }); + + it("exposes refetchMessages from the messages query", () => { + const { result } = renderHook(() => + useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }), + ); + + expect(result.current.refetchMessages).toBe(mockRefetch); + }); + + it("flattens all message pages into documents", () => { + const { result } = renderHook(() => + useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }), + ); + + expect(result.current.docsData.documents).toHaveLength(2); + }); + + it("filters out messages with no content", () => { + mockPages = [ + { + items: [ + { timestamp: "t1", sender: "User", session_id: "s1", text: "Valid" }, + { timestamp: "t2", sender: "AI", session_id: "s1", text: "" }, + ], + }, + ]; + + const { result } = renderHook(() => + useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }), + ); + + expect(result.current.docsData.documents).toHaveLength(1); + expect(result.current.docsData.documents![0].content).toBe("Valid"); + }); + + it("filters documents to the active sessionId", () => { + mockPages = [ + { + items: [ + { + timestamp: "t1", + sender: "User", + session_id: "s1", + text: "From s1", + }, + { + timestamp: "t2", + sender: "User", + session_id: "s2", + text: "From s2", + }, + ], + }, + ]; + + const { result } = renderHook(() => + useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }), + ); + + expect(result.current.docsData.documents).toHaveLength(1); + expect(result.current.docsData.documents![0].content).toBe("From s1"); + }); + + it("builds the sessions list from memorySessions", () => { + const { result } = renderHook(() => + useMemoryDocuments({ memoryId: "m1", sessionId: "s1", memorySessions }), + ); + + expect(result.current.docsData.sessions).toEqual(["s1", "s2"]); + }); + + it("deduplicates session IDs in the sessions list", () => { + const dupSessions = [ + ...memorySessions, + { + session_id: "s1", + total_processed: 0, + pending_count: 0, + last_sync_at: "", + id: "s1b", + memory_base_id: "m1", + cursor_id: null, + }, + ]; + + const { result } = renderHook(() => + useMemoryDocuments({ + memoryId: "m1", + sessionId: "s1", + memorySessions: dupSessions, + }), + ); + + const s1Count = result.current.docsData.sessions!.filter( + (s) => s === "s1", + ).length; + expect(s1Count).toBe(1); + }); +}); diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemorySessionResolver.test.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemorySessionResolver.test.ts new file mode 100644 index 0000000000..898df29d59 --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemorySessionResolver.test.ts @@ -0,0 +1,200 @@ +import { act, renderHook } from "@testing-library/react"; +import { useMemorySessionResolver } from "../useMemorySessionResolver"; + +const mockFetchNextPage = jest.fn(); +const mockRefetch = jest.fn(); + +let mockSessionPages: any[] = [ + { + items: [ + { + session_id: "s1", + last_sync_at: "2026-04-02T00:00:00.000Z", + total_processed: 5, + pending_count: 0, + }, + { + session_id: "s2", + last_sync_at: "2026-03-01T00:00:00.000Z", + total_processed: 2, + pending_count: 1, + }, + ], + total: 2, + page: 1, + size: 50, + pages: 1, + }, +]; +let mockHasNextPage = false; +let mockIsFetchingNextPage = false; + +jest.mock("@/controllers/API/queries/memories/use-get-memory-sessions", () => ({ + useGetMemorySessions: () => ({ + data: { pages: mockSessionPages, pageParams: [1] }, + refetch: mockRefetch, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockHasNextPage, + isFetchingNextPage: mockIsFetchingNextPage, + }), +})); + +describe("useMemorySessionResolver", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHasNextPage = false; + mockIsFetchingNextPage = false; + mockSessionPages = [ + { + items: [ + { + session_id: "s1", + last_sync_at: "2026-04-02T00:00:00.000Z", + total_processed: 5, + pending_count: 0, + }, + { + session_id: "s2", + last_sync_at: "2026-03-01T00:00:00.000Z", + total_processed: 2, + pending_count: 1, + }, + ], + total: 2, + page: 1, + size: 50, + pages: 1, + }, + ]; + }); + + it("flattens all pages into a single memorySessions array", () => { + mockSessionPages = [ + { + items: [ + { + session_id: "s1", + last_sync_at: "2026-04-02", + total_processed: 0, + pending_count: 0, + }, + ], + }, + { + items: [ + { + session_id: "s2", + last_sync_at: "2026-03-01", + total_processed: 0, + pending_count: 0, + }, + ], + }, + ]; + + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.memorySessions).toHaveLength(2); + expect(result.current.memorySessions[0].session_id).toBe("s1"); + expect(result.current.memorySessions[1].session_id).toBe("s2"); + }); + + it("returns empty memorySessions when pages are empty", () => { + mockSessionPages = []; + + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.memorySessions).toEqual([]); + }); + + it("exposes fetchNextSessionsPage from the query", () => { + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.fetchNextSessionsPage).toBe(mockFetchNextPage); + }); + + it("exposes hasNextSessionsPage from the query", () => { + mockHasNextPage = true; + + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.hasNextSessionsPage).toBe(true); + }); + + it("exposes isFetchingNextSessionsPage from the query", () => { + mockIsFetchingNextPage = true; + + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.isFetchingNextSessionsPage).toBe(true); + }); + + it("exposes refetchMemorySessions from the query", () => { + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.refetchMemorySessions).toBe(mockRefetch); + }); + + it("sets effectiveSessionId to the most recently synced session by default", () => { + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + expect(result.current.effectiveSessionId).toBe("s1"); + }); + + it("respects selectedSession when it exists in memorySessions", () => { + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + act(() => { + result.current.setSelectedSession("s2"); + }); + + expect(result.current.effectiveSessionId).toBe("s2"); + expect(result.current.selectedSession).toBe("s2"); + }); + + it("falls back to default session when selectedSession is not in the list", () => { + const { result } = renderHook(() => + useMemorySessionResolver({ memoryId: "m1" }), + ); + + act(() => { + result.current.setSelectedSession("nonexistent"); + }); + + expect(result.current.effectiveSessionId).toBe("s1"); + }); + + it("resets selectedSession when memoryId changes", () => { + const { result, rerender } = renderHook( + ({ memoryId }) => useMemorySessionResolver({ memoryId }), + { initialProps: { memoryId: "m1" } }, + ); + + act(() => { + result.current.setSelectedSession("s2"); + }); + expect(result.current.selectedSession).toBe("s2"); + + // Return empty pages for the new memory so the auto-select effect doesn't fire + mockSessionPages = []; + rerender({ memoryId: "m2" }); + + expect(result.current.selectedSession).toBeNull(); + }); +}); diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useAutoCaptureDebouncedToggle.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useAutoCaptureDebouncedToggle.ts index 8af43fdfbc..22aff71484 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useAutoCaptureDebouncedToggle.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useAutoCaptureDebouncedToggle.ts @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import type { UpdateMemoryParams } from "@/controllers/API/queries/memories/types"; -import type { MemoryInfo } from "@/controllers/API/queries/memories/types"; import { AUTO_CAPTURE_DEBOUNCE_MS } from "../MemoriesMainContent.constants"; +import type { + MemoryInfo, + UpdateMemoryParams, +} from "@/controllers/API/queries/memories/types"; type UpdateMemoryMutation = { mutate: ( variables: UpdateMemoryParams, - options?: { onSuccess?: () => void }, + options?: { onSuccess?: () => void; onError?: () => void }, ) => void; }; @@ -99,16 +101,20 @@ export const useAutoCaptureDebouncedToggle = ({ return; } + const clearDraft = () => { + setAutoCaptureDraft(null); + draftIsActiveRef.current = null; + }; + updateMemoryMutation.mutate( { memoryId: memory.id, auto_capture: nextIsActive, }, { - onSuccess: () => { - setAutoCaptureDraft(null); - draftIsActiveRef.current = null; - }, + onSuccess: clearDraft, + // On failure the draft never resolved — reset so UI reflects server state. + onError: clearDraft, }, ); autoCaptureTimerRef.current = null; diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoriesData.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoriesData.ts index 2f1e51e3de..8bf4d99e20 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoriesData.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoriesData.ts @@ -1,19 +1,18 @@ -import { useEffect, useMemo, useState } from "react"; -import useAlertStore from "@/stores/alertStore"; -import { useGetMemories } from "@/controllers/API/queries/memories/use-get-memories"; -import { useGetMemory } from "@/controllers/API/queries/memories/use-get-memory"; -import { useDeleteMemory } from "@/controllers/API/queries/memories/use-delete-memory"; -import { useUpdateMemory } from "@/controllers/API/queries/memories/use-update-memory"; -import { UseMemoriesDataProps } from "../types"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { MemoryDocumentItem, MemoryInfo, } from "@/controllers/API/queries/memories/types"; -import { useMemorySessionResolver } from "./useMemorySessionResolver"; +import { useDeleteMemory } from "@/controllers/API/queries/memories/use-delete-memory"; +import { useGetMemories } from "@/controllers/API/queries/memories/use-get-memories"; +import { useGetMemory } from "@/controllers/API/queries/memories/use-get-memory"; +import { useUpdateMemory } from "@/controllers/API/queries/memories/use-update-memory"; +import useAlertStore from "@/stores/alertStore"; +import { UseMemoriesDataProps } from "../types"; import { useAutoCaptureDebouncedToggle } from "./useAutoCaptureDebouncedToggle"; import { useMemoryDocuments } from "./useMemoryDocuments"; -import { AUTO_CAPTURE_DEBOUNCE_MS } from "../MemoriesMainContent.constants"; import { extractApiErrorMessages } from "@/utils/apiError"; +import { useMemorySessionResolver } from "./useMemorySessionResolver"; const EMPTY_MEMORIES: MemoryInfo[] = []; @@ -35,6 +34,7 @@ export function useMemoriesData({ fetchNextPage, hasNextPage, isFetchingNextPage, + refetch: refetchMemories, } = useGetMemories( { flowId: currentFlowId ?? undefined }, { enabled: !!currentFlowId }, @@ -83,7 +83,6 @@ export function useMemoriesData({ { memoryId: selectedMemoryId ?? "" }, { enabled: !!selectedMemoryId, - retry: false, }, ); @@ -92,6 +91,10 @@ export function useMemoriesData({ selectedSession, setSelectedSession, effectiveSessionId, + refetchMemorySessions, + fetchNextSessionsPage, + hasNextSessionsPage, + isFetchingNextSessionsPage, } = useMemorySessionResolver({ memoryId: selectedMemoryId }); const deleteMutation = useDeleteMemory({ @@ -118,7 +121,6 @@ export function useMemoriesData({ useAutoCaptureDebouncedToggle({ memory, updateMemoryMutation, - debounceMs: AUTO_CAPTURE_DEBOUNCE_MS, }); const { @@ -127,6 +129,7 @@ export function useMemoriesData({ fetchNextMessagesPage, hasNextMessagesPage, isFetchingNextMessagesPage, + refetchMessages, } = useMemoryDocuments({ memoryId: selectedMemoryId, sessionId: effectiveSessionId, @@ -163,6 +166,12 @@ export function useMemoriesData({ return nextMemory; }, [memory, autoCaptureDraft, effectiveSessionId, memorySessions]); + const onRefresh = useCallback(() => { + refetchMemories(); + refetchMemorySessions(); + refetchMessages(); + }, [refetchMemories, refetchMemorySessions, refetchMessages]); + const handleOpenDocumentPanel = (doc: MemoryDocumentItem) => { setSelectedDocument(doc); setDocumentPanelOpen(true); @@ -173,13 +182,12 @@ export function useMemoriesData({ const map = new Map(); for (const doc of docsData.documents) { const sid = doc.session_id || "(no session)"; - if (effectiveSessionId && sid !== effectiveSessionId) continue; const list = map.get(sid) || []; list.push(doc); map.set(sid, list); } return map; - }, [docsData, effectiveSessionId]); + }, [docsData]); return { memories, @@ -207,6 +215,10 @@ export function useMemoriesData({ deleteMutation, updateMemoryMutation, handleToggleActive, + onRefresh, + fetchNextSessionsPage, + hasNextSessionsPage, + isFetchingNextSessionsPage, createModalOpen, setCreateModalOpen, }; diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoryDocuments.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoryDocuments.ts index ab97f0b4f9..d97f8136e4 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoryDocuments.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoryDocuments.ts @@ -1,9 +1,9 @@ import { useMemo } from "react"; -import { useGetMemorySessionMessages } from "@/controllers/API/queries/memories/use-get-memory-session-messages"; import type { MemoryDocumentItem, MemorySessionInfo, } from "@/controllers/API/queries/memories/types"; +import { useGetMemorySessionMessages } from "@/controllers/API/queries/memories/use-get-memory-session-messages"; type UseMemoryDocumentsArgs = { memoryId?: string | null; @@ -24,6 +24,7 @@ export const useMemoryDocuments = ({ fetchNextPage: fetchNextMessagesPage, hasNextPage: hasNextMessagesPage, isFetchingNextPage: isFetchingNextMessagesPage, + refetch: refetchMessages, } = useGetMemorySessionMessages( { memoryId: memoryId ?? "", @@ -40,16 +41,22 @@ export const useMemoryDocuments = ({ const rawDocuments: MemoryDocumentItem[] = pages .flatMap((p) => p?.items ?? []) - .map((m) => { - const ingestionJobId = String(m?.ingestion_job_id ?? ""); + .map((m, idx) => { + const ingestionJobId = String(m?.job_id ?? ""); const ingestionTimestamp = String(m?.ingestion_timestamp ?? ""); const timestamp = String(m?.timestamp ?? ""); const sender = String(m?.sender ?? ""); const sessionIdFromMessage = String(m?.session_id ?? ""); - const messageId = - ingestionJobId || timestamp || sender - ? [ingestionJobId, timestamp, sender].filter(Boolean).join(":") - : ""; + // Include idx as a tiebreaker so messages sharing sender/timestamp/job are distinct. + const messageId = [ + sessionIdFromMessage, + ingestionJobId, + timestamp, + sender, + String(idx), + ] + .filter(Boolean) + .join(":"); return { message_id: messageId, @@ -57,7 +64,7 @@ export const useMemoryDocuments = ({ sender, content: String(m?.text ?? ""), timestamp, - ...(ingestionJobId ? { ingestion_job_id: ingestionJobId } : {}), + ...(ingestionJobId ? { job_id: ingestionJobId } : {}), ...(ingestionTimestamp ? { ingestion_timestamp: ingestionTimestamp } : {}), @@ -94,5 +101,6 @@ export const useMemoryDocuments = ({ fetchNextMessagesPage, hasNextMessagesPage, isFetchingNextMessagesPage, + refetchMessages, }; }; diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemorySessionResolver.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemorySessionResolver.ts index 4cf5414c22..6fd49ec2b3 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemorySessionResolver.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemorySessionResolver.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import type { MemorySessionInfo } from "@/controllers/API/queries/memories/types"; import { useGetMemorySessions } from "@/controllers/API/queries/memories/use-get-memory-sessions"; import { resolveDefaultSessionId } from "./memorySessionResolverHelpers"; @@ -11,14 +12,23 @@ export const useMemorySessionResolver = ({ }: UseMemorySessionResolverArgs) => { const [selectedSession, setSelectedSession] = useState(null); - const { data: memorySessions = [], refetch: refetchMemorySessions } = - useGetMemorySessions( - { memoryId: memoryId ?? "" }, - { - enabled: !!memoryId, - retry: false, - }, - ); + const { + data: sessionsInfinite, + refetch: refetchMemorySessions, + fetchNextPage: fetchNextSessionsPage, + hasNextPage: hasNextSessionsPage, + isFetchingNextPage: isFetchingNextSessionsPage, + } = useGetMemorySessions( + { memoryId: memoryId ?? "" }, + { + enabled: !!memoryId, + }, + ); + + const memorySessions = useMemo(() => { + const pages = sessionsInfinite?.pages ?? []; + return pages.flatMap((p) => p?.items ?? []); + }, [sessionsInfinite]); useEffect(() => { setSelectedSession(null); @@ -61,5 +71,9 @@ export const useMemorySessionResolver = ({ selectedSession, setSelectedSession, effectiveSessionId, + refetchMemorySessions, + fetchNextSessionsPage, + hasNextSessionsPage, + isFetchingNextSessionsPage, }; }; diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/index.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/index.tsx index 73e5e12ef6..12065a0759 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/index.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useState } from "react"; import Loading from "@/components/ui/loading"; import CreateMemoryModal from "@/modals/createMemoryModal"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; -import { useMemoriesData } from "./hooks/useMemoriesData"; -import { NoMemorySelected } from "./components/NoMemorySelected"; import { MemoriesSidebar } from "./components/MemoriesSidebar"; import { MemoryDetails } from "./components/MemoryDetails"; import { MemoryDocumentPanel } from "./components/MemoryDocumentPanel"; +import { NoMemorySelected } from "./components/NoMemorySelected"; +import { useMemoriesData } from "./hooks/useMemoriesData"; export default function MemoriesMainContent() { const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId); @@ -48,6 +48,10 @@ export default function MemoriesMainContent() { handleOpenDocumentPanel, deleteMutation, handleToggleActive, + onRefresh, + fetchNextSessionsPage, + hasNextSessionsPage, + isFetchingNextSessionsPage, createModalOpen, setCreateModalOpen, } = useMemoriesData({ @@ -95,6 +99,10 @@ export default function MemoriesMainContent() { handleOpenDocumentPanel={handleOpenDocumentPanel} deleteMutation={deleteMutation} handleToggleActive={handleToggleActive} + onRefresh={onRefresh} + fetchNextSessionsPage={fetchNextSessionsPage} + hasNextSessionsPage={hasNextSessionsPage} + isFetchingNextSessionsPage={isFetchingNextSessionsPage} /> )}
diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts index 7c7e3c8dc0..c3bd68c46c 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts @@ -39,6 +39,10 @@ export type MemoryDetailsProps = { handleOpenDocumentPanel: (doc: MemoryDocumentItem) => void; deleteMutation: MemoryActionMutation; handleToggleActive: (nextIsActive: NextIsActive) => void; + onRefresh: () => void; + fetchNextSessionsPage: () => void; + hasNextSessionsPage?: boolean; + isFetchingNextSessionsPage?: boolean; }; export type MemoriesSidebarProps = { @@ -71,8 +75,6 @@ export type MemoryKnowledgeBaseSectionProps = { fetchNextMessagesPage: () => void; hasNextMessagesPage?: boolean; isFetchingNextMessagesPage?: boolean; - selectedSession: string | null; - setSelectedSession: (value: string | null) => void; groupedBySession: Map; handleOpenDocumentPanel: (doc: MemoryDocumentItem) => void; }; @@ -84,4 +86,8 @@ export type MemoryDetailsHeaderProps = { setSelectedSession: (value: string | null) => void; deleteMutation: MemoryActionMutation; handleToggleActive: (nextIsActive: NextIsActive) => void; + onRefresh: () => void; + fetchNextSessionsPage: () => void; + hasNextSessionsPage?: boolean; + isFetchingNextSessionsPage?: boolean; }; From 358310ce949a7a9e05a2c94ae420d644e5c80f3a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:46:50 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- .../API/queries/memories/memoriesQueryConfig.ts | 5 ++++- .../controllers/API/queries/memories/use-delete-memory.ts | 8 ++++++-- .../hooks/__tests__/useMemoriesData.test.tsx | 4 +++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts b/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts index 5dfc84600b..c8a86ed709 100644 --- a/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts +++ b/src/frontend/src/controllers/API/queries/memories/memoriesQueryConfig.ts @@ -6,7 +6,10 @@ const MEMORIES_RETRY_MAX_DELAY_MS = 10_000; /** Exponential backoff capped at 10 s, shared by all memory query and mutation hooks. */ export const memoriesRetryDelay = (attemptIndex: number): number => - Math.min(MEMORIES_RETRY_DELAY_MS * 2 ** attemptIndex, MEMORIES_RETRY_MAX_DELAY_MS); + Math.min( + MEMORIES_RETRY_DELAY_MS * 2 ** attemptIndex, + MEMORIES_RETRY_MAX_DELAY_MS, + ); /** Default page size for all memory infinite queries. */ export const MEMORIES_PAGE_SIZE = 50; diff --git a/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts b/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts index cc7d1318ef..18b58b2ed8 100644 --- a/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts +++ b/src/frontend/src/controllers/API/queries/memories/use-delete-memory.ts @@ -39,8 +39,12 @@ export const useDeleteMemory: useMutationFunctionType< ...restOptions, onSuccess: (data, variables, onMutateResult, context) => { // Cancel in-flight fetches for the deleted resource so a 404 doesn't land after removal. - queryClient.cancelQueries({ queryKey: ["useGetMemory", variables.memoryId] }); - queryClient.removeQueries({ queryKey: ["useGetMemory", variables.memoryId] }); + queryClient.cancelQueries({ + queryKey: ["useGetMemory", variables.memoryId], + }); + queryClient.removeQueries({ + queryKey: ["useGetMemory", variables.memoryId], + }); // Keep cached lists consistent without forcing a refetch. removeMemoryFromMemoriesCache(queryClient, variables.memoryId); diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx index 006c5cf165..1b70a7df98 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useMemoriesData.test.tsx @@ -545,7 +545,9 @@ describe("useMemoriesData", () => { }), ); - expect(result.current.fetchNextSessionsPage).toBe(mockFetchNextSessionsPage); + expect(result.current.fetchNextSessionsPage).toBe( + mockFetchNextSessionsPage, + ); expect(result.current.hasNextSessionsPage).toBe(false); expect(result.current.isFetchingNextSessionsPage).toBe(false); });