Merge pull request #34366 from BerriAI/litellm_/migrate-page-memory-9b2c09

refactor(ui): migrate memory page to shadcn
This commit is contained in:
yuneng-jiang 2026-07-23 23:39:03 -07:00 • committed by GitHub
commit bd753aecf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 212 additions and 115 deletions

View file

@ -1109,21 +1109,11 @@
"count": 1
}
},
"src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": {
"react/display-name": {
"count": 1

View file

@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { MemoryRow } from "@/components/networking";
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
memory_id: "mem-1",
key: "user:profile",
value: "The user prefers concise answers.",
metadata: null,
user_id: "user-42",
team_id: "team-7",
created_at: "2024-05-01T12:00:00Z",
updated_at: "2024-05-02T12:00:00Z",
created_by: "alice",
updated_by: "bob",
...overrides,
});
describe("MemoryDetailDrawer", () => {
it("renders nothing until a row is selected", () => {
render(<MemoryDetailDrawer row={null} onClose={vi.fn()} />);
expect(screen.queryByText("Memory ID")).not.toBeInTheDocument();
expect(screen.queryByText("Value")).not.toBeInTheDocument();
});
it("shows the selected row's key, identifiers and value", () => {
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
expect(screen.getByText("user:profile")).toBeInTheDocument();
expect(screen.getByText("Memory ID")).toBeInTheDocument();
expect(screen.getByText("mem-1")).toBeInTheDocument();
expect(screen.getByText("User ID")).toBeInTheDocument();
expect(screen.getByText("user-42")).toBeInTheDocument();
expect(screen.getByText("Team ID")).toBeInTheDocument();
expect(screen.getByText("team-7")).toBeInTheDocument();
expect(screen.getByText("Value")).toBeInTheDocument();
expect(screen.getByText("The user prefers concise answers.")).toBeInTheDocument();
});
it("falls back to a dash for a memory with no owning user or team", () => {
render(<MemoryDetailDrawer row={makeMemory({ user_id: null, team_id: null })} onClose={vi.fn()} />);
expect(screen.getAllByText("-")).toHaveLength(2);
expect(screen.queryByText("user-42")).not.toBeInTheDocument();
});
it("omits the metadata block when the row carries no metadata", () => {
render(<MemoryDetailDrawer row={makeMemory({ metadata: null })} onClose={vi.fn()} />);
expect(screen.queryByText("Metadata")).not.toBeInTheDocument();
});
it("pretty-prints metadata as JSON when present", () => {
render(<MemoryDetailDrawer row={makeMemory({ metadata: { tags: ["example"] } })} onClose={vi.fn()} />);
expect(screen.getByText("Metadata")).toBeInTheDocument();
expect(screen.getByText('{ "tags": [ "example" ] }')).toBeInTheDocument();
});
it("attributes the created and updated timestamps to their actors", () => {
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument();
expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument();
});
it("renders an em dash for a timestamp the backend did not send", () => {
render(<MemoryDetailDrawer row={makeMemory({ created_at: undefined, created_by: undefined })} onClose={vi.fn()} />);
expect(screen.getByText("Created —")).toBeInTheDocument();
});
it("closes through the close control", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<MemoryDetailDrawer row={makeMemory()} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,17 +1,19 @@
"use client";
import { Drawer, Space, Typography } from "antd";
import React from "react";
import { MemoryRow } from "@/components/networking";
const { Text, Paragraph } = Typography;
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
interface MemoryDetailDrawerProps {
row: MemoryRow | null;
onClose: () => void;
}
const CODE_CLASS = "rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground";
const BLOCK_CLASS = "mt-1 rounded-md bg-muted p-3 font-mono whitespace-pre-wrap text-foreground";
const LABEL_CLASS = "text-sm font-semibold text-foreground";
function formatTimestamp(ts?: string): string {
if (!ts) return "—";
try {
@ -24,90 +26,61 @@ function formatTimestamp(ts?: string): string {
export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
return (
<Drawer
<Sheet
open={!!row}
onClose={onClose}
title={
row ? (
<Space>
<Text code>{row.key}</Text>
</Space>
) : (
"Memory"
)
}
width={720}
destroyOnClose
onOpenChange={(open) => {
if (!open) onClose();
}}
>
{row && (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space size="large" wrap>
<div>
<Text strong style={{ display: "block" }}>
Memory ID
</Text>
<Text code style={{ fontSize: 12 }}>
{row.memory_id}
</Text>
<SheetContent className="overflow-y-auto data-[side=right]:w-full data-[side=right]:max-w-full data-[side=right]:sm:w-[720px] data-[side=right]:sm:max-w-full">
<SheetHeader className="border-b">
<SheetTitle>{row ? <code className={CODE_CLASS}>{row.key}</code> : "Memory"}</SheetTitle>
</SheetHeader>
{row && (
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-wrap gap-x-8 gap-y-3">
<div>
<span className={`block ${LABEL_CLASS}`}>Memory ID</span>
<code className={CODE_CLASS}>{row.memory_id}</code>
</div>
<div>
<span className={`block ${LABEL_CLASS}`}>User ID</span>
<span className={row.user_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
{row.user_id ?? "-"}
</span>
</div>
<div>
<span className={`block ${LABEL_CLASS}`}>Team ID</span>
<span className={row.team_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
{row.team_id ?? "-"}
</span>
</div>
</div>
<div>
<Text strong style={{ display: "block" }}>
User ID
</Text>
<Text type={row.user_id ? undefined : "secondary"}>{row.user_id ?? "-"}</Text>
<span className={LABEL_CLASS}>Value</span>
<p className={`${BLOCK_CLASS} text-[13px]`}>{row.value}</p>
</div>
<div>
<Text strong style={{ display: "block" }}>
Team ID
</Text>
<Text type={row.team_id ? undefined : "secondary"}>{row.team_id ?? "-"}</Text>
{row.metadata !== undefined && row.metadata !== null && (
<div>
<span className={LABEL_CLASS}>Metadata</span>
<p className={`${BLOCK_CLASS} text-xs`}>{JSON.stringify(row.metadata, null, 2)}</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
Created {formatTimestamp(row.created_at)}
{row.created_by ? ` by ${row.created_by}` : ""}
</span>
<span aria-hidden="true">·</span>
<span>
Updated {formatTimestamp(row.updated_at)}
{row.updated_by ? ` by ${row.updated_by}` : ""}
</span>
</div>
</Space>
<div>
<Text strong>Value</Text>
<Paragraph
style={{
background: "#fafafa",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 13,
}}
>
{row.value}
</Paragraph>
</div>
{row.metadata !== undefined && row.metadata !== null && (
<div>
<Text strong>Metadata</Text>
<Paragraph
style={{
background: "#fafafa",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
}}
>
{JSON.stringify(row.metadata, null, 2)}
</Paragraph>
</div>
)}
<Space split={<Text type="secondary">·</Text>} wrap size="small" style={{ color: "rgba(0,0,0,0.45)" }}>
<Text type="secondary">
Created {formatTimestamp(row.created_at)}
{row.created_by ? ` by ${row.created_by}` : ""}
</Text>
<Text type="secondary">
Updated {formatTimestamp(row.updated_at)}
{row.updated_by ? ` by ${row.updated_by}` : ""}
</Text>
</Space>
</Space>
)}
</Drawer>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
@ -12,6 +13,7 @@ interface CapturedTableProps {
rowCount: number;
data: MemoryRow[];
hasActiveSearch: boolean;
onViewClick: (row: MemoryRow) => void;
}
const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null }));
@ -42,4 +44,47 @@ describe("MemoryView", () => {
expect(captured.current?.rowCount).toBe(0);
expect(captured.current?.hasActiveSearch).toBe(false);
});
it("heads the page with the Memory title and the /v1/memory scope note", () => {
renderView(null);
expect(screen.getByRole("heading", { name: "Memory" })).toBeInTheDocument();
expect(screen.getByText("/v1/memory")).toBeInTheDocument();
expect(screen.getByText(/Scoped to memories visible to your user \/ team \(admins see all\)/)).toBeInTheDocument();
});
it("opens the create modal from the New memory button", async () => {
const user = userEvent.setup();
renderView(null);
expect(screen.queryByText("Create memory")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /new memory/i }));
expect(await screen.findByText("Create memory")).toBeInTheDocument();
});
it("opens the detail drawer for the row the table hands back, and closes it again", async () => {
const user = userEvent.setup();
renderView(null);
expect(screen.queryByText("Memory ID")).not.toBeInTheDocument();
const row: MemoryRow = {
memory_id: "mem-drawer",
key: "user:profile",
value: "remembered",
metadata: null,
user_id: null,
team_id: null,
};
act(() => captured.current?.onViewClick(row));
expect(await screen.findByText("Memory ID")).toBeInTheDocument();
expect(screen.getByText("mem-drawer")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByText("mem-drawer")).not.toBeInTheDocument();
});
});

View file

@ -3,20 +3,19 @@
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import { PlusOutlined } from "@ant-design/icons";
import { Button, Space, Typography, message } from "antd";
import { Plus } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import MessageManager from "@/components/molecules/message_manager";
import { Button } from "@/components/ui/button";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
import { MemoryEditModal } from "./MemoryEditModal";
import { MemoryTable } from "./MemoryTable";
const { Text, Paragraph, Title } = Typography;
interface MemoryViewProps {
accessToken: string | null;
userID: string | null;
@ -62,7 +61,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
// All three write endpoints share the same success/error plumbing:
// - on success: invalidate the list query so every cached page
// refetches from scratch (pagination + filter-aware).
// - on error: surface the message via antd `message.error`.
// - on error: surface the message via `MessageManager.error`.
const invalidateList = useCallback(
() => queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] }),
@ -75,11 +74,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
return createMemory(accessToken, args);
},
onSuccess: (row) => {
message.success(`Created ${row.key}`);
MessageManager.success(`Created ${row.key}`);
invalidateList();
},
onError: (err: Error) => {
message.error(`Save failed: ${err.message}`);
MessageManager.error(`Save failed: ${err.message}`);
},
});
@ -90,11 +89,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
return updateMemory(accessToken, key, payload);
},
onSuccess: (row) => {
message.success(`Updated ${row.key}`);
MessageManager.success(`Updated ${row.key}`);
invalidateList();
},
onError: (err: Error) => {
message.error(`Save failed: ${err.message}`);
MessageManager.error(`Save failed: ${err.message}`);
},
});
@ -104,11 +103,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
return deleteMemory(accessToken, key).then(() => key);
},
onSuccess: (key) => {
message.success(`Deleted ${key}`);
MessageManager.success(`Deleted ${key}`);
invalidateList();
},
onError: (err: Error) => {
message.error(`Delete failed: ${err.message}`);
MessageManager.error(`Delete failed: ${err.message}`);
},
});
@ -150,7 +149,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
try {
metadataPayload = JSON.parse(metadataText);
} catch {
message.error("Metadata must be valid JSON (or leave empty).");
MessageManager.error("Metadata must be valid JSON (or leave empty).");
return false;
}
}
@ -177,19 +176,21 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
};
return (
<div className="w-full" style={{ padding: 24 }}>
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16 }}>
<div className="w-full p-6">
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div>
<Title level={3} style={{ marginBottom: 4 }}>
Memory
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
Inspect what your agents have stored under <Text code>/v1/memory</Text>. Scoped to memories visible to
your user / team (admins see all).
</Paragraph>
<h1 className="text-2xl font-semibold text-foreground">Memory</h1>
<p className="mt-1 text-sm text-muted-foreground">
Inspect what your agents have stored under{" "}
<code className="rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
/v1/memory
</code>
. Scoped to memories visible to your user / team (admins see all).
</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateOpen(true)}>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus />
New memory
</Button>
</div>
@ -209,7 +210,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
onEditClick={handleEdit}
onDeleteClick={handleDelete}
/>
</Space>
</div>
{/* Detail drawer */}
<MemoryDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />