From 9dda5d882c206e6003cf8c0bf54c7155f1736c50 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Jul 2026 22:23:45 -0700 Subject: [PATCH 1/3] test(ui): characterise the memory page's drawer and header before migrating Adds role/text-based coverage for MemoryDetailDrawer (which had none) and extends MemoryView's test past the mocked table to the header, the create modal trigger and the detail drawer round trip. Both are green against the current antd components, so they act as an unedited regression net for the shadcn migration that follows. --- .../_components/MemoryDetailDrawer.test.tsx | 88 +++++++++++++++++++ .../memory/_components/MemoryView.test.tsx | 47 +++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx new file mode 100644 index 00000000000..df65d5b534f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx @@ -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 => ({ + 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(); + + expect(screen.queryByText("Memory ID")).not.toBeInTheDocument(); + expect(screen.queryByText("Value")).not.toBeInTheDocument(); + }); + + it("shows the selected row's key, identifiers and value", () => { + render(); + + 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(); + + expect(screen.getAllByText("-")).toHaveLength(2); + expect(screen.queryByText("user-42")).not.toBeInTheDocument(); + }); + + it("omits the metadata block when the row carries no metadata", () => { + render(); + + expect(screen.queryByText("Metadata")).not.toBeInTheDocument(); + }); + + it("pretty-prints metadata as JSON when present", () => { + render(); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText('{ "tags": [ "example" ] }')).toBeInTheDocument(); + }); + + it("attributes the created and updated timestamps to their actors", () => { + render(); + + 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(); + + expect(screen.getByText("Created —")).toBeInTheDocument(); + }); + + it("closes through the close control", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx index f415c99225a..9ccef5357b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx @@ -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(); + }); }); From d2f872d04fd67435452beffe26ca3b6f5c23550b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Jul 2026 22:52:15 -0700 Subject: [PATCH 2/3] refactor(ui): migrate memory page to shadcn Replaces the antd Drawer with ui/sheet and the antd Button, Typography and Space usage with ui/button plus token utilities, and swaps the @ant-design PlusOutlined icon for lucide's Plus. Toasts now go through the shared MessageManager so the route no longer imports antd directly. The route's tests were written against the antd components in the previous commit and are unchanged here, so they pass on both implementations. MemoryEditModal is left alone because it is built on antd Form; the table already sits on the shared DataTable. --- ui/litellm-dashboard/eslint-suppressions.json | 18 --- .../memory/_components/MemoryDetailDrawer.tsx | 133 +++++++----------- .../memory/_components/MemoryView.tsx | 49 +++---- 3 files changed, 78 insertions(+), 122 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index e31cdcae596..01258f55ba6 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1128,29 +1128,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/ModelsAndEndpointsView.tsx": { - "no-restricted-imports": { - "count": 3 - }, - "react-hooks/preserve-manual-memoization": { - "count": 4 - } - }, "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": { "max-params": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx index 970e088ec00..63e6644d7e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx @@ -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 ( - - {row.key} - - ) : ( - "Memory" - ) - } - width={720} - destroyOnClose + onOpenChange={(open) => { + if (!open) onClose(); + }} > - {row && ( - - -
- - Memory ID - - - {row.memory_id} - + + + {row ? {row.key} : "Memory"} + + {row && ( +
+
+
+ Memory ID + {row.memory_id} +
+
+ User ID + + {row.user_id ?? "-"} + +
+
+ Team ID + + {row.team_id ?? "-"} + +
- - User ID - - {row.user_id ?? "-"} + Value +

{row.value}

-
- - Team ID - - {row.team_id ?? "-"} + {row.metadata !== undefined && row.metadata !== null && ( +
+ Metadata +

{JSON.stringify(row.metadata, null, 2)}

+
+ )} +
+ + Created {formatTimestamp(row.created_at)} + {row.created_by ? ` by ${row.created_by}` : ""} + + + + Updated {formatTimestamp(row.updated_at)} + {row.updated_by ? ` by ${row.updated_by}` : ""} +
- -
- Value - - {row.value} -
- {row.metadata !== undefined && row.metadata !== null && ( -
- Metadata - - {JSON.stringify(row.metadata, null, 2)} - -
- )} - ·} wrap size="small" style={{ color: "rgba(0,0,0,0.45)" }}> - - Created {formatTimestamp(row.created_at)} - {row.created_by ? ` by ${row.created_by}` : ""} - - - Updated {formatTimestamp(row.updated_at)} - {row.updated_by ? ` by ${row.updated_by}` : ""} - - - - )} - + )} + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx index fcb15978f47..a91110cf780 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ accessToken }) => { }; return ( -
- -
+
+
+
- - Memory - - - Inspect what your agents have stored under /v1/memory. Scoped to memories visible to - your user / team (admins see all). - +

Memory

+

+ Inspect what your agents have stored under{" "} + + /v1/memory + + . Scoped to memories visible to your user / team (admins see all). +

-
@@ -209,7 +210,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { onEditClick={handleEdit} onDeleteClick={handleDelete} /> - +
{/* Detail drawer */} setDetailRow(null)} /> From a928db1fab12ff190afc91266c0be8c58b6f11aa Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Jul 2026 23:40:19 -0700 Subject: [PATCH 3/3] fix(ui): keep the memory detail sheet inside the viewport on narrow screens The migrated sheet asked for a flat 720px width while its only max-width came from the primitive's sm:-scoped rule, so below the sm breakpoint no cap applied at all: on a 375px viewport the sheet rendered 720px wide with its left edge at -305px, and because it is position:fixed there was no scroll to reach the hidden content. The primitive's own w-3/4 default did not have this problem; the fixed pixel width is what removed the guard. Caps the width to the viewport at every breakpoint and only asks for 720px from sm up. Verified in a browser at 375px, 700px and 1280px: the sheet is now 375, 700 and 720 wide respectively, always at left 0. --- .../app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx index 63e6644d7e0..5c2fac2d5a9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx @@ -32,7 +32,7 @@ export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) { if (!open) onClose(); }} > - + {row ? {row.key} : "Memory"}