feat(memory): improve memory UI with empty states, toasts, and model (#13116)

* feat(memory): improve memory UI with empty states, toasts, and model filtering

* [autofix.ci] apply automated fixes

* fix: capture memory id/name before setTimeout to prevent stale closure null access

* error improvement and testcase addition

* fix: surface refresh errors, restore API error messages, flatten sidebar ternary, and add keyboard access to batch-size tooltip

* [autofix.ci] apply automated fixes

* biome fix

* fix types

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Olayinka Adelakun <olayinkaadelakun@mac.war.can.ibm.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local>
This commit is contained in:
olayinkaadelakun
2026-05-19 11:33:51 -07:00
committed by GitHub
parent bc656b44e8
commit bd54c1b952
12 changed files with 555 additions and 51 deletions

View File

@ -45,6 +45,7 @@ export default function ModelInputComponent({
editNode,
inspectionPanel,
showEmptyState = false,
modelType: modelTypeProp,
}: BaseInputProps<ModelOption[] | undefined> &
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

View File

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

View File

@ -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"
/>
</div>
{selectedEmbeddingModel[0]?.provider && (
@ -100,10 +102,25 @@ export default function CreateMemoryModal({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="memory-batch-size">Batch Size</Label>
<span className="text-xs text-muted-foreground">
Messages per ingestion batch (min 1)
</span>
<div className="flex items-center gap-1.5">
<Label htmlFor="memory-batch-size">Batch Size</Label>
<ShadTooltip
content="Number of messages to accumulate before syncing to memory. Use 1 to sync after every message, or a higher value to reduce ingestion frequency and group related context together."
side="right"
>
<button
type="button"
tabIndex={0}
aria-label="Batch size help"
className="cursor-help rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<ForwardedIconComponent
name="Info"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</button>
</ShadTooltip>
</div>
<Input
id="memory-batch-size"
value={batchSizeInput}
@ -154,6 +171,7 @@ export default function CreateMemoryModal({
}}
options={llmModelOptions}
placeholder="Select preprocessing model"
modelType="llm"
/>
</div>
{selectedPreprocessingModel[0]?.provider && (

View File

@ -49,12 +49,14 @@ export function MemoriesSidebar({
Create
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{(() => {
const count = memories?.length ?? 0;
return `${count} ${count === 1 ? "memory" : "memories"}`;
})()}
</p>
{(memories?.length ?? 0) > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{(() => {
const count = memories!.length;
return `${count} ${count === 1 ? "memory" : "memories"}`;
})()}
</p>
)}
</div>
<div className="p-4">
@ -66,17 +68,16 @@ export function MemoriesSidebar({
</div>
<div className="flex-1 overflow-auto px-2 pb-4" onScroll={handleScroll}>
{!filteredMemories.length ? (
{!filteredMemories.length && memoriesSearch.trim() && (
<div className="px-3 py-6 text-center">
<IconComponent
name="BrainCog"
className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-50"
/>
<p className="text-xs text-muted-foreground">
{memoriesSearch.trim() ? "No memories found" : "No memories yet"}
</p>
<p className="text-xs text-muted-foreground">No memories found</p>
</div>
) : (
)}
{filteredMemories.length > 0 && (
<div className="flex flex-col gap-1">
{filteredMemories.map((memoryItem) => {
const isSelected = selectedMemoryId === memoryItem.id;

View File

@ -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({
<Button
variant="outline"
size="sm"
onClick={onRefresh}
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="Reload sessions and messages"
>
<IconComponent name="RefreshCw" className="h-4 w-4" />
<IconComponent
name="RefreshCw"
className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
</Button>
{sessions && sessions.length > 0 && (
@ -95,8 +122,7 @@ export function MemoryDetailsHeader({
<DropdownMenuItem
key={sid}
className="flex items-center justify-between"
onSelect={(e) => {
e.preventDefault();
onSelect={() => {
setSelectedSession(sid);
}}
>
@ -132,9 +158,8 @@ export function MemoryDetailsHeader({
aria-label="Toggle auto-capture"
className="gap-2"
>
<IconComponent
name={memory.is_active ? "ToggleRight" : "ToggleLeft"}
className="h-4 w-4"
<span
className={`h-2 w-2 shrink-0 rounded-full ${memory.is_active ? "bg-accent-emerald-foreground" : "bg-muted-foreground"}`}
/>
Auto-capture
</Button>

View File

@ -49,12 +49,16 @@ export function MemoryKnowledgeBaseSection({
<Loading size={32} className="text-primary" />
</div>
) : !docsData?.documents?.length ? (
<div className="flex h-32 flex-col items-center justify-center text-center">
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<IconComponent
name="Database"
className="mb-2 h-8 w-8 text-muted-foreground opacity-50"
className="h-8 w-8 text-muted-foreground opacity-50"
/>
<p className="text-xs font-medium">No chunks yet.</p>
<p className="text-sm font-medium text-foreground">No chunks yet</p>
<p className="max-w-xs text-xs text-muted-foreground">
Head to the Playground, run your flow, and chunks will start
appearing here automatically.
</p>
</div>
) : (
<Table>

View File

@ -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 }) => <span>{name}</span>,
@ -59,6 +77,10 @@ jest.mock("@/modals/deleteConfirmationModal", () => ({
),
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe("MemoryDetailsHeader", () => {
const makeProps = (overrides?: Partial<MemoryDetailsHeaderProps>) => {
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<void>((res) => {
resolve = res;
}),
);
const props = makeProps({ onRefresh });
render(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
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(<MemoryDetailsHeader {...props} />);
const btn = screen.getByRole("button", {
name: "Reload sessions and messages",
});
fireEvent.click(btn);
await waitFor(() => expect(btn).not.toBeDisabled());
});
});
});

View File

@ -63,7 +63,7 @@ describe("MemoryKnowledgeBaseSection", () => {
render(<MemoryKnowledgeBaseSection {...props} />);
expect(screen.getByText("No chunks yet.")).toBeInTheDocument();
expect(screen.getByText("No chunks yet")).toBeInTheDocument();
});
it("opens document panel when row is clicked", () => {

View File

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

View File

@ -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<ReturnType<typeof setTimeout> | 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;

View File

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

View File

@ -39,7 +39,7 @@ export type MemoryDetailsProps = {
handleOpenDocumentPanel: (doc: MemoryDocumentItem) => void;
deleteMutation: MemoryActionMutation;
handleToggleActive: (nextIsActive: NextIsActive) => void;
onRefresh: () => void;
onRefresh: () => Promise<void>;
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<void>;
fetchNextSessionsPage: () => void;
hasNextSessionsPage?: boolean;
isFetchingNextSessionsPage?: boolean;