feat(ui): link the User ID and Team ID cells on the Memory page (#40752)

Both columns rendered as dead pills, so tracing a memory row back to its
owner meant copying an id into another page's search box. IdCell grows an
href prop that turns the pill into a client-routed link, and the Memory
columns pass the shared entityLinks helpers so the proxy admin and
dashboard sentinels stay unlinked.

Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
This commit is contained in:
ryan-crabbe-berri 2026-09-11 17:48:44 -07:00 committed by GitHub
parent b957c25241
commit 1be930664f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 83 additions and 4 deletions

View file

@ -8,6 +8,8 @@ import { MemoryRow } from "@/components/networking";
import { MemoryTable } from "./MemoryTable";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
memory_id: "mem-1",
key: "user:profile",
@ -36,6 +38,23 @@ const baseProps = {
};
describe("MemoryTable", () => {
it("links the User ID and Team ID cells to their detail pages", () => {
render(<MemoryTable {...baseProps} />);
expect(screen.getByRole("link", { name: "user-42" })).toHaveAttribute("href", "/ui/users?user=user-42");
expect(screen.getByRole("link", { name: "team-7" })).toHaveAttribute("href", "/ui/teams?team=team-7");
});
it("leaves the proxy admin and dashboard sentinels unlinked", () => {
const sentinelRow = makeMemory({ user_id: "default_user_id", team_id: "litellm-dashboard" });
render(<MemoryTable {...baseProps} data={[sentinelRow]} />);
expect(screen.getByText("default_user_id")).toBeInTheDocument();
expect(screen.getByText("litellm-dashboard")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "litellm-dashboard" })).not.toBeInTheDocument();
});
it("renders every column header", () => {
render(<MemoryTable {...baseProps} />);
for (const header of ["ID", "Name", "Preview", "User ID", "Team ID", "Updated"]) {

View file

@ -14,6 +14,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { teamDetailHref, userDetailHref } from "@/utils/entityLinks";
interface MemoryRowActionsProps {
row: MemoryRow;
@ -109,7 +110,10 @@ export const getMemoryTableColumns = ({
header: "User ID",
size: 160,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.user_id} />,
cell: ({ row }) => {
const userId = row.original.user_id;
return <IdCell value={userId} href={userId ? userDetailHref(userId) : undefined} />;
},
},
{
id: "team_id",
@ -118,7 +122,10 @@ export const getMemoryTableColumns = ({
header: "Team ID",
size: 160,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.team_id} />,
cell: ({ row }) => {
const teamId = row.original.team_id;
return <IdCell value={teamId} href={teamId ? teamDetailHref(teamId) : undefined} />;
},
},
{
id: "updated_at",

View file

@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest";
import { IdCell } from "./id_cell";
const { routerPushMock } = vi.hoisted(() => ({ routerPushMock: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: routerPushMock }) }));
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
@ -83,4 +87,23 @@ describe("IdCell", () => {
render(<IdCell value="k-1" dataTestId="key-id-cell" />);
expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1");
});
it("renders the id as a link and routes client side when href is set", async () => {
const user = userEvent.setup();
render(<IdCell value="user-42" href="/ui/users?user=user-42" />);
const link = screen.getByRole("link", { name: "user-42" });
expect(link).toHaveAttribute("href", "/ui/users?user=user-42");
expect(link).toHaveClass("cursor-pointer");
await user.click(link);
expect(routerPushMock).toHaveBeenCalledWith("/ui/users?user=user-42");
});
it("stays plain text when href is undefined", () => {
render(<IdCell value="default_user_id" href={undefined} />);
expect(screen.getByText("default_user_id").tagName).toBe("SPAN");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});

View file

@ -3,6 +3,7 @@
import { Copy } from "lucide-react";
import * as React from "react";
import { useEntityLinkClick } from "@/components/shared/EntityLink";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
@ -13,6 +14,7 @@ export type IdCellVariant = "pill" | "plain";
interface IdCellProps {
value: string | null | undefined;
variant?: IdCellVariant;
href?: string;
onClick?: (value: string) => void;
copyable?: boolean;
copyLabel?: string;
@ -38,6 +40,7 @@ const VARIANT_CLASS: Record<IdCellVariant, { base: string; clickable: string }>
export function IdCell({
value,
variant = "pill",
href,
onClick,
copyable = false,
copyLabel = "Copy ID",
@ -52,16 +55,17 @@ export function IdCell({
return <span className="text-muted-foreground">{fallback}</span>;
}
const linked = !!href && !disabled;
const clickable = !!onClick && !disabled;
const classes = cn(
VARIANT_CLASS[variant].base,
clickable && VARIANT_CLASS[variant].clickable,
(linked || clickable) && VARIANT_CLASS[variant].clickable,
truncate && "block max-w-[15ch] truncate",
disabled && "opacity-50",
className,
);
const idElement = clickable ? (
const unlinkedElement = clickable ? (
<button type="button" className={classes} data-testid={dataTestId} onClick={() => onClick(value)}>
{value}
</button>
@ -71,6 +75,14 @@ export function IdCell({
</span>
);
const idElement = linked ? (
<IdLink href={href} className={classes} dataTestId={dataTestId}>
{value}
</IdLink>
) : (
unlinkedElement
);
const withTooltip = <CellTooltip content={tooltip ?? value} trigger={idElement} />;
if (!copyable) {
@ -94,3 +106,21 @@ export function IdCell({
</span>
);
}
interface IdLinkProps extends React.ComponentPropsWithoutRef<"a"> {
href: string;
dataTestId?: string;
}
const IdLink = React.forwardRef<HTMLAnchorElement, IdLinkProps>(function IdLink(
{ href, dataTestId, children, ...props },
ref,
) {
const handleClick = useEntityLinkClick(href);
return (
<a {...props} ref={ref} href={href} data-testid={dataTestId} onClick={handleClick}>
{children}
</a>
);
});