diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx index ced42104f4..368a138d90 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx @@ -45,6 +45,7 @@ export default function ModelInputComponent({ editNode, inspectionPanel, showEmptyState = false, + modelType: modelTypeProp, }: BaseInputProps & ModelInputComponentType): JSX.Element | null { const { t } = useTranslation(); @@ -111,9 +112,10 @@ export default function ModelInputComponent({ }); const modelType = - nodeClass?.template?.model?.model_type === "language" + modelTypeProp ?? + (nodeClass?.template?.model?.model_type === "language" ? "llm" - : "embeddings"; + : "embeddings"); // Declarative metadata filters from the backend ModelInput (e.g. Agent // declares ``filters={"tool_calling": True}``). The backend already diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/types.ts b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/types.ts index 41dc5590dc..32c52cf197 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/types.ts +++ b/src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/types.ts @@ -1,3 +1,5 @@ +import type { APIClassType } from "@/types/api"; + export interface ModelOption { id?: string; name: string; @@ -6,12 +8,19 @@ export interface ModelOption { metadata?: Record; } +export type ExternalOptionsType = { + fields: { data: { node: APIClassType } }; + functionality?: string; +}; + export type SelectedModel = ModelOption; export interface ModelInputComponentType { options?: ModelOption[]; placeholder?: string; - externalOptions?: any; + externalOptions?: ExternalOptionsType; /** When true and options are empty, shows "No models enabled" in a clickable dropdown instead of loading state */ showEmptyState?: boolean; + /** Explicitly set the model type filter ("llm" or "embeddings"). Overrides the nodeClass-derived default. */ + modelType?: "llm" | "embeddings"; } diff --git a/src/frontend/src/modals/createMemoryModal/index.tsx b/src/frontend/src/modals/createMemoryModal/index.tsx index e1f72aff7b..332e7bf9bd 100644 --- a/src/frontend/src/modals/createMemoryModal/index.tsx +++ b/src/frontend/src/modals/createMemoryModal/index.tsx @@ -1,4 +1,5 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import ShadTooltip from "@/components/common/shadTooltipComponent"; import ModelInputComponent from "@/components/core/parameterRenderComponent/components/modelInputComponent"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -89,6 +90,7 @@ export default function CreateMemoryModal({ }} options={embeddingModelOptions} placeholder="Select embedding model" + modelType="embeddings" /> {selectedEmbeddingModel[0]?.provider && ( @@ -100,10 +102,25 @@ export default function CreateMemoryModal({
- - - Messages per ingestion batch (min 1) - +
+ + + + +
{selectedPreprocessingModel[0]?.provider && ( 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 4630664662..d241bf5adf 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoriesSidebar.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoriesSidebar.tsx @@ -49,12 +49,14 @@ export function MemoriesSidebar({ Create -

- {(() => { - const count = memories?.length ?? 0; - return `${count} ${count === 1 ? "memory" : "memories"}`; - })()} -

+ {(memories?.length ?? 0) > 0 && ( +

+ {(() => { + const count = memories!.length; + return `${count} ${count === 1 ? "memory" : "memories"}`; + })()} +

+ )}
@@ -66,17 +68,16 @@ export function MemoriesSidebar({
- {!filteredMemories.length ? ( + {!filteredMemories.length && memoriesSearch.trim() && (
-

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

+

No memories found

- ) : ( + )} + {filteredMemories.length > 0 && (
{filteredMemories.map((memoryItem) => { const isSelected = selectedMemoryId === memoryItem.id; 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 2c1f8f698f..93061eb15f 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetailsHeader.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryDetailsHeader.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import IconComponent from "@/components/common/genericIconComponent"; import { Button } from "@/components/ui/button"; import { @@ -7,6 +8,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import DeleteConfirmationModal from "@/modals/deleteConfirmationModal"; +import useAlertStore from "@/stores/alertStore"; +import { extractApiErrorMessages } from "@/utils/apiError"; import type { MemoryDetailsHeaderProps } from "../types"; export function MemoryDetailsHeader({ @@ -21,6 +24,26 @@ export function MemoryDetailsHeader({ hasNextSessionsPage, isFetchingNextSessionsPage, }: MemoryDetailsHeaderProps) { + const setSuccessData = useAlertStore((state) => state.setSuccessData); + const setErrorData = useAlertStore((state) => state.setErrorData); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + if (isRefreshing) return; + setIsRefreshing(true); + try { + await onRefresh(); + setSuccessData({ title: `Memory "${memory.name}" refreshed` }); + } catch (error) { + setErrorData({ + title: "Failed to refresh memory", + list: extractApiErrorMessages(error), + }); + } finally { + setIsRefreshing(false); + } + }; + const effectiveSession = (selectedSession ?? sessions?.[0] ?? "") as | string | null; @@ -57,10 +80,14 @@ export function MemoryDetailsHeader({ {sessions && sessions.length > 0 && ( @@ -95,8 +122,7 @@ export function MemoryDetailsHeader({ { - e.preventDefault(); + onSelect={() => { setSelectedSession(sid); }} > @@ -132,9 +158,8 @@ export function MemoryDetailsHeader({ aria-label="Toggle auto-capture" className="gap-2" > - Auto-capture 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 b2f3607a5f..c452e6f9a2 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryKnowledgeBaseSection.tsx +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/components/MemoryKnowledgeBaseSection.tsx @@ -49,12 +49,16 @@ export function MemoryKnowledgeBaseSection({
) : !docsData?.documents?.length ? ( -
+
-

No chunks yet.

+

No chunks yet

+

+ Head to the Playground, run your flow, and chunks will start + appearing here automatically. +

) : ( 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 83f02d88e3..3cd5f986ef 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,8 +1,26 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import type { MemoryInfo } from "@/controllers/API/queries/memories/types"; import type { MemoryDetailsHeaderProps } from "../../types"; import { MemoryDetailsHeader } from "../MemoryDetailsHeader"; +const mockSetSuccessData = jest.fn(); +const mockSetErrorData = jest.fn(); + +jest.mock("@/stores/alertStore", () => ({ + __esModule: true, + default: (selector: (s: unknown) => unknown) => + selector({ + setSuccessData: mockSetSuccessData, + setErrorData: mockSetErrorData, + }), +})); + jest.mock("@/components/common/genericIconComponent", () => ({ __esModule: true, default: ({ name }: { name: string }) => {name}, @@ -59,6 +77,10 @@ jest.mock("@/modals/deleteConfirmationModal", () => ({ ), })); +beforeEach(() => { + jest.clearAllMocks(); +}); + describe("MemoryDetailsHeader", () => { const makeProps = (overrides?: Partial) => { const memory: MemoryInfo = { @@ -278,4 +300,89 @@ describe("MemoryDetailsHeader", () => { expect(scrollDiv).not.toBeNull(); expect(scrollDiv).toHaveTextContent("Loading"); }); + + describe("handleRefresh", () => { + it("disables the refresh button while refreshing", async () => { + let resolve!: () => void; + const onRefresh = jest.fn( + () => + new Promise((res) => { + resolve = res; + }), + ); + const props = makeProps({ onRefresh }); + render(); + + const btn = screen.getByRole("button", { + name: "Reload sessions and messages", + }); + fireEvent.click(btn); + expect(btn).toBeDisabled(); + + await act(async () => { + resolve(); + }); + }); + + it("shows success toast after onRefresh resolves", async () => { + const onRefresh = jest.fn().mockResolvedValue(undefined); + const props = makeProps({ onRefresh }); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Reload sessions and messages" }), + ); + + await waitFor(() => { + expect(mockSetSuccessData).toHaveBeenCalledWith({ + title: `Memory "Memory One" refreshed`, + }); + }); + }); + + it("does not show success toast when onRefresh rejects", async () => { + const onRefresh = jest.fn().mockRejectedValue(new Error("network")); + const props = makeProps({ onRefresh }); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Reload sessions and messages" }), + ); + + await waitFor(() => { + expect(mockSetErrorData).toHaveBeenCalled(); + }); + expect(mockSetSuccessData).not.toHaveBeenCalled(); + }); + + it("shows error toast with api message when onRefresh rejects", async () => { + const onRefresh = jest.fn().mockRejectedValue(new Error("timeout")); + const props = makeProps({ onRefresh }); + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Reload sessions and messages" }), + ); + + await waitFor(() => { + expect(mockSetErrorData).toHaveBeenCalledWith({ + title: "Failed to refresh memory", + list: ["timeout"], + }); + }); + }); + + it("re-enables the refresh button after onRefresh rejects", async () => { + const onRefresh = jest.fn().mockRejectedValue(new Error("fail")); + const props = makeProps({ onRefresh }); + render(); + + const btn = screen.getByRole("button", { + name: "Reload sessions and messages", + }); + fireEvent.click(btn); + + await waitFor(() => expect(btn).not.toBeDisabled()); + }); + }); }); 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 d905ff203b..5a2e7ff9b1 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 @@ -63,7 +63,7 @@ describe("MemoryKnowledgeBaseSection", () => { render(); - expect(screen.getByText("No chunks yet.")).toBeInTheDocument(); + expect(screen.getByText("No chunks yet")).toBeInTheDocument(); }); it("opens document panel when row is clicked", () => { diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useAutoCaptureDebouncedToggle.test.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useAutoCaptureDebouncedToggle.test.ts new file mode 100644 index 0000000000..a8032ded8a --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/__tests__/useAutoCaptureDebouncedToggle.test.ts @@ -0,0 +1,320 @@ +import { act, renderHook } from "@testing-library/react"; +import type { MemoryInfo } from "@/controllers/API/queries/memories/types"; +import { useAutoCaptureDebouncedToggle } from "../useAutoCaptureDebouncedToggle"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockSetSuccessData = jest.fn(); +const mockSetErrorData = jest.fn(); + +jest.mock("@/stores/alertStore", () => ({ + __esModule: true, + default: (selector: (s: unknown) => unknown) => + selector({ + setSuccessData: mockSetSuccessData, + setErrorData: mockSetErrorData, + }), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMemory(overrides: Partial = {}): MemoryInfo { + return { + id: "m1", + name: "Test Memory", + kb_name: "", + embedding_model: "", + embedding_provider: "", + is_active: false, + total_messages_processed: 0, + sessions_count: 0, + batch_size: 1, + preprocessing_enabled: false, + pending_messages_count: 0, + user_id: "u1", + flow_id: "flow-1", + ...overrides, + }; +} + +function makeProps( + memory: MemoryInfo | undefined, + mutate = jest.fn(), + debounceMs = 0, +) { + return { + memory, + updateMemoryMutation: { mutate }, + debounceMs, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe("useAutoCaptureDebouncedToggle", () => { + describe("initial state", () => { + it("returns null draft on mount", () => { + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(makeMemory())), + ); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + }); + + describe("handleToggleActive — no-ops", () => { + it("does nothing when memory is undefined", () => { + const mutate = jest.fn(); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(undefined, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + jest.runAllTimers(); + expect(mutate).not.toHaveBeenCalled(); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + + it("does nothing when toggling to the same value as current state", () => { + const mutate = jest.fn(); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(false)); + jest.runAllTimers(); + expect(mutate).not.toHaveBeenCalled(); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + }); + + describe("handleToggleActive — optimistic draft", () => { + it("sets autoCaptureDraft immediately before debounce fires", () => { + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, jest.fn(), 500)), + ); + act(() => result.current.handleToggleActive(true)); + expect(result.current.autoCaptureDraft).toBe(true); + }); + + it("accepts an updater function and derives next value from current state", () => { + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, jest.fn(), 500)), + ); + act(() => result.current.handleToggleActive((prev) => !prev)); + expect(result.current.autoCaptureDraft).toBe(true); + }); + }); + + describe("handleToggleActive — debounce and mutation", () => { + it("calls mutate with correct args after debounce", () => { + const mutate = jest.fn(); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + act(() => jest.runAllTimers()); + expect(mutate).toHaveBeenCalledWith( + { memoryId: "m1", auto_capture: true }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + + it("debounces rapid successive toggles — only one mutate call", () => { + const mutate = jest.fn(); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate, 200)), + ); + act(() => { + result.current.handleToggleActive(true); + jest.advanceTimersByTime(100); + result.current.handleToggleActive(true); + }); + act(() => jest.runAllTimers()); + expect(mutate).toHaveBeenCalledTimes(1); + }); + + it("cancels pending mutation when toggling back to committed value", () => { + const mutate = jest.fn(); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate, 200)), + ); + act(() => { + result.current.handleToggleActive(true); + jest.advanceTimersByTime(100); + // toggle back before debounce fires + result.current.handleToggleActive(false); + }); + act(() => jest.runAllTimers()); + expect(mutate).not.toHaveBeenCalled(); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + }); + + describe("toast notifications", () => { + it("shows success toast with memory name when enabled", () => { + const mutate = jest.fn((_, opts) => opts?.onSuccess?.()); + const memory = makeMemory({ is_active: false, name: "My Memory" }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + act(() => jest.runAllTimers()); + expect(mockSetSuccessData).toHaveBeenCalledWith({ + title: 'Auto-capture enabled for memory "My Memory"', + }); + }); + + it("shows success toast with memory name when disabled", () => { + const mutate = jest.fn((_, opts) => opts?.onSuccess?.()); + const memory = makeMemory({ is_active: true, name: "My Memory" }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(false)); + act(() => jest.runAllTimers()); + expect(mockSetSuccessData).toHaveBeenCalledWith({ + title: 'Auto-capture disabled for memory "My Memory"', + }); + }); + + it("shows error toast when mutation fails", () => { + const mutate = jest.fn((_, opts) => + opts?.onError?.(new Error("api error")), + ); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + act(() => jest.runAllTimers()); + expect(mockSetErrorData).toHaveBeenCalledWith({ + title: "Failed to update auto-capture", + list: ["api error"], + }); + }); + + it("shows exactly one error toast on failure — no duplicate from mutation level", () => { + const mutate = jest.fn((_, opts) => + opts?.onError?.(new Error("api error")), + ); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + act(() => jest.runAllTimers()); + expect(mockSetErrorData).toHaveBeenCalledTimes(1); + }); + }); + + describe("draft cleanup", () => { + it("clears draft on mutation success", () => { + const mutate = jest.fn((_, opts) => opts?.onSuccess?.()); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + act(() => jest.runAllTimers()); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + + it("clears draft on mutation error", () => { + const mutate = jest.fn((_, opts) => + opts?.onError?.(new Error("api error")), + ); + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + act(() => result.current.handleToggleActive(true)); + act(() => jest.runAllTimers()); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + + it("snaps back to original memory.is_active after mutation failure", () => { + const mutate = jest.fn((_, opts) => + opts?.onError?.(new Error("api error")), + ); + // Start with is_active = false, toggle to true, then fail + const memory = makeMemory({ is_active: false }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + + // Draft goes to true optimistically + act(() => result.current.handleToggleActive(true)); + expect(result.current.autoCaptureDraft).toBe(true); + + // Mutation fires and fails — draft must clear, reverting to original false + act(() => jest.runAllTimers()); + expect(result.current.autoCaptureDraft).toBeNull(); + // null draft means the UI falls back to memory.is_active (false) — original value restored + }); + + it("snaps back correctly when toggling an active memory to inactive and failing", () => { + const mutate = jest.fn((_, opts) => + opts?.onError?.(new Error("api error")), + ); + const memory = makeMemory({ is_active: true }); + const { result } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate)), + ); + + act(() => result.current.handleToggleActive(false)); + expect(result.current.autoCaptureDraft).toBe(false); + + act(() => jest.runAllTimers()); + expect(result.current.autoCaptureDraft).toBeNull(); + // null draft means the UI falls back to memory.is_active (true) — original value restored + }); + + it("resets all state when memory id changes", () => { + const mutate = jest.fn(); + const memory = makeMemory({ is_active: false }); + const { result, rerender } = renderHook( + (props) => useAutoCaptureDebouncedToggle(props), + { initialProps: makeProps(memory, mutate, 500) }, + ); + act(() => result.current.handleToggleActive(true)); + expect(result.current.autoCaptureDraft).toBe(true); + + rerender(makeProps(makeMemory({ id: "m2", name: "Other" }), mutate, 500)); + expect(result.current.autoCaptureDraft).toBeNull(); + }); + + it("cancels pending timer on unmount", () => { + const mutate = jest.fn(); + const memory = makeMemory({ is_active: false }); + const { result, unmount } = renderHook(() => + useAutoCaptureDebouncedToggle(makeProps(memory, mutate, 500)), + ); + act(() => result.current.handleToggleActive(true)); + unmount(); + act(() => jest.runAllTimers()); + expect(mutate).not.toHaveBeenCalled(); + }); + }); +}); 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 22aff71484..ac7bd6e48d 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useAutoCaptureDebouncedToggle.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useAutoCaptureDebouncedToggle.ts @@ -1,14 +1,16 @@ import { useEffect, useRef, useState } from "react"; -import { AUTO_CAPTURE_DEBOUNCE_MS } from "../MemoriesMainContent.constants"; import type { MemoryInfo, UpdateMemoryParams, } from "@/controllers/API/queries/memories/types"; +import useAlertStore from "@/stores/alertStore"; +import { extractApiErrorMessages } from "@/utils/apiError"; +import { AUTO_CAPTURE_DEBOUNCE_MS } from "../MemoriesMainContent.constants"; type UpdateMemoryMutation = { mutate: ( variables: UpdateMemoryParams, - options?: { onSuccess?: () => void; onError?: () => void }, + options?: { onSuccess?: () => void; onError?: (error: unknown) => void }, ) => void; }; @@ -25,6 +27,9 @@ export const useAutoCaptureDebouncedToggle = ({ updateMemoryMutation, debounceMs = AUTO_CAPTURE_DEBOUNCE_MS, }: UseAutoCaptureDebouncedToggleArgs) => { + const setSuccessData = useAlertStore((state) => state.setSuccessData); + const setErrorData = useAlertStore((state) => state.setErrorData); + const autoCaptureTimerRef = useRef | null>( null, ); @@ -92,9 +97,12 @@ export const useAutoCaptureDebouncedToggle = ({ autoCaptureTimerRef.current = null; } + const capturedId = memory.id; + const capturedIsActive = memory.is_active; + autoCaptureTimerRef.current = setTimeout(() => { // If the committed value already matches, skip a no-op update. - if ((committedIsActiveRef.current ?? memory.is_active) === nextIsActive) { + if ((committedIsActiveRef.current ?? capturedIsActive) === nextIsActive) { setAutoCaptureDraft(null); draftIsActiveRef.current = null; autoCaptureTimerRef.current = null; @@ -106,15 +114,29 @@ export const useAutoCaptureDebouncedToggle = ({ draftIsActiveRef.current = null; }; + const currentName = memory?.name ?? capturedId; + updateMemoryMutation.mutate( { - memoryId: memory.id, + memoryId: capturedId, auto_capture: nextIsActive, }, { - onSuccess: clearDraft, - // On failure the draft never resolved — reset so UI reflects server state. - onError: clearDraft, + onSuccess: () => { + clearDraft(); + setSuccessData({ + title: nextIsActive + ? `Auto-capture enabled for memory "${currentName}"` + : `Auto-capture disabled for memory "${currentName}"`, + }); + }, + onError: (error: unknown) => { + clearDraft(); + setErrorData({ + title: "Failed to update auto-capture", + list: extractApiErrorMessages(error), + }); + }, }, ); 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 8bf4d99e20..c3c96f32ff 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoriesData.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/hooks/useMemoriesData.ts @@ -8,10 +8,10 @@ import { useGetMemories } from "@/controllers/API/queries/memories/use-get-memor 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 { extractApiErrorMessages } from "@/utils/apiError"; import { UseMemoriesDataProps } from "../types"; import { useAutoCaptureDebouncedToggle } from "./useAutoCaptureDebouncedToggle"; import { useMemoryDocuments } from "./useMemoryDocuments"; -import { extractApiErrorMessages } from "@/utils/apiError"; import { useMemorySessionResolver } from "./useMemorySessionResolver"; const EMPTY_MEMORIES: MemoryInfo[] = []; @@ -109,13 +109,7 @@ export function useMemoriesData({ }), }); - const updateMemoryMutation = useUpdateMemory({ - onError: (error: unknown) => - setErrorData({ - title: "Failed to update memory", - list: extractApiErrorMessages(error), - }), - }); + const updateMemoryMutation = useUpdateMemory(); const { autoCaptureDraft, handleToggleActive } = useAutoCaptureDebouncedToggle({ @@ -166,10 +160,12 @@ export function useMemoriesData({ return nextMemory; }, [memory, autoCaptureDraft, effectiveSessionId, memorySessions]); - const onRefresh = useCallback(() => { - refetchMemories(); - refetchMemorySessions(); - refetchMessages(); + const onRefresh = useCallback(async () => { + await Promise.all([ + refetchMemories(), + refetchMemorySessions(), + refetchMessages(), + ]); }, [refetchMemories, refetchMemorySessions, refetchMessages]); const handleOpenDocumentPanel = (doc: MemoryDocumentItem) => { diff --git a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts index c3bd68c46c..c5a7f8d639 100644 --- a/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts +++ b/src/frontend/src/pages/FlowPage/components/MemoriesMainContent/types.ts @@ -39,7 +39,7 @@ export type MemoryDetailsProps = { handleOpenDocumentPanel: (doc: MemoryDocumentItem) => void; deleteMutation: MemoryActionMutation; handleToggleActive: (nextIsActive: NextIsActive) => void; - onRefresh: () => void; + onRefresh: () => Promise; fetchNextSessionsPage: () => void; hasNextSessionsPage?: boolean; isFetchingNextSessionsPage?: boolean; @@ -86,7 +86,7 @@ export type MemoryDetailsHeaderProps = { setSelectedSession: (value: string | null) => void; deleteMutation: MemoryActionMutation; handleToggleActive: (nextIsActive: NextIsActive) => void; - onRefresh: () => void; + onRefresh: () => Promise; fetchNextSessionsPage: () => void; hasNextSessionsPage?: boolean; isFetchingNextSessionsPage?: boolean;