fixes and suggestions implemented

This commit is contained in:
Developing-Gamer 2026-07-14 16:57:28 -07:00
parent 2ab581076f
commit 54167b7f63
10 changed files with 236 additions and 92 deletions

View File

@ -1,6 +1,7 @@
"use client";
import { Link } from "@/components/link";
import { DesignSelectorDropdown } from "@/components/design-components/select";
import { Button, Typography } from "@/components/ui";
import { cn } from "@/lib/utils";
import { ArrowClockwiseIcon, CodeIcon } from "@phosphor-icons/react";
@ -13,7 +14,6 @@ import {
QueryDataGrid,
type QueryDataGridMode,
} from "./query-data-grid";
import { getValidatedTableFilterQuery } from "./search-bar-logic";
import { TableSearchBar } from "./table-search-bar";
import { useAiTableFilterChat } from "./use-ai-table-filter-chat";
@ -139,6 +139,10 @@ const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
],
]);
const AVAILABLE_TABLE_OPTIONS = [...AVAILABLE_TABLES.entries()].map(
([value, config]) => ({ value, label: config.displayName }),
);
// ─── Per-table content ──────────────────────────────────────────────
function TableContent({ tableId }: { tableId: TableId }) {
@ -148,12 +152,7 @@ function TableContent({ tableId }: { tableId: TableId }) {
// table, so the grid's columns never change.
const filterChat = useAiTableFilterChat(tableId);
const rawFilterQuery = filterChat.latestQuery;
// Defense-in-depth: the system prompt promises `SELECT * FROM <table>
// WHERE ...`, but the model could still emit anything — validate the shape
// before letting it drive the grid, otherwise the columns could change.
const filterQuery = rawFilterQuery == null ? null : getValidatedTableFilterQuery(rawFilterQuery, tableId);
const filterRejected = rawFilterQuery != null && filterQuery == null;
const filterQuery = filterChat.latestQuery;
const effectiveQuery = filterQuery ?? tableConfig.baseQuery;
const effectiveMode: QueryDataGridMode = filterQuery != null ? "one-shot" : "paginated";
@ -199,7 +198,7 @@ function TableContent({ tableId }: { tableId: TableId }) {
queryKey={effectiveQuery}
chat={filterChat}
activeFilterQuery={filterQuery}
filterRejected={filterRejected}
filterRejected={filterChat.filterRejected}
onAiSubmit={(text) => filterChat.sendMessage({ text })}
onClearFilter={filterChat.clearMessages}
/>
@ -228,9 +227,25 @@ export default function PageClient() {
<AnalyticsEventLimitBanner />
</div>
{/* Match the primary nav's dark:rounded-2xl so the gap junction mirrors
the same radius on both sides (nav top-right tables top-left). */}
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl rounded-tr-2xl lg:ml-0.5">
<div className="shrink-0 px-3 py-2 lg:hidden">
<label htmlFor="analytics-table-selector" className="sr-only">
Table
</label>
<DesignSelectorDropdown
value={selectedTable ?? "events"}
onValueChange={setSelectedTable}
options={AVAILABLE_TABLE_OPTIONS}
placeholder="Select a table"
size="sm"
triggerId="analytics-table-selector"
/>
</div>
{/* Dark: match the primary nav's rounded-2xl so the gap junction mirrors
the same radius on both sides (nav top-right tables top-left).
Light: only round the left edge the shell card already owns the
top-right radius, so an inner tr curve reads as a stray notch. */}
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl dark:rounded-tr-2xl lg:ml-0.5">
{/* Use the same surface treatment as the primary sidebar so equal radii render
identically. Omit the right border to keep the sidebar/grid junction divider-free. */}
<div className="hidden w-48 min-h-0 flex-shrink-0 flex-col overflow-hidden rounded-l-2xl bg-black/[0.03] dark:border dark:border-r-0 dark:border-foreground/5 dark:bg-foreground/5 dark:backdrop-blur-2xl dark:shadow-sm lg:flex">
@ -267,7 +282,11 @@ export default function PageClient() {
</div>
</div>
{/* Right content — grid fills remaining height and scrolls internally */}
{/* Right content grid fills remaining height and scrolls internally.
Sticky chrome is its own composited layer and paints square over a
parent's overflow:hidden radius, so dark mode clips the panel itself.
Below lg the tables sidebar is hidden and the grid owns both top
corners; at lg+ the sidebar owns the left radius. */}
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
@ -278,14 +297,9 @@ export default function PageClient() {
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
"[&_[role=grid]_.sticky>div:first-child>div]:pr-3",
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
"max-lg:dark:rounded-t-2xl max-lg:dark:[clip-path:inset(0_round_1rem_1rem_0_0)]",
"lg:dark:rounded-tr-2xl lg:dark:[clip-path:inset(0_round_0_1rem_0_0)]",
)}
style={{
// Outer top-right corner of the block. Same clip-path trick as the
// sidebar: the grid's sticky chrome is its own composited layer and
// paints square over the parent's rounded overflow clip without this.
borderTopRightRadius: "1rem",
clipPath: "inset(0 round 0 1rem 0 0)",
}}
>
{selectedTable ? (
<TableContent key={selectedTable} tableId={selectedTable} />

View File

@ -578,11 +578,10 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
*
* Priority:
* 1. `toolbar` (full override) caller owns the whole row
* 2. `searchBar` provided render our own DataGridToolbar
* wrapper that hides the built-in quick search and slots the
* caller's node where it used to live; keeps Columns/Export
* intact. `toolbarExtra` / `toolbarActions` (if provided) are
* passed through to the leading / trailing slots.
* 2. `searchBar` or `toolbarActions` provided render our own
* DataGridToolbar wrapper. The built-in quick search is hidden only
* when `searchBar` replaces it; Columns/Export stay intact and the
* leading/trailing extension slots are passed through.
* 3. neither undefined, so the DataGrid
* falls back to its default toolbar behaviour (built-in
* quick search, extras, columns, export).
@ -610,7 +609,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
extra={extras}
extraLeading={leading}
extraActions={actions}
hideQuickSearch
hideQuickSearch={searchBar !== undefined}
/>
);
},
@ -627,7 +626,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
const resolvedToolbar = toolbar
? renderForwardedToolbar
: searchBar !== undefined
: searchBar !== undefined || toolbarActions !== undefined
? renderCustomToolbar
: undefined;
@ -635,11 +634,11 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
// When we've already built a custom toolbar above for the
// `searchBar` case, the `toolbarExtra` prop is consumed inside
// that custom toolbar — don't also pass it to DataGrid.
if (toolbar || searchBar !== undefined) return undefined;
if (toolbar || searchBar !== undefined || toolbarActions !== undefined) return undefined;
if (toolbarExtra === undefined) return undefined;
if (typeof toolbarExtra !== "function") return toolbarExtra;
return (ctx: DataGridToolbarContext<RowData>) => toolbarExtra(extendCtx(ctx));
}, [toolbar, searchBar, toolbarExtra, extendCtx]);
}, [toolbar, searchBar, toolbarActions, toolbarExtra, extendCtx]);
return (
<div className={fillHeight ? "flex flex-1 min-h-0 flex-col" : "flex flex-col"}>

View File

@ -70,6 +70,24 @@ describe("getValidatedTableFilterQuery", () => {
expect(getValidatedTableFilterQuery(query, "users")).toBe(query);
});
it("rejects top-level sorting and pagination while allowing them in subqueries", () => {
expect(
getValidatedTableFilterQuery(
"SELECT * FROM users ORDER BY signed_up_at DESC",
"users",
),
).toBeNull();
expect(
getValidatedTableFilterQuery("SELECT * FROM users LIMIT 100", "users"),
).toBeNull();
expect(
getValidatedTableFilterQuery(
"SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events ORDER BY event_at DESC LIMIT 100)",
"users",
),
).not.toBeNull();
});
it("rejects queries on a different table", () => {
expect(getValidatedTableFilterQuery("SELECT * FROM events", "users")).toBe(null);
// prefix of another table name must not match

View File

@ -77,9 +77,11 @@ function escapeRegExp(value: string): string {
/**
* Validates that an AI-committed query is a pure row filter over the given
* table, i.e. it cannot change the grid's columns. Accepts
* `SELECT * FROM [default.]<table>` optionally followed by
* WHERE/PREWHERE/ORDER BY/LIMIT anything else (column lists, JOINs at the
* top level, other tables) returns null and must not be applied to the grid.
* `SELECT * FROM [default.]<table>` optionally followed by a WHERE/PREWHERE
* row filter anything else (column lists, JOINs, sorting, pagination, other
* tables) returns null and must not be applied to the grid. Sorting and
* pagination stay grid-owned so an AI filter cannot silently truncate or
* reorder the result set before the grid processes it.
* Subqueries inside the WHERE condition are fine: the `SELECT *` prefix on
* the outer query is what guarantees the column set stays identical.
*
@ -95,7 +97,7 @@ export function getValidatedTableFilterQuery(
const collapsed = normalized.replace(/\s+/g, " ");
const table = escapeRegExp(tableName);
const pattern = new RegExp(
`^select \\* from (?:default\\.)?\`?${table}\`?(?: (?:where|prewhere|order by|limit)\\b.*)?$`,
`^select \\* from (?:default\\.)?\`?${table}\`?(?: (?:where|prewhere)\\b.*)?$`,
"i",
);
return pattern.test(collapsed) ? normalized : null;

View File

@ -238,19 +238,9 @@ export function TableSearchBar({
applyQuickSearch("");
}, [applyQuickSearch]);
// Chip label: the natural-language request that produced the active filter.
const activeFilterLabel = useMemo(() => {
if (activeFilterQuery == null) return null;
for (let i = chat.messages.length - 1; i >= 0; i--) {
const msg = chat.messages[i]!;
if (msg.role !== "user") continue;
const part = msg.content.find((p) => p.type === "text");
if (part?.type === "text" && part.text.trim().length > 0) {
return part.text.trim();
}
}
return "AI filter";
}, [activeFilterQuery, chat.messages]);
// Stored with the committed query so a rejected/text-only refinement cannot
// relabel the still-active filter with the newer request.
const activeFilterLabel = activeFilterQuery == null ? null : chat.latestQueryLabel;
const rejectionMessage = filterRejected
? "The AI answered with a query that would change this table's columns, so it wasn't applied. Try rephrasing your request."

View File

@ -4,7 +4,7 @@ import { extractLatestQuery } from "./use-ai-table-filter-chat";
const fixture = (messages: Array<{
id: string,
role: "assistant",
role: "user" | "assistant",
content: Array<
| { type: "text", text: string }
| { type: "tool-call", toolCallId: string, toolName: string, args: { query?: string }, argsText?: string, result: unknown }
@ -14,6 +14,11 @@ const fixture = (messages: Array<{
describe("extractLatestQuery", () => {
it("ignores failed queryAnalytics tool calls and keeps the last successful query", () => {
const messages = fixture([
{
id: "user-1",
role: "user",
content: [{ type: "text", text: "verified users" }],
},
{
id: "assistant-1",
role: "assistant",
@ -28,6 +33,11 @@ describe("extractLatestQuery", () => {
},
],
},
{
id: "user-2",
role: "user",
content: [{ type: "text", text: "only recent users" }],
},
{
id: "assistant-2",
role: "assistant",
@ -48,7 +58,55 @@ describe("extractLatestQuery", () => {
expect(result).toEqual({
query: "SELECT 1",
toolCallIndex: 2,
toolCallIndex: 1,
requestText: "verified users",
});
});
it("does not reuse a successful query from before the supplied run", () => {
const currentRun = fixture([
{
id: "user-2",
role: "user",
content: [{ type: "text", text: "group them by month" }],
},
{
id: "assistant-2",
role: "assistant",
content: [{ type: "text", text: "This table view cannot show grouped results." }],
},
]);
expect(extractLatestQuery(currentRun)).toBeNull();
});
it("associates a successful query with the request that produced it", () => {
const messages = fixture([
{
id: "user-1",
role: "user",
content: [{ type: "text", text: "verified users" }],
},
{
id: "assistant-1",
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "queryAnalytics",
args: { query: "SELECT * FROM users WHERE primary_email_verified = 1" },
argsText: "",
result: { success: true },
},
],
},
]);
expect(extractLatestQuery(messages)).toEqual({
query: "SELECT * FROM users WHERE primary_email_verified = 1",
toolCallIndex: 1,
requestText: "verified users",
});
});

View File

@ -7,6 +7,7 @@ import { useUser } from "@hexclave/next";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useProjectId } from "../../use-admin-app";
import { getValidatedTableFilterQuery } from "./search-bar-logic";
const QUERY_ANALYTICS_TOOL = "queryAnalytics";
@ -33,29 +34,42 @@ function isSuccessfulQueryToolPart(part: QueryToolPart): boolean {
export function extractLatestQuery(messages: readonly ThreadMessage[]): {
query: string,
toolCallIndex: number,
requestText: string,
} | null {
let toolCallIndex = 0;
let requestText: string | null = null;
let latest: {
query: string,
toolCallIndex: number,
requestText: string,
} | null = null;
for (const msg of messages) {
if (msg.role === "user") {
const textPart = msg.content.find((part) => part.type === "text");
if (textPart?.type === "text" && textPart.text.trim().length > 0) {
requestText = textPart.text.trim();
}
continue;
}
if (msg.role !== "assistant") continue;
for (const part of msg.content) {
if (isQueryAnalyticsToolPart(part)) toolCallIndex += 1;
}
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!;
if (msg.role !== "assistant") continue;
for (let j = msg.content.length - 1; j >= 0; j--) {
const part = msg.content[j]!;
if (!isQueryAnalyticsToolPart(part)) continue;
toolCallIndex += 1;
if (!isSuccessfulQueryToolPart(part)) continue;
const query = typeof part.args.query === "string" ? part.args.query : null;
if (query && query.trim().length > 0) {
return { query, toolCallIndex };
latest = {
query,
toolCallIndex,
requestText: requestText ?? throwErr(
"queryAnalytics returned a filter without a preceding user request",
),
};
}
}
}
return null;
return latest;
}
/**
@ -83,8 +97,12 @@ export type AiTableFilterChat = {
error: Error | null,
sendMessage: (input: { text: string }) => void,
clearMessages: () => void,
/** The last query the AI committed via the queryAnalytics tool (unvalidated). */
/** The last validated row filter committed via the queryAnalytics tool. */
latestQuery: string | null,
/** The user request that produced `latestQuery`. */
latestQueryLabel: string | null,
/** Whether the latest run produced a query that failed row-filter validation. */
filterRejected: boolean,
/**
* Set when the last run finished WITHOUT committing a new query holds the
* assistant's text reply (or null if there was none / a query was
@ -97,9 +115,8 @@ export type AiTableFilterChat = {
/**
* AI chat thread backing the analytics table search bar's AI fallback. Uses
* the constrained `filter-analytics-table` system prompt scoped to the given
* table, so the AI only produces `SELECT * FROM <table> WHERE ...` row
* filters (callers still validate the shape before applying see
* `getValidatedTableFilterQuery`).
* table, and validates every committed tool call before exposing it as the
* active `SELECT * FROM <table> WHERE ...` row filter.
*/
export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
const currentUser = useUser();
@ -147,43 +164,53 @@ export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
const [committed, setCommitted] = useState<{
query: string,
generation: number,
label: string,
} | null>(null);
const [filterRejected, setFilterRejected] = useState(false);
const [assistantNote, setAssistantNote] = useState<string | null>(null);
const wasRespondingRef = useRef(false);
const lastCommittedGenRef = useRef(0);
const runStartMessageIndexRef = useRef(0);
useEffect(() => {
const justFinished = wasRespondingRef.current && !isResponding;
wasRespondingRef.current = isResponding;
if (!justFinished) return;
const latest = extractLatestQuery(messages);
if (latest == null || latest.toolCallIndex <= lastCommittedGenRef.current) {
// Each falling edge is evaluated only against messages added by that run.
// Scanning the whole thread would let a failed/text-only refinement pick
// up and recommit an older successful query (and its unrelated reply).
const runMessages = messages.slice(runStartMessageIndexRef.current);
const latest = extractLatestQuery(runMessages);
if (latest == null) {
// The run ended without committing a new query — surface the
// assistant's text (if any) so callers can show WHY nothing changed.
// This must live here (not in a consuming component) because child
// effects run before this one; a child comparing commit state on the
// same falling edge would race the commit below.
setAssistantNote(extractLastAssistantText(messages));
setAssistantNote(extractLastAssistantText(runMessages));
return;
}
lastCommittedGenRef.current = latest.toolCallIndex;
setCommitted({ query: latest.query, generation: latest.toolCallIndex });
setAssistantNote(null);
}, [isResponding, messages]);
useEffect(() => {
if (messages.length === 0 && committed != null) {
lastCommittedGenRef.current = 0;
setCommitted(null);
const validatedQuery = getValidatedTableFilterQuery(latest.query, tableName);
if (validatedQuery == null) {
// Keep the previous valid filter active. A rejected refinement should
// never reset the grid to its unfiltered base query or relabel the chip.
setFilterRejected(true);
setAssistantNote(null);
return;
}
}, [messages.length, committed]);
setCommitted({ query: validatedQuery, label: latest.requestText });
setFilterRejected(false);
setAssistantNote(null);
}, [isResponding, messages, tableName]);
const sendMessage = useCallback(
({ text }: { text: string }) => {
setError(null);
setAssistantNote(null);
setFilterRejected(false);
runStartMessageIndexRef.current = runtime.thread.getState().messages.length;
runtime.thread.append({
role: "user",
content: [{ type: "text", text }],
@ -195,6 +222,9 @@ export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
const clearMessages = useCallback(() => {
setError(null);
setAssistantNote(null);
setFilterRejected(false);
setCommitted(null);
runStartMessageIndexRef.current = 0;
runtime.thread.import({ messages: [], headId: null });
}, [runtime]);
@ -209,6 +239,8 @@ export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
sendMessage,
clearMessages,
latestQuery: committed?.query ?? null,
latestQueryLabel: committed?.label ?? null,
filterRejected,
assistantNote,
dismissAssistantNote,
};

View File

@ -330,6 +330,7 @@ export function DataGridToolbar<TRow>({
() => columns.some((c) => c.type === "date" || c.type === "dateTime"),
[columns],
);
const hasExtraActions = React.Children.toArray(extraActions).length > 0;
return (
<div className="flex w-full min-w-0 flex-col gap-2 px-2.5 py-2.5 pr-3 border-b border-foreground/[0.06] sm:flex-row sm:items-center sm:gap-2">
@ -345,7 +346,7 @@ export function DataGridToolbar<TRow>({
{extra}
</div>
<div className="flex shrink-0 items-center justify-end gap-1.5">
{extraActions != null && (
{hasExtraActions && (
<>
{extraActions}
<div

View File

@ -5,7 +5,9 @@ import { useMemo, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createDefaultDataGridState,
DATA_GRID_DEFAULT_STRINGS,
DataGrid,
DataGridToolbar,
isDataGridInteractiveRowClickTarget,
useDataSource,
type DataGridColumnDef,
@ -49,6 +51,41 @@ const wideColumns: DataGridColumnDef<Row>[] = [
},
];
describe("DataGridToolbar action divider", () => {
const ctx = {
state: createDefaultDataGridState(columns),
onChange: vi.fn(),
columns,
visibleColumns: columns,
totalRowCount: 1,
selectedRowCount: 0,
strings: DATA_GRID_DEFAULT_STRINGS,
exportCsv: vi.fn(),
};
afterEach(cleanup);
it("does not render a divider for an empty React node", () => {
const { container } = render(
<DataGridToolbar ctx={ctx} extraActions={false} />,
);
expect(container.querySelector(".mx-0\\.5.h-4.w-px")).toBeNull();
});
it("renders the divider when an action is visible", () => {
const { container, getByRole } = render(
<DataGridToolbar
ctx={ctx}
extraActions={<button type="button">Refresh</button>}
/>,
);
expect(getByRole("button", { name: "Refresh" })).not.toBeNull();
expect(container.querySelector(".mx-0\\.5.h-4.w-px")).not.toBeNull();
});
});
type ObserverRecord = {
options?: IntersectionObserverInit,
};

View File

@ -62,6 +62,12 @@ import type {
// (no `fillHeight`, no `maxHeight`). Leaves ~16rem of room for the top bar, page header, and grid
// toolbar. See the `effectiveMaxHeight` comment in DataGrid for why an infinite grid must be bounded.
const DEFAULT_INFINITE_MAX_HEIGHT = "calc(100dvh - 16rem)";
const DATA_GRID_SCROLLBAR_CLASS_NAME = cn(
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
);
// ─── Row click target ────────────────────────────────────────────────
@ -1073,19 +1079,6 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
const horizontalScrollbarAtTop = horizontalScrollbarPosition === "top";
// Shared thin thumb styling. When the bar is on top, the header scrollport shows
// it; the body keeps overflow-x hidden so a second thumb doesn't appear at the bottom.
const headerScrollbarClassName = cn(
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
);
const bodyScrollbarClassName = cn(
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
);
// Trackpad / shift-wheel horizontal gestures land on the body; with the top
// scrollbar the body has overflow-x:hidden, so forward deltaX to the header.
const handleBodyWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
@ -1206,7 +1199,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
className={cn(
"w-full min-w-0 shrink-0 border-b border-foreground/[0.06]",
horizontalScrollbarAtTop
? cn("overflow-x-auto overflow-y-hidden", headerScrollbarClassName)
? cn("overflow-x-auto overflow-y-hidden", DATA_GRID_SCROLLBAR_CLASS_NAME)
: "overflow-hidden",
)}
onScroll={horizontalScrollbarAtTop ? handleHeaderScroll : undefined}
@ -1256,7 +1249,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
// under the rows. scrollLeft is still set programmatically from the header.
horizontalScrollbarAtTop ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
isBounded ? "min-h-0 flex-1" : "flex-none",
bodyScrollbarClassName,
DATA_GRID_SCROLLBAR_CLASS_NAME,
)}
onScroll={handleBodyScroll}
onWheel={horizontalScrollbarAtTop ? handleBodyWheel : undefined}