From 54167b7f63857aa75201b84a007a194fefb45d94 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 14 Jul 2026 16:57:28 -0700 Subject: [PATCH] fixes and suggestions implemented --- .../analytics/tables/page-client.tsx | 52 +++++++---- .../analytics/tables/query-data-grid.tsx | 17 ++-- .../analytics/tables/search-bar-logic.test.ts | 18 ++++ .../analytics/tables/search-bar-logic.ts | 10 ++- .../analytics/tables/table-search-bar.tsx | 16 +--- .../tables/use-ai-table-filter-chat.test.ts | 62 ++++++++++++- .../tables/use-ai-table-filter-chat.ts | 90 +++++++++++++------ .../data-grid/data-grid-toolbar.tsx | 3 +- .../components/data-grid/data-grid.test.tsx | 37 ++++++++ .../src/components/data-grid/data-grid.tsx | 23 ++--- 10 files changed, 236 insertions(+), 92 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx index e733f1381..05d09a8c6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx @@ -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([ ], ]); +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 - // 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() { - {/* 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). */} -
+
+ + +
+ + {/* 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. */} +
{/* 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. */}
@@ -267,7 +282,11 @@ export default function PageClient() {
- {/* 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: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 ? ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx index 1da247610..d098900d2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx @@ -578,11 +578,10 @@ export const QueryDataGrid = forwardRef * * 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 extra={extras} extraLeading={leading} extraActions={actions} - hideQuickSearch + hideQuickSearch={searchBar !== undefined} /> ); }, @@ -627,7 +626,7 @@ export const QueryDataGrid = forwardRef const resolvedToolbar = toolbar ? renderForwardedToolbar - : searchBar !== undefined + : searchBar !== undefined || toolbarActions !== undefined ? renderCustomToolbar : undefined; @@ -635,11 +634,11 @@ export const QueryDataGrid = forwardRef // 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) => toolbarExtra(extendCtx(ctx)); - }, [toolbar, searchBar, toolbarExtra, extendCtx]); + }, [toolbar, searchBar, toolbarActions, toolbarExtra, extendCtx]); return (
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts index 86190281a..f9cbbd2ab 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts @@ -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 diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts index 0d28bac66..ef388b6f9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts @@ -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.]
` 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.]
` 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; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx index 04d3ec5b0..c7e9367cc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/table-search-bar.tsx @@ -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." diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts index a785379fd..ccf623568 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.test.ts @@ -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", }); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts index 8513caf16..39b2a663f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-table-filter-chat.ts @@ -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
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
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(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, }; diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx index 38ec1f9b0..44182ec50 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx @@ -330,6 +330,7 @@ export function DataGridToolbar({ () => columns.some((c) => c.type === "date" || c.type === "dateTime"), [columns], ); + const hasExtraActions = React.Children.toArray(extraActions).length > 0; return (
@@ -345,7 +346,7 @@ export function DataGridToolbar({ {extra}
- {extraActions != null && ( + {hasExtraActions && ( <> {extraActions}
[] = [ }, ]; +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( + , + ); + + expect(container.querySelector(".mx-0\\.5.h-4.w-px")).toBeNull(); + }); + + it("renders the divider when an action is visible", () => { + const { container, getByRole } = render( + Refresh} + />, + ); + + expect(getByRole("button", { name: "Refresh" })).not.toBeNull(); + expect(container.querySelector(".mx-0\\.5.h-4.w-px")).not.toBeNull(); + }); +}); + type ObserverRecord = { options?: IntersectionObserverInit, }; diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx index c8763af3d..1150041e7 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx @@ -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(props: DataGridProps) { 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) => { @@ -1206,7 +1199,7 @@ export function DataGrid(props: DataGridProps) { 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(props: DataGridProps) { // 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}