fix(ui): scroll admin table rows inside the table instead of the page

Virtual Keys, Teams, Request Logs and Tags now hand DataTable a bounded
flex chain and use fillHeight, so the app shell main stays the only page
scroller, the rows scroll under a pinned header and the pagination footer
sits at the bottom of the page. DataTable keeps the sticky header inside
its own scroller in maxBodyHeight mode too, which is what let the header
scroll away with the rows on Keys, Teams and Models. Model Hub, Vector
Stores and the team detail keys tab drop their 75vh boxes and flow with
the page scroller.

Adds an e2e spec that fails on the merge base for every one of those
pages and passes at this tip.

Refs LIT-4738

Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
This commit is contained in:
ryan-crabbe-berri 2026-09-03 17:13:18 -07:00
parent a53c550951
commit dc9f40c11f
14 changed files with 315 additions and 58 deletions

View file

@ -0,0 +1,239 @@
import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";
import { CHAT_MODEL_A, masterKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
/**
* LIT-4738 table scrolling. On the paginated pages the app shell <main> is the only page scroller
* and must never overflow: rows scroll inside the table body under a header that stays put, and the
* pagination footer sits at the bottom of the page instead of below the fold or inside a clipped
* box. Pages that keep plain page scrolling must never paint rows past a fixed-height ancestor.
* The viewport is pinned so "more rows than fit" means the same thing on every machine.
*/
const VIEWPORT = { width: 1280, height: 720 };
const SEED_ROWS = 40;
const LOG_ROWS = 20;
const BODY_SCROLL_PX = 500;
/** p-8 on Keys and Teams, p-6 on Logs: the footer may sit at most one page padding above the edge. */
const MAX_FOOTER_GAP_PX = 40;
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const adminHeaders = (): Record<string, string> => ({
Authorization: `Bearer ${masterKey()}`,
});
/** Keys and Teams render a <main> of their own inside the app shell's, which comes first in document order. */
const pageScroller = (page: PlaywrightPage): Locator => page.locator("main").first();
/** Tabs keep every panel mounted, so a bare test id can match a hidden table; scope to the visible one. */
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
/** The page's data table; Model Hub also renders a plain links table above it, which this skips. */
const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first();
const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr");
interface BoxMetrics {
top: number;
bottom: number;
scrollHeight: number;
clientHeight: number;
scrollWidth: number;
clientWidth: number;
}
const metrics = (locator: Locator): Promise<BoxMetrics> =>
locator.evaluate((el) => {
const rect = el.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
};
});
async function postOk(
request: APIRequestContext,
path: string,
data: Record<string, unknown>,
): Promise<Record<string, any>> {
const res = await request.post(path, { headers: adminHeaders(), data });
expect(res.ok(), `POST ${path} failed (${res.status()}): ${await res.text()}`).toBe(true);
return (await res.json()) as Record<string, any>;
}
/** One request at a time: a burst of forty management calls starves the proxy's transaction pool. */
async function oneAtATime<T>(count: number, call: (index: number) => Promise<T>): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < count; i++) {
results.push(await call(i));
}
return results;
}
async function expectRowsAtLeast(page: PlaywrightPage, count: number): Promise<void> {
await expect.poll(() => visibleRows(page).count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(count);
}
async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise<void> {
await visibleTestId(page, "pagination-page-size").click();
await page.getByRole("option", { name: size, exact: true }).click();
}
/**
* The page scroller stays put, the table body is what scrolls, the header does not move while the
* body scrolls, and the pagination footer sits at the bottom of the page.
*/
async function expectBodyIsTheOnlyScroller(page: PlaywrightPage): Promise<void> {
const scroller = await metrics(pageScroller(page));
const body = visibleTestId(page, "data-table-scroller");
const bodyBefore = await metrics(body);
const headBefore = await metrics(visibleTestId(page, "data-table-head"));
const footer = await metrics(visibleDataTable(page));
expect(scroller.scrollHeight, "page scroller must not overflow vertically").toBe(scroller.clientHeight);
expect(scroller.scrollWidth, "page scroller must not overflow horizontally").toBe(scroller.clientWidth);
expect(bodyBefore.scrollHeight, "table body must be the element that scrolls").toBeGreaterThan(
bodyBefore.clientHeight,
);
expect(footer.bottom, "pagination footer must be inside the page").toBeLessThanOrEqual(scroller.bottom);
expect(scroller.bottom - footer.bottom, "pagination footer must sit at the bottom of the page").toBeLessThanOrEqual(
MAX_FOOTER_GAP_PX,
);
await body.evaluate((el, px) => {
el.scrollTop = px;
}, BODY_SCROLL_PX);
await expect.poll(() => body.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
const headAfter = await metrics(visibleTestId(page, "data-table-head"));
expect(Math.round(headAfter.top), "header must stay put while the body scrolls").toBe(Math.round(headBefore.top));
}
/**
* Every row must sit inside each ancestor up to the nearest one that really scrolls vertically; a
* fixed-height box that neither grows nor scrolls lets rows paint past its bottom edge.
*/
const rowsPaintingPastAnAncestor = (page: PlaywrightPage): Promise<string[]> =>
visibleDataTable(page)
.locator("table")
.evaluate((table) => {
const scrollsVertically = (el: Element): boolean =>
/auto|scroll/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1;
const describe = (el: Element): string =>
`<${el.tagName.toLowerCase()} class="${el.getAttribute("class") ?? ""}">`;
return Array.from(table.querySelectorAll("tbody tr")).flatMap((row, index) => {
const rowBottom = row.getBoundingClientRect().bottom;
const spills: string[] = [];
for (let el = row.parentElement; el && el !== document.body && !scrollsVertically(el); el = el.parentElement) {
const bottom = el.getBoundingClientRect().bottom;
if (rowBottom > bottom + 1) {
spills.push(
`row ${index} bottom ${Math.round(rowBottom)} past ${describe(el)} bottom ${Math.round(bottom)}`,
);
}
}
return spills;
});
});
type Cleanup = (request: APIRequestContext) => Promise<unknown>;
const cleanups: Cleanup[] = [];
test.describe("Admin tables scroll inside the page", () => {
test.use({ storageState: ADMIN_STORAGE_PATH, viewport: VIEWPORT });
test.afterEach(async ({ request }) => {
for (const cleanup of cleanups.splice(0)) {
// Teardown must never turn a passing test red or mask a real failure.
await cleanup(request).catch(() => {});
}
});
test("Virtual Keys: rows scroll under a sticky header and the page itself never scrolls", async ({
page,
request,
}) => {
const suffix = uniqueSuffix();
const created = await oneAtATime(SEED_ROWS, (i) =>
postOk(request, "/key/generate", { key_alias: `e2e-scroll-key-${suffix}-${i}` }),
);
cleanups.push((r) => r.post("/key/delete", { headers: adminHeaders(), data: { keys: created.map((k) => k.key) } }));
await navigateToPage(page, Page.ApiKeys);
await expectRowsAtLeast(page, SEED_ROWS);
await expectBodyIsTheOnlyScroller(page);
});
test("Teams: rows scroll under a sticky header and the page itself never scrolls", async ({ page, request }) => {
const suffix = uniqueSuffix();
const created = await oneAtATime(SEED_ROWS, (i) =>
postOk(request, "/team/new", { team_alias: `e2e-scroll-team-${suffix}-${i}` }),
);
cleanups.push((r) =>
r.post("/team/delete", { headers: adminHeaders(), data: { team_ids: created.map((t) => t.team_id) } }),
);
await navigateToPage(page, Page.Teams);
await expectRowsAtLeast(page, SEED_ROWS);
await expectBodyIsTheOnlyScroller(page);
});
test("Request Logs: rows scroll under a sticky header and the page itself never scrolls", async ({
page,
request,
}) => {
const suffix = uniqueSuffix();
const ids = await oneAtATime(LOG_ROWS, (i) =>
sendChatCompletion(request, { model: CHAT_MODEL_A, prompt: `scroll ${suffix} ${i}` }),
);
await waitForSpendLog(request, ids[ids.length - 1]);
await navigateToPage(page, Page.Logs);
await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 });
await setRowsPerPage(page, "25");
await expectRowsAtLeast(page, LOG_ROWS);
await expectBodyIsTheOnlyScroller(page);
});
test("Tags: no row paints past the box it lives in", async ({ page, request }) => {
const suffix = uniqueSuffix();
const names = Array.from({ length: SEED_ROWS }, (_, i) => `e2e-scroll-tag-${suffix}-${i}`);
await oneAtATime(SEED_ROWS, (i) => postOk(request, "/tag/new", { name: names[i], description: "LIT-4738 scroll" }));
cleanups.push((r) =>
oneAtATime(SEED_ROWS, (i) => r.post("/tag/delete", { headers: adminHeaders(), data: { name: names[i] } })),
);
await navigateToPage(page, Page.TagManagement);
await expectRowsAtLeast(page, SEED_ROWS);
expect(await rowsPaintingPastAnAncestor(page)).toEqual([]);
});
test("Model Hub: no row paints past the box it lives in", async ({ page, request }) => {
const suffix = uniqueSuffix();
const created = await oneAtATime(SEED_ROWS, (i) =>
postOk(request, "/model/new", {
model_name: `e2e-scroll-model-${suffix}-${i}`,
litellm_params: {
model: "openai/fake-gpt-4",
api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`,
api_key: "fake-key",
},
}),
);
cleanups.push((r) =>
oneAtATime(SEED_ROWS, (i) =>
r.post("/model/delete", { headers: adminHeaders(), data: { id: created[i].model_info.id } }),
),
);
await navigateToPage(page, Page.ModelHubTable);
await expectRowsAtLeast(page, SEED_ROWS);
expect(await rowsPaintingPastAnAncestor(page)).toEqual([]);
});
});

View file

@ -41,6 +41,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
data={data}
columns={columns}
getRowId={(tag, index) => tag.name || String(index)}
fillHeight
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}

View file

@ -126,7 +126,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
}, [accessToken]);
return (
<div className="mx-4 h-[75vh]">
<div className="mx-4 h-full">
{selectedTagId ? (
<TagInfoView
tagId={selectedTagId}
@ -139,7 +139,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
editTag={editTag}
/>
) : (
<div className="mt-2 h-[75vh] w-full gap-2 p-8">
<div className="flex h-full w-full flex-col p-8 pt-10">
<div className="mt-2 mb-4 flex w-full items-center justify-between">
<h1>Tag Management</h1>
<div className="flex items-center space-x-2">
@ -162,23 +162,21 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
</p>
</div>
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
<Button className="mb-4 self-start" onClick={() => setIsCreateModalVisible(true)}>
+ Create New Tag
</Button>
<div className="mt-2 grid h-[75vh] w-full grid-cols-1 gap-2 pt-2 pb-2">
<div>
<TagTable
data={tags}
isLoading={isLoadingTags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);
}}
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</div>
<div className="mt-2 flex min-h-0 flex-1 flex-col">
<TagTable
data={tags}
isLoading={isLoadingTags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);
}}
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</div>
{/* Create Tag Modal */}

View file

@ -137,8 +137,8 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
/>
</div>
) : (
<div className="mx-4 h-[75vh]">
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
<div className="mx-4">
<div className="gap-2 p-8 w-full mt-2">
<div className="flex justify-between mt-2 w-full items-center mb-4">
<h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
<div className="flex items-center space-x-2">

View file

@ -400,7 +400,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
}
return (
<div className="mx-4 h-[75vh]">
<div className="mx-4">
{publicPage == false ? (
<div className="w-full m-2 mt-2 p-8">
{/* Header with Title, Description and URL */}

View file

@ -559,6 +559,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "your-teams",
label: "Your Teams",
className: "flex min-h-0 flex-1 flex-col",
children: (
<>
<TeamsTable
@ -608,6 +609,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "available-teams",
label: "Available Teams",
className: "min-h-0 flex-1 overflow-y-auto",
children: <AvailableTeamsPanel accessToken={accessToken} userID={userID} />,
},
...(isProxyAdminRole(userRole || "")
@ -615,6 +617,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "default-settings",
label: "Default Team Settings",
className: "min-h-0 flex-1 overflow-y-auto",
children: <TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />,
},
]
@ -622,7 +625,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
];
return (
<main className={selectedTeamId ? "px-12 py-6" : "p-8"}>
<main className={selectedTeamId ? "px-12 py-6" : "flex h-full flex-col p-8"}>
{selectedTeamId ? (
<TeamInfoView
teamId={selectedTeamId}
@ -642,7 +645,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
premiumUser={premiumUser}
/>
) : (
<Tabs defaultValue={tabItems[0].key} className="gap-6">
<Tabs defaultValue={tabItems[0].key} className="min-h-0 flex-1 gap-6">
<PageHeader
icon={<Users />}
title="Teams"
@ -674,7 +677,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
)}
/>
{tabItems.map((item) => (
<TabsContent key={item.key} value={item.key}>
<TabsContent key={item.key} value={item.key} className={item.className}>
{item.children}
</TabsContent>
))}

View file

@ -164,7 +164,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
isLoading={isLoading}
loadingMessage="Loading teams..."
noDataMessage="No teams found"
maxBodyHeight="calc(75vh - 210px)"
fillHeight
size="compact"
toolbar={(table) => (
<>

View file

@ -256,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
}
return (
<div className="flex h-full flex-col gap-6 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-6">
<PageHeader
icon={<KeyRound />}
title="Virtual Keys"
@ -283,7 +283,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
isLoading={isLoading}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
maxBodyHeight="calc(75vh - 210px)"
fillHeight
size="compact"
toolbar={(table) => (
<>

View file

@ -613,8 +613,12 @@ describe("DataTable layout", () => {
it("makes the header sticky and constrains body height when maxBodyHeight is set", () => {
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky");
expect(screen.getByTestId("data-table-scroller")).toHaveStyle({ maxHeight: "240px" });
const scroller = screen.getByTestId("data-table-scroller");
expect(scroller).toHaveStyle({ maxHeight: "240px" });
expect(scroller).toHaveClass("overflow-auto");
// As in fill mode: the Table primitive's own overflow container would otherwise capture the sticky header.
expect(scroller).toHaveClass("[&_[data-slot=table-container]]:overflow-visible");
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background");
});
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {

View file

@ -59,18 +59,28 @@ const noop = () => {};
/**
* Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so
* a short table keeps its footer under the last row and a long one scrolls its rows instead of the
* page. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it
* captures the sticky header and the header scrolls away with the rows. And rows pass under that
* header, which the semi-transparent header row tint alone would not hide.
* page.
*/
const FILL_CLASSES = {
outer: "flex max-h-full min-h-0 flex-col",
frame: "flex min-h-0 flex-col",
body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible",
body: "min-h-0",
} as const;
const NO_FILL_CLASSES = { outer: "", frame: "", body: "" } as const;
/**
* Sticky header, in both fill and maxBodyHeight mode. `table-container` is the Table primitive's own
* overflow-x wrapper; left as a scroll box it captures the sticky header and the header scrolls away
* with the rows. And rows pass under that header, which the semi-transparent header row tint alone
* would not hide.
*/
const STICKY_CLASSES = {
body: "[&_[data-slot=table-container]]:overflow-visible",
header: "bg-background",
} as const;
const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const;
const NO_STICKY_CLASSES = { body: "", header: "" } as const;
function columnDefId<TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined {
if ("id" in column && typeof column.id === "string") {
@ -533,6 +543,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const visibleColumnCount = table.getVisibleLeafColumns().length;
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
const sticky = stickyHeader ? STICKY_CLASSES : NO_STICKY_CLASSES;
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
const renderPagination = (): React.ReactNode => {
@ -593,13 +604,13 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div
data-testid="data-table-scroller"
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", sticky.body, fill.body)}
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader
data-testid="data-table-head"
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", sticky.header)}
>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">

View file

@ -443,7 +443,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
}, []);
return (
<div className="w-full h-full overflow-hidden">
<div className="w-full">
{selectedKey ? (
<KeyInfoView
keyId={selectedKey.token}
@ -453,7 +453,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
onDelete={refetch}
/>
) : (
<div className="py-4 flex-1 overflow-hidden">
<div className="py-4">
<DataTable
data={displayKeys}
columns={columns}
@ -471,7 +471,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
columnResizeMode="onChange"
isLoading={isLoading || isFetching}
loadingMessage="Loading keys..."
maxBodyHeight="75vh"
size="compact"
toolbar={(table) => (
<>

View file

@ -216,24 +216,22 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer";
return (
<main className="h-[75vh] p-8">
<div className="flex h-full flex-col">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</div>
<main className="flex h-full flex-col p-8">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</main>
);
};

View file

@ -86,6 +86,7 @@ export function RequestLogsTable({
data={data}
columns={columns}
getRowId={(row) => row.request_id}
fillHeight
sortingMode="server"
sorting={sorting}
onSortingChange={onSortingChange}

View file

@ -27,6 +27,9 @@ const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" };
const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" };
const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" };
const tabContentClassName = (tabId: LogsTabId): string =>
tabId === REQUEST_LOGS_TAB.id ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1 overflow-y-auto";
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
const [activeTab, setActiveTab] = useState<LogsTabId>(REQUEST_LOGS_TAB.id);
const canViewAuditLogs = useCan("viewAuditLogs");
@ -78,8 +81,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
};
return (
<div className="box-border w-full overflow-x-hidden p-6">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as LogsTabId)}>
<div className="flex h-full w-full flex-col p-6">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as LogsTabId)} className="min-h-0 flex-1">
<TabsList variant="line">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-none">
@ -88,7 +91,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} keepMounted>
<TabsContent key={tab.id} value={tab.id} keepMounted className={tabContentClassName(tab.id)}>
{renderPanel(tab.id)}
</TabsContent>
))}