mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 13:52:34 +08:00
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:
@ -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
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user