Merge remote-tracking branch 'origin/api-memory' into temp-memory

This commit is contained in:
Olayinka Adelakun
2026-04-27 10:03:36 -04:00
30 changed files with 1201 additions and 226 deletions

View File

@ -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>): MemoryApiDTO => ({

View File

@ -0,0 +1,140 @@
import { renderHook } from "@testing-library/react";
type QueryFn = (ctx: { pageParam: number }) => Promise<unknown>;
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);
});
});

View File

@ -0,0 +1,15 @@
/** 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;

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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;

View File

@ -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,40 @@ 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<void> => {
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<void, unknown, DeleteMemoryParams, unknown>({
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;

View File

@ -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<typeof mapGetMemoriesApiResponse>;
@ -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 ?? {}),
});
};

View File

@ -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 ?? {}),
});

View File

@ -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<MemorySessionInfo[]> => {
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<SessionsPage, number>,
SessionsQueryKey,
number
>,
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
>,
): UseInfiniteQueryResult<InfiniteData<SessionsPage, number>, unknown> => {
const memoryId = params?.memoryId;
const size = params?.size ?? MEMORIES_PAGE_SIZE;
const getSessionsPage = async ({
pageParam,
}: {
pageParam: number;
}): Promise<SessionsPage> => {
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<GetMemorySessionsApiResponse>(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<MemorySessionInfo[], Error> = query(
["useGetMemorySessions", params?.memoryId],
getSessionsFn,
{
enabled: !!params?.memoryId,
refetchOnWindowFocus: false,
...options,
},
);
return queryResult;
return useInfiniteQuery<
SessionsPage,
unknown,
InfiniteData<SessionsPage, number>,
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 ?? {}),
});
};

View File

@ -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<GetMemoryParams, MemoryInfo> = (
params,
@ -28,6 +32,8 @@ export const useGetMemory: useQueryFunctionType<GetMemoryParams, MemoryInfo> = (
{
enabled: !!params?.memoryId,
refetchOnWindowFocus: false,
retry: MEMORIES_RETRY_MAX_ATTEMPTS,
retryDelay: memoriesRetryDelay,
...options,
},
);

View File

@ -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;

View File

@ -102,7 +102,7 @@ export default function CreateMemoryModal({
<div className="flex flex-col gap-2">
<Label htmlFor="memory-batch-size">Batch Size</Label>
<span className="text-xs text-muted-foreground">
Messages to accumulate before ingestion triggers (min 1)
Messages per ingestion batch (min 1)
</span>
<Input
id="memory-batch-size"
@ -180,7 +180,7 @@ export default function CreateMemoryModal({
<BaseModal.Footer
submit={{
label: "Create Memory",
icon: <ForwardedIconComponent name="Plus" className="mr-2 h-4 w-4" />,
icon: <ForwardedIconComponent name="Plus" className="h-4 w-4" />,
loading: createMemoryMutation.isPending,
disabled:
!name.trim() ||

View File

@ -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;

View File

@ -44,8 +44,12 @@ jest.mock("../components/MemoriesSidebar", () => ({
),
}));
const capturedMemoryDetailsProps: Record<string, unknown> = {};
jest.mock("../components/MemoryDetails", () => ({
MemoryDetails: () => <div>MemoryDetails</div>,
MemoryDetails: (props: any) => {
Object.assign(capturedMemoryDetailsProps, props);
return <div>MemoryDetails</div>;
},
}));
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(<MemoriesMainContent />);
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);
});
});

View File

@ -45,7 +45,7 @@ export function MemoriesSidebar({
onClick={onCreateMemory}
disabled={!currentFlowId}
>
<IconComponent name="Plus" className="mr-1.5 h-3.5 w-3.5" />
<IconComponent name="Plus" className="h-3.5 w-3.5" />
Create
</Button>
</div>
@ -75,18 +75,6 @@ export function MemoriesSidebar({
<p className="text-xs text-muted-foreground">
{memoriesSearch.trim() ? "No memories found" : "No memories yet"}
</p>
{!memoriesSearch.trim() && (
<Button
className="mt-3"
size="sm"
variant="outline"
onClick={onCreateMemory}
disabled={!currentFlowId}
>
<IconComponent name="Plus" className="mr-1.5 h-3.5 w-3.5" />
Create Memory
</Button>
)}
</div>
) : (
<div className="flex flex-col gap-1">

View File

@ -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 (
<>
<MemoryDetailsHeader
@ -27,24 +38,20 @@ export function MemoryDetails({
setSelectedSession={setSelectedSession}
deleteMutation={deleteMutation}
handleToggleActive={handleToggleActive}
onRefresh={onRefresh}
fetchNextSessionsPage={fetchNextSessionsPage}
hasNextSessionsPage={hasNextSessionsPage}
isFetchingNextSessionsPage={isFetchingNextSessionsPage}
/>
<div className="flex flex-1 flex-col overflow-auto p-4">
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
<SummaryCard
label="Messages Processed"
label="Processed Messages"
value={memory.total_messages_processed}
icon="MessageSquare"
/>
<SummaryCard
label="Pending Messages"
value={
memory.batch_size > 1
? `${memory.pending_messages_count}/${memory.batch_size}`
: memory.pending_messages_count
}
icon="Timer"
/>
<SummaryCard label={pendingLabel} value={pendingValue} icon="Timer" />
<SummaryCard
label="Last Generated"
value={formatDate(memory.last_generated_at)}
@ -70,7 +77,9 @@ export function MemoryDetails({
<>
<span>&middot;</span>
<span>
<span className="font-medium text-foreground">Batch Size:</span>{" "}
<span className="font-medium text-foreground">
Messages per batch:
</span>{" "}
{memory.batch_size}
</span>
</>
@ -94,8 +103,6 @@ export function MemoryDetails({
fetchNextMessagesPage={fetchNextMessagesPage}
hasNextMessagesPage={hasNextMessagesPage}
isFetchingNextMessagesPage={isFetchingNextMessagesPage}
selectedSession={selectedSession}
setSelectedSession={setSelectedSession}
groupedBySession={groupedBySession}
handleOpenDocumentPanel={handleOpenDocumentPanel}
/>

View File

@ -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<HTMLDivElement>) => {
const el = e.currentTarget;
if (
el.scrollHeight - el.scrollTop <= el.clientHeight * 2 &&
hasNextSessionsPage &&
!isFetchingNextSessionsPage
) {
fetchNextSessionsPage();
}
};
return (
<div className="flex items-center justify-between border-b border-border bg-background px-6 py-3">
<div className="flex items-center gap-3">
@ -26,30 +51,89 @@ export function MemoryDetailsHeader({
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
aria-label="Reload sessions and messages"
>
<IconComponent name="RefreshCw" className="h-4 w-4" />
</Button>
{sessions && sessions.length > 0 && (
<select
aria-label="Session filter"
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
disabled={sessions.length <= 1}
value={selectedSession ?? sessions[0] ?? ""}
onChange={(e) => setSelectedSession(e.target.value)}
>
{sessions.map((sid) => (
<option key={sid} value={sid}>
{sid.length > 20 ? `${sid.slice(0, 20)}...` : sid}
</option>
))}
</select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
aria-label="Session filter"
disabled={sessions.length <= 1 && !hasNextSessionsPage}
className="w-[240px] justify-between px-3"
>
<span className="truncate">
{effectiveSession && effectiveSession.length > 20
? `${effectiveSession.slice(0, 20)}...`
: (effectiveSession ?? "")}
</span>
<IconComponent
name="ChevronDown"
className="ml-2 h-4 w-4 shrink-0 text-muted-foreground"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[240px] p-0">
<div
className="max-h-[240px] overflow-y-auto py-1"
onScroll={handleSessionsScroll}
>
{sessions.map((sid) => {
const isSelected = sid === effectiveSession;
return (
<DropdownMenuItem
key={sid}
className="flex items-center justify-between"
onSelect={(e) => {
e.preventDefault();
setSelectedSession(sid);
}}
>
<span className="truncate">{sid}</span>
<IconComponent
name="Check"
className={
isSelected
? "h-4 w-4 text-primary"
: "h-4 w-4 opacity-0"
}
/>
</DropdownMenuItem>
);
})}
{isFetchingNextSessionsPage && (
<div className="py-1 text-center">
<span className="text-xs text-muted-foreground">
Loading
</span>
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="outline"
variant={memory.is_active ? "primary" : "outline"}
size="sm"
onClick={() => handleToggleActive((prevIsActive) => !prevIsActive)}
aria-pressed={memory.is_active}
aria-label="Toggle auto-capture"
className="gap-2"
>
Auto-capture: {memory.is_active ? "Enabled" : "Disabled"}
<IconComponent
name={memory.is_active ? "ToggleRight" : "ToggleLeft"}
className="h-4 w-4"
/>
Auto-capture
</Button>
<DeleteConfirmationModal
@ -65,7 +149,7 @@ export function MemoryDetailsHeader({
size="sm"
disabled={deleteMutation.isPending}
>
<IconComponent name="Trash2" className="mr-1.5 h-3.5 w-3.5" />
<IconComponent name="Trash2" className="h-4 w-4" />
Delete
</Button>
</DeleteConfirmationModal>

View File

@ -20,7 +20,6 @@ export function MemoryKnowledgeBaseSection({
fetchNextMessagesPage,
hasNextMessagesPage,
isFetchingNextMessagesPage,
setSelectedSession,
groupedBySession,
handleOpenDocumentPanel,
}: MemoryKnowledgeBaseSectionProps) {
@ -61,9 +60,8 @@ export function MemoryKnowledgeBaseSection({
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-32 text-xs">Session</TableHead>
<TableHead className="w-24 text-xs">Sender</TableHead>
<TableHead className="w-40 text-xs">Ingestion Job</TableHead>
<TableHead className="w-40 text-xs">Job ID</TableHead>
<TableHead className="text-xs">Content</TableHead>
<TableHead className="w-44 text-xs">
Ingestion Timestamp
@ -72,43 +70,22 @@ export function MemoryKnowledgeBaseSection({
</TableHeader>
<TableBody>
{Array.from(groupedBySession.entries()).map(([sessionId, docs]) =>
docs.map((doc, index) => (
docs.map((doc, idx) => (
<TableRow
key={`${sessionId}-${doc.message_id}-${index}`}
key={`${sessionId}-${doc.message_id}`}
className={cn(
"cursor-pointer",
index === 0 && sessionId !== "(no session)"
idx === 0 && sessionId !== "(no session)"
? "border-t-2 border-t-border"
: "",
)}
onClick={() => handleOpenDocumentPanel(doc)}
>
<TableCell className="text-xs text-muted-foreground">
{index === 0 ? (
<span
className="cursor-pointer truncate font-medium text-foreground hover:underline"
title={sessionId}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedSession(sessionId);
}}
>
{sessionId === "(no session)"
? sessionId
: sessionId.length > 12
? `${sessionId.slice(0, 12)}...`
: sessionId}
</span>
) : (
""
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{doc.sender || "-"}
</TableCell>
<TableCell className="max-w-40 truncate text-xs text-muted-foreground">
{doc.ingestion_job_id || "-"}
{doc.job_id || "-"}
</TableCell>
<TableCell className="max-w-md text-xs">
<div className="line-clamp-2" title={doc.content}>
@ -126,7 +103,7 @@ export function MemoryKnowledgeBaseSection({
{isFetchingNextMessagesPage && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={5} className="py-4">
<TableCell colSpan={4} className="py-4">
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Loading size={16} className="text-muted-foreground" />
Loading more...

View File

@ -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 }) => <span>{name}</span>,
}));
jest.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
className,
}: {
children: React.ReactNode;
onSelect?: (e: Event) => void;
className?: string;
}) => (
<div
role="menuitem"
className={className}
onClick={(e) => onSelect?.(e as unknown as Event)}
>
{children}
</div>
),
}));
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
const scrollDiv = container.querySelector(".overflow-y-auto");
expect(scrollDiv).not.toBeNull();
expect(scrollDiv).toHaveTextContent("Loading");
});
});

View File

@ -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(),
};

View File

@ -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<string, MemorySessionMessageApiItem[]> = {
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<string, MemorySessionMessageApiItem[]> = {
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<string, MemorySessionMessageApiItem[]> = {
],
};
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,23 @@ 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);
});
});

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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<string, MemoryDocumentItem[]>();
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,
};

View File

@ -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,
};
};

View File

@ -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<string | null>(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<MemorySessionInfo[]>(() => {
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,
};
};

View File

@ -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}
/>
)}
</div>

View File

@ -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<string, MemoryDocumentItem[]>;
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;
};