From 6dccbc43fa4ea63baa54acfa04e9dfebd83a158c Mon Sep 17 00:00:00 2001 From: Nicholas Kolean Date: Tue, 16 Jun 2026 08:30:18 -0600 Subject: [PATCH 1/3] feat(studio): row pinning for ExperimentGroupDataView Signed-off-by: Nicholas Kolean --- .../ExperimentGroupDataView/index.tsx | 56 +++++++++++++++++-- .../usePinnedExperiments.ts | 40 +++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 web/packages/studio/src/components/dataViews/ExperimentGroupDataView/usePinnedExperiments.ts diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx index 5e6cc41e17..f537796cb0 100644 --- a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx @@ -18,13 +18,14 @@ import type { ExperimentResponse, ListExperimentsSort, } from '@nemo/sdk/generated/platform/schema'; -import { Text, Tooltip } from '@nvidia/foundations-react-core'; +import { Button, Text, Tooltip } from '@nvidia/foundations-react-core'; import { Empty } from '@studio/components/dataViews/ExperimentGroupDataView/Empty'; +import { usePinnedExperiments } from '@studio/components/dataViews/ExperimentGroupDataView/usePinnedExperiments'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getExperimentDetailRoute } from '@studio/routes/utils'; import { tooltipClassName } from '@studio/styles/common'; import { keepPreviousData } from '@tanstack/react-query'; -import { Columns3 } from 'lucide-react'; +import { Columns3, Pin } from 'lucide-react'; import { type ComponentProps, type FC, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -64,8 +65,12 @@ export const ExperimentGroupDataView: FC = ({ defaultSort: { id: 'created_at', desc: true }, // created_by isn't returned by the API and updated_at isn't shown; both are filter-only. columnVisibility: { created_by: false, updated_at: false }, + // Keep the pin toggle reachable while horizontally scrolling this wide table. + columnPinning: { left: ['pin'] }, }); + const { pinnedSet, togglePin } = usePinnedExperiments(workspace, experimentGroupName); + const page = dataViewState.pagination.state.pageIndex + 1; const pageSize = dataViewState.pagination.state.pageSize; const sortParam = getSortParamWithWhitelist( @@ -106,6 +111,17 @@ export const ExperimentGroupDataView: FC = ({ [experimentsData] ); + // Float pinned experiments to the top of the current page. The list is server-sorted and + // server-paginated (dataMode="manual"), so the rendered order follows this array directly: + // pinned rows first, then the rest, each preserving the server's sort order (stable partition). + const orderedData = useMemo(() => { + if (pinnedSet.size === 0) return tableData; + return [ + ...tableData.filter((row) => pinnedSet.has(row.id)), + ...tableData.filter((row) => !pinnedSet.has(row.id)), + ]; + }, [tableData, pinnedSet]); + // One score column per evaluator: the union of evaluator names across the loaded rows, // sorted for a deterministic column order across renders and page changes. const evaluatorNames = useMemo( @@ -128,7 +144,37 @@ export const ExperimentGroupDataView: FC = ({ const makeColumns = useCallback< ComponentProps>['makeColumns'] >( - ({ accessor }) => [ + ({ accessor, display }) => [ + display({ + id: 'pin', + header: () => Pinned, + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 48, + minSize: 48, + maxSize: 48, + meta: { alignment: 'center', _isPrebuiltColumn: true, _isSizeInitialized: true }, + cell: ({ row }) => { + const { id } = row.original; + const isPinned = pinnedSet.has(id); + return ( + + ); + }, + }), accessor('name', { header: 'Name', enableSorting: true, @@ -282,7 +328,7 @@ export const ExperimentGroupDataView: FC = ({ }, }), ], - [evaluatorNames, metadataKeys] + [evaluatorNames, pinnedSet, togglePin, metadataKeys] ); if (groupError) { @@ -317,7 +363,7 @@ export const ExperimentGroupDataView: FC = ({ } attributes={{ DataViewRoot: { - data: tableData, + data: orderedData, totalCount, requestStatus: isGroupLoading || (isLoading && !experimentsData) ? 'loading' : undefined, }, diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/usePinnedExperiments.ts b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/usePinnedExperiments.ts new file mode 100644 index 0000000000..c6c1010b55 --- /dev/null +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/usePinnedExperiments.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLocalStorage } from '@studio/util/hooks/useLocalStorage'; +import { useCallback, useMemo } from 'react'; + +/** localStorage key prefix for pinned experiment ids, scoped per workspace + group below. */ +const PINNED_STORAGE_PREFIX = 'nemo:experiments:pinned'; + +export interface PinnedExperiments { + /** Set of pinned experiment ids for O(1) membership checks. */ + pinnedSet: Set; + /** Pins the experiment if unpinned, unpins it otherwise. */ + togglePin: (id: string) => void; +} + +/** + * Tracks which experiments the user has pinned to the top of the list, persisted in the browser's + * localStorage. Pins are scoped per workspace + experiment group so each group keeps its own set. + * + * `groupName` is used (rather than the group id) because it is available synchronously from the + * route, avoiding a key change while the group id loads asynchronously. + */ +export function usePinnedExperiments(workspace: string, groupName: string): PinnedExperiments { + const [pinnedIds = [], setPinnedIds] = useLocalStorage( + `${PINNED_STORAGE_PREFIX}:${workspace}:${groupName}`, + [] + ); + + const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]); + + const togglePin = useCallback( + (id: string) => { + setPinnedIds(pinnedSet.has(id) ? pinnedIds.filter((p) => p !== id) : [...pinnedIds, id]); + }, + [pinnedIds, pinnedSet, setPinnedIds] + ); + + return { pinnedSet, togglePin }; +} From 28d0561372e5b2a46e85206d6fdf412d7f399cdc Mon Sep 17 00:00:00 2001 From: Nicholas Kolean Date: Tue, 23 Jun 2026 15:39:09 -0600 Subject: [PATCH 2/3] change from local storage to two query approach with pinned status saved in be Signed-off-by: Nicholas Kolean --- .../ExperimentGroupDataView/index.tsx | 83 +++----- .../useExperimentGroupExperiments.test.ts | 180 ++++++++++++++++ .../useExperimentGroupExperiments.ts | 200 ++++++++++++++++++ .../usePinnedExperiments.ts | 40 ---- 4 files changed, 406 insertions(+), 97 deletions(-) create mode 100644 web/packages/studio/src/components/dataViews/ExperimentGroupDataView/useExperimentGroupExperiments.test.ts create mode 100644 web/packages/studio/src/components/dataViews/ExperimentGroupDataView/useExperimentGroupExperiments.ts delete mode 100644 web/packages/studio/src/components/dataViews/ExperimentGroupDataView/usePinnedExperiments.ts diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx index f537796cb0..3f2e2c41f1 100644 --- a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx @@ -12,24 +12,22 @@ import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; import { snakeCaseToTitleCase } from '@nemo/common/src/utils/formatters'; import { getSortParamWithWhitelist } from '@nemo/common/src/utils/query'; -import { useGetExperimentGroup, useListExperiments } from '@nemo/sdk/generated/platform/api'; -import type { - ExperimentFilter, - ExperimentResponse, - ListExperimentsSort, -} from '@nemo/sdk/generated/platform/schema'; +import { useGetExperimentGroup } from '@nemo/sdk/generated/platform/api'; +import type { ExperimentFilter } from '@nemo/sdk/generated/platform/schema'; import { Button, Text, Tooltip } from '@nvidia/foundations-react-core'; import { Empty } from '@studio/components/dataViews/ExperimentGroupDataView/Empty'; -import { usePinnedExperiments } from '@studio/components/dataViews/ExperimentGroupDataView/usePinnedExperiments'; +import { + type ExperimentRow, + useExperimentGroupExperiments, +} from '@studio/components/dataViews/ExperimentGroupDataView/useExperimentGroupExperiments'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getExperimentDetailRoute } from '@studio/routes/utils'; import { tooltipClassName } from '@studio/styles/common'; -import { keepPreviousData } from '@tanstack/react-query'; import { Columns3, Pin } from 'lucide-react'; import { type ComponentProps, type FC, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -export type ExperimentRow = ExperimentResponse & { id: string }; +export type { ExperimentRow }; const SORTABLE_FIELDS = ['name', 'created_at'] as const; const DEFAULT_SORT = '-created_at'; @@ -69,8 +67,6 @@ export const ExperimentGroupDataView: FC = ({ columnPinning: { left: ['pin'] }, }); - const { pinnedSet, togglePin } = usePinnedExperiments(workspace, experimentGroupName); - const page = dataViewState.pagination.state.pageIndex + 1; const pageSize = dataViewState.pagination.state.pageSize; const sortParam = getSortParamWithWhitelist( @@ -80,53 +76,26 @@ export const ExperimentGroupDataView: FC = ({ ); const { - data: experimentsResponse, - isLoading, + rows: orderedData, + togglePin, + totalCount, error, - } = useListExperiments( + isLoading, + } = useExperimentGroupExperiments({ workspace, - { - page, - page_size: pageSize, - sort: sortParam as ListExperimentsSort, - // User filters merge under the group scope, which always wins so it can't be overridden. - filter: { - ...dataViewState.apiFilter.filter, - ...(dataViewState.searchBar.state && { name: { $like: dataViewState.searchBar.state } }), - experiment_group_id: experimentGroupId, - } as ExperimentFilter, - }, - { query: { placeholderData: keepPreviousData, enabled: !!experimentGroupId } } - ); - - const experimentsData = experimentsResponse?.data; - const totalCount = experimentsResponse?.pagination?.total_results ?? experimentsData?.length ?? 0; - - const tableData = useMemo( - () => - (experimentsData ?? []).map((experiment) => ({ - ...experiment, - id: experiment.id ?? experiment.name ?? '', - })), - [experimentsData] - ); - - // Float pinned experiments to the top of the current page. The list is server-sorted and - // server-paginated (dataMode="manual"), so the rendered order follows this array directly: - // pinned rows first, then the rest, each preserving the server's sort order (stable partition). - const orderedData = useMemo(() => { - if (pinnedSet.size === 0) return tableData; - return [ - ...tableData.filter((row) => pinnedSet.has(row.id)), - ...tableData.filter((row) => !pinnedSet.has(row.id)), - ]; - }, [tableData, pinnedSet]); + experimentGroupId, + filter: dataViewState.apiFilter.filter, + search: dataViewState.debouncedSearchBar, + page, + pageSize, + sort: sortParam, + }); // One score column per evaluator: the union of evaluator names across the loaded rows, // sorted for a deterministic column order across renders and page changes. const evaluatorNames = useMemo( - () => [...new Set(tableData.flatMap((e) => Object.keys(e.aggregate_scores ?? {})))].sort(), - [tableData] + () => [...new Set(orderedData.flatMap((e) => Object.keys(e.aggregate_scores ?? {})))].sort(), + [orderedData] ); // One column per metadata key: keys are lowercased so case variants (e.g. "status" @@ -156,8 +125,8 @@ export const ExperimentGroupDataView: FC = ({ maxSize: 48, meta: { alignment: 'center', _isPrebuiltColumn: true, _isSizeInitialized: true }, cell: ({ row }) => { - const { id } = row.original; - const isPinned = pinnedSet.has(id); + const { pinned_at } = row.original; + const isPinned = pinned_at != null; return (