mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 11:13:10 +08:00
feat(memories): redesign MemoryDetailsHeader UI and update tests (#13263)
* 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 * fix: UI polish for Memories and Traces sidebar sections * [autofix.ci] apply automated fixes * feat(memories): surface memory config in summary card with popover details * fix: replace magic index, template literals, and inline noops in Memories/Traces sidebar * [autofix.ci] apply automated fixes * ruff fix * fix testcases * improve testcase cov import clean up * [autofix.ci] apply automated fixes * fix import order * accessibility fix * [autofix.ci] apply automated fixes * feat(memories): make preprocessing instructions required with tooltip hint * ruff fix * [autofix.ci] apply automated fixes * feat(memories): redesign MemoryDetailsHeader UI and update tests * [autofix.ci] apply automated fixes * ruff 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:
@ -1555,7 +1555,6 @@
|
||||
"memory.noChunksRunFlow": "Run a flow, and chunks will start appearing automatically.",
|
||||
"memory.noId": "(no id)",
|
||||
"memory.preprocessing": "Preprocessing:",
|
||||
"memory.preprocessingPromptPlaceholder": "Produce a concise summary that captures key facts and context.",
|
||||
"memory.processedMessages": "Processed Messages",
|
||||
"memory.providerLabel": "Provider:",
|
||||
"memory.reloadSessions": "Reload sessions and messages",
|
||||
@ -1566,6 +1565,7 @@
|
||||
"memory.preprocessingInstructions": "Preprocessing instructions:",
|
||||
"memory.preprocessingInstructionsHint": "Summarize the key context, facts, and user preferences from this chat to remember for future conversations, skipping the casual chitchat.",
|
||||
"memory.senderLabel": "Sender:",
|
||||
"memory.activate": "Activate",
|
||||
"memory.sessionFilter": "Session filter",
|
||||
"memory.sessionLabel": "Session:",
|
||||
"memory.timestampLabel": "Timestamp:",
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { UIEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SIDEBAR_SCROLL_THRESHOLD_PX } from "../MemoriesMainContent.constants";
|
||||
import { MemoriesSidebarProps } from "../types";
|
||||
|
||||
@ -32,24 +32,23 @@ export function MemoriesSidebar({
|
||||
return (
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-border bg-background">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
name="BrainCog"
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<h2 className="text-sm font-semibold">Memories</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCreateMemory}
|
||||
disabled={!currentFlowId}
|
||||
>
|
||||
<IconComponent name="Plus" className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
name="BrainCog"
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<h2 className="text-sm font-semibold">Memories</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCreateMemory}
|
||||
disabled={!currentFlowId}
|
||||
className="mt-3 rounded-[10px]"
|
||||
>
|
||||
<IconComponent name="Plus" className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import IconComponent from "@/components/common/genericIconComponent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -7,8 +8,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { extractApiErrorMessages } from "@/utils/apiError";
|
||||
import { cn } from "@/utils/utils";
|
||||
@ -47,10 +48,6 @@ export function MemoryDetailsHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveSession = (selectedSession ?? sessions?.[0] ?? "") as
|
||||
| string
|
||||
| null;
|
||||
|
||||
const handleSessionsScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (
|
||||
@ -62,106 +59,91 @@ export function MemoryDetailsHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const sessionLabel = selectedSession ?? "All Sessions";
|
||||
|
||||
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">
|
||||
<IconComponent
|
||||
name="BrainCog"
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{memory.name}</h2>
|
||||
{memory.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{memory.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-end justify-between border-b border-border bg-background px-6 py-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold">{memory.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("memory.activate")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={memory.is_active}
|
||||
onCheckedChange={(checked) => handleToggleActive(checked)}
|
||||
aria-label={t("memory.toggleAutoCapture")}
|
||||
className="data-[state=checked]:bg-accent-emerald-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{sessions && sessions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t("memory.sessionFilter")}
|
||||
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={() => {
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("memory.sessionLabel")}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={t("memory.sessionFilter")}
|
||||
className="w-[180px] justify-between rounded-[10px] px-3"
|
||||
>
|
||||
<span className="truncate">
|
||||
{sessionLabel.length > 20
|
||||
? `${sessionLabel.slice(0, 20)}...`
|
||||
: sessionLabel}
|
||||
</span>
|
||||
<IconComponent
|
||||
name="ChevronDown"
|
||||
className="ml-2 h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[180px] p-0">
|
||||
<div
|
||||
className="max-h-[240px] overflow-y-auto py-1"
|
||||
onScroll={handleSessionsScroll}
|
||||
>
|
||||
{sessions.map((sid) => {
|
||||
const isSelected = sid === selectedSession;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sid}
|
||||
className="flex items-center justify-between"
|
||||
onSelect={() => 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={memory.is_active ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleToggleActive((prevIsActive) => !prevIsActive)}
|
||||
aria-pressed={memory.is_active}
|
||||
aria-label={t("memory.toggleAutoCapture")}
|
||||
className="gap-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
memory.is_active
|
||||
? "bg-accent-emerald-foreground"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
Auto-capture
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("memory.reloadSessions")}
|
||||
@ -181,12 +163,12 @@ export function MemoryDetailsHeader({
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="Delete memory"
|
||||
>
|
||||
<IconComponent name="Trash2" className="h-4 w-4" />
|
||||
Delete
|
||||
<IconComponent name="Trash2" className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</DeleteConfirmationModal>
|
||||
</div>
|
||||
|
||||
@ -26,6 +26,25 @@ jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
default: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
@ -121,9 +140,10 @@ describe("MemoryDetailsHeader", () => {
|
||||
});
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
expect(screen.getByText("Memory One")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Toggle auto-capture" }),
|
||||
).toHaveTextContent("Auto-capture");
|
||||
expect(screen.getByText("Activate")).toBeInTheDocument();
|
||||
const toggle = screen.getByRole("switch", { name: "Toggle auto-capture" });
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("calls mutate handlers for actions", () => {
|
||||
@ -137,16 +157,15 @@ describe("MemoryDetailsHeader", () => {
|
||||
});
|
||||
|
||||
it("toggles auto-capture", () => {
|
||||
const props = makeProps();
|
||||
const props = makeProps({
|
||||
memory: { ...makeProps().memory, is_active: true },
|
||||
});
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Toggle auto-capture" }),
|
||||
screen.getByRole("switch", { name: "Toggle auto-capture" }),
|
||||
);
|
||||
|
||||
const firstCallArg = (props.handleToggleActive as jest.Mock).mock
|
||||
.calls[0]?.[0];
|
||||
expect(firstCallArg).toEqual(expect.any(Function));
|
||||
expect(firstCallArg(true)).toBe(false);
|
||||
expect(props.handleToggleActive).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("renders the session selector when sessions exist", () => {
|
||||
@ -160,7 +179,7 @@ describe("MemoryDetailsHeader", () => {
|
||||
expect(sessionTrigger).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables the session selector when only one session and no more pages", () => {
|
||||
it("always enables the session selector regardless of session count", () => {
|
||||
const props = makeProps({
|
||||
sessions: ["session-1"],
|
||||
hasNextSessionsPage: false,
|
||||
@ -168,7 +187,7 @@ describe("MemoryDetailsHeader", () => {
|
||||
render(<MemoryDetailsHeader {...props} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Session filter" }),
|
||||
).toBeDisabled();
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders the reload button with correct aria-label", () => {
|
||||
|
||||
Reference in New Issue
Block a user