diff --git a/web/packages/common/src/components/Chat/MessageContent/MarkdownDataViewTable.tsx b/web/packages/common/src/components/Chat/MessageContent/MarkdownDataViewTable.tsx new file mode 100644 index 0000000000..fb0a4ca75b --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/MarkdownDataViewTable.tsx @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getMarkdownTableOptions, + parseMarkdownTable, +} from '@nemo/common/src/components/Chat/MessageContent/helpers'; +import { MarkdownTableCell } from '@nemo/common/src/components/Chat/MessageContent/MarkdownTableCell'; +import type { + MarkdownDataViewTableProps, + MarkdownTableRow, +} from '@nemo/common/src/components/Chat/MessageContent/types'; +import * as DataView from '@nemo/common/src/components/DataView/internal'; +import { type FC, type MouseEvent, useCallback, useMemo, useState } from 'react'; + +export const MarkdownDataViewTable: FC = ({ children, options }) => { + const tableOptions = useMemo(() => getMarkdownTableOptions(options), [options]); + const { columns, rows } = useMemo(() => parseMarkdownTable(children), [children]); + const dataViewState = DataView.useDataViewState(); + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()); + const data = useMemo( + () => rows.map((row) => ({ ...row, expandedRowIds })), + [expandedRowIds, rows] + ); + const toggleExpandedRow = useCallback((rowId: string) => { + setExpandedRowIds((current) => { + const next = new Set(current); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return next; + }); + }, []); + const handleTableClick = useCallback( + (event: MouseEvent) => { + if (!tableOptions.expandableCells || !(event.target instanceof Element)) return; + + const rowElement = event.target.closest('tbody tr[data-row-id]'); + const rowId = rowElement?.getAttribute('data-row-id'); + if (!rowId) return; + + toggleExpandedRow(rowId); + }, + [tableOptions.expandableCells, toggleExpandedRow] + ); + const makeColumns = useMemo>( + () => (columnHelper) => + columns.map((column, columnIndex) => + columnHelper.accessor((row) => row.cellValues[columnIndex] ?? '', { + id: column.id, + header: () => column.header, + cell: ({ row }) => { + return ( + toggleExpandedRow(row.original.id)} + > + {row.original.cells[columnIndex] ?? ''} + + ); + }, + enableResizing: false, + enableSorting: true, + }) + ), + [columns, tableOptions, toggleExpandedRow] + ); + + if (!columns.length) return null; + + return ( + + + + + .data-view-header-control]:!max-w-none [&_.data-view-header-control>span]:!overflow-visible [&_.data-view-header-control>span]:!text-clip ${tableOptions.expandableCells ? '[&_tbody_tr]:cursor-pointer' : ''}`} + density="compact" + layout="auto" + onClick={handleTableClick} + stickyTableHeader={false} + /> + + ); +}; diff --git a/web/packages/common/src/components/Chat/MessageContent/MarkdownParagraph.tsx b/web/packages/common/src/components/Chat/MessageContent/MarkdownParagraph.tsx new file mode 100644 index 0000000000..2fa140425c --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/MarkdownParagraph.tsx @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '@nvidia/foundations-react-core'; +import { type FC, type PropsWithChildren } from 'react'; + +export const MarkdownParagraph: FC = ({ children }) => ( + +

{children}

+
+); diff --git a/web/packages/common/src/components/Chat/MessageContent/MarkdownTableCell.tsx b/web/packages/common/src/components/Chat/MessageContent/MarkdownTableCell.tsx new file mode 100644 index 0000000000..44e0bb6aaa --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/MarkdownTableCell.tsx @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getNodeText } from '@nemo/common/src/components/Chat/MessageContent/helpers'; +import type { MarkdownTableCellProps } from '@nemo/common/src/components/Chat/MessageContent/types'; + +export const MarkdownTableCell = ({ + children, + expanded, + expandable, + onToggle, +}: MarkdownTableCellProps) => { + if (!expandable) { + return {children}; + } + + const text = getNodeText(children); + + return ( + + ); +}; diff --git a/web/packages/common/src/components/Chat/MessageContent/constants.ts b/web/packages/common/src/components/Chat/MessageContent/constants.ts new file mode 100644 index 0000000000..d33d6187da --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/constants.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MarkdownTableOptions } from '@nemo/common/src/components/Chat/MessageContent/types'; + +export const INLINE_CODE_CLASS = + 'rounded bg-gray-050 px-1 py-0.5 font-sans text-sm dark:bg-gray-800'; + +export const DEFAULT_MARKDOWN_TABLE_OPTIONS: Required = { + expandableCells: true, +}; diff --git a/web/packages/common/src/components/Chat/MessageContent/helpers.tsx b/web/packages/common/src/components/Chat/MessageContent/helpers.tsx new file mode 100644 index 0000000000..ff508eb03c --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/helpers.tsx @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DEFAULT_MARKDOWN_TABLE_OPTIONS } from '@nemo/common/src/components/Chat/MessageContent/constants'; +import { MarkdownParagraph } from '@nemo/common/src/components/Chat/MessageContent/MarkdownParagraph'; +import type { + ElementWithChildrenProps, + MarkdownTableData, + MarkdownTableOptions, +} from '@nemo/common/src/components/Chat/MessageContent/types'; +import { Text } from '@nvidia/foundations-react-core'; +import { childrenToText } from '@nvidia/foundations-react-core/lib'; +import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'; + +export const isElementWithChildren = ( + node: ReactNode +): node is ReactElement => isValidElement(node); + +export const isElementNamed = ( + node: ReactNode, + elementName: 'thead' | 'tbody' | 'tr' | 'th' | 'td' | 'p' +): node is ReactElement => + isElementWithChildren(node) && node.type === elementName; + +export const getChildNodes = (node: ReactElement): ReactNode[] => + Children.toArray(node.props.children); + +export const isWhitespaceTextNode = (node: ReactNode): node is string => + typeof node === 'string' && node.trim().length === 0; + +export const isMarkdownParagraphElement = ( + node: ReactNode +): node is ReactElement => + isElementWithChildren(node) && (node.type === MarkdownParagraph || isElementNamed(node, 'p')); + +export const renderListItemChildren = (children: ReactNode): ReactNode => { + const childNodes = Children.toArray(children); + const firstContentIndex = childNodes.findIndex((child) => !isWhitespaceTextNode(child)); + const firstContent = childNodes[firstContentIndex]; + + if (firstContentIndex === -1 || !isMarkdownParagraphElement(firstContent)) { + return children; + } + + return [ + + {getChildNodes(firstContent)} + , + ...childNodes.slice(firstContentIndex + 1), + ]; +}; + +export const getRowCells = (row: ReactElement): readonly ReactNode[] => + getChildNodes(row) + .filter((cell) => isElementNamed(cell, 'th') || isElementNamed(cell, 'td')) + .map((cell) => cell.props.children ?? ''); + +export const getSectionRows = ( + section: ReactElement | undefined +): readonly (readonly ReactNode[])[] => { + if (!section) return []; + + return getChildNodes(section) + .filter((child) => isElementNamed(child, 'tr')) + .map(getRowCells); +}; + +export const getNodeText = (node: ReactNode): string => childrenToText(node).trim(); + +export const getMarkdownTableOptions = ( + options: MarkdownTableOptions | undefined +): Required => ({ + ...DEFAULT_MARKDOWN_TABLE_OPTIONS, + ...options, +}); + +export const parseMarkdownTable = (children: ReactNode): MarkdownTableData => { + const tableChildren = Children.toArray(children); + const head = tableChildren.find((child) => isElementNamed(child, 'thead')); + const bodies = tableChildren.filter((child) => isElementNamed(child, 'tbody')); + const headerRows = getSectionRows(head); + const bodyRows = bodies.flatMap(getSectionRows); + const headerCells = headerRows[0] ?? bodyRows[0] ?? []; + const dataRows = headerRows.length > 0 ? bodyRows : bodyRows.slice(1); + const columnCount = Math.max(headerCells.length, ...dataRows.map((row) => row.length)); + + return { + columns: Array.from({ length: columnCount }, (_, index) => ({ + id: `column-${index}`, + header: headerCells[index] ?? `Column ${index + 1}`, + })), + rows: dataRows.map((cells, rowIndex) => ({ + id: `row-${rowIndex}`, + cells: Array.from({ length: columnCount }, (_, cellIndex) => cells[cellIndex] ?? ''), + cellValues: Array.from({ length: columnCount }, (_, cellIndex) => + getNodeText(cells[cellIndex] ?? '') + ), + })), + }; +}; diff --git a/web/packages/common/src/components/Chat/MessageContent/index.tsx b/web/packages/common/src/components/Chat/MessageContent/index.tsx index 2053152d0e..90541f27b3 100644 --- a/web/packages/common/src/components/Chat/MessageContent/index.tsx +++ b/web/packages/common/src/components/Chat/MessageContent/index.tsx @@ -1,487 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { messageMarkdownComponents } from '@nemo/common/src/components/Chat/MessageContent/markdownComponents'; +import { MarkdownDataViewTable } from '@nemo/common/src/components/Chat/MessageContent/MarkdownDataViewTable'; +import { remarkNormalizeEmptyOrderedListMarkers } from '@nemo/common/src/components/Chat/MessageContent/remarkPlugin'; +import type { MessageContentProps } from '@nemo/common/src/components/Chat/MessageContent/types'; import { splitMessageWithLabels } from '@nemo/common/src/components/Chat/MessageContent/utils'; import { CodeDisplay } from '@nemo/common/src/components/CodeDisplay'; -import * as DataView from '@nemo/common/src/components/DataView/internal'; import { simpleHash } from '@nemo/common/src/utils/simpleHash'; import { Stack, Text } from '@nvidia/foundations-react-core'; -import { childrenToText } from '@nvidia/foundations-react-core/lib'; -import cn from 'classnames'; import { decode } from 'html-entities'; -import { - Children, - type FC, - isValidElement, - type MouseEvent, - type PropsWithChildren, - type ReactElement, - type ReactNode, - useCallback, - useMemo, - useState, -} from 'react'; +import { type FC, type PropsWithChildren, useMemo } from 'react'; import Markdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; -export interface MarkdownTableOptions { - expandableCells?: boolean; -} - -export interface MessageContentProps { - content?: string | null; - markdownLinkComponent?: Components['a']; - markdownTableOptions?: MarkdownTableOptions; - renderAsMarkdown?: boolean; -} - -const INLINE_CODE_CLASS = 'rounded bg-gray-050 px-1 py-0.5 font-sans text-sm dark:bg-gray-800'; - -interface MarkdownTableColumn { - id: string; - header: ReactNode; -} - -interface MarkdownTableRow { - id: string; - cells: readonly ReactNode[]; - cellValues: readonly string[]; - expandedRowIds?: ReadonlySet; -} - -interface ElementWithChildrenProps { - children?: ReactNode; -} - -interface MarkdownAstNode { - children?: MarkdownAstNode[]; - ordered?: boolean | null; - spread?: boolean; - start?: number | null; - type: string; - value?: unknown; -} - -interface MarkdownAstParent extends MarkdownAstNode { - children: MarkdownAstNode[]; -} - -interface MarkdownAstListNode extends MarkdownAstParent { - ordered?: boolean | null; - start?: number | null; - type: 'list'; -} - -interface MarkdownAstListItemNode extends MarkdownAstParent { - spread?: boolean; - type: 'listItem'; -} - -interface MarkdownTableData { - columns: readonly MarkdownTableColumn[]; - rows: readonly MarkdownTableRow[]; -} - -const MarkdownParagraph: FC = ({ children }) => ( - -

{children}

-
-); - -interface MarkdownDataViewTableProps extends PropsWithChildren { - options?: MarkdownTableOptions; -} - -interface MarkdownTableCellProps { - children: ReactNode; - expanded: boolean; - expandable: boolean; - onToggle: () => void; -} - -const DEFAULT_MARKDOWN_TABLE_OPTIONS: Required = { - expandableCells: true, -}; - -const isElementWithChildren = (node: ReactNode): node is ReactElement => - isValidElement(node); - -const isElementNamed = ( - node: ReactNode, - elementName: 'thead' | 'tbody' | 'tr' | 'th' | 'td' | 'p' -): node is ReactElement => - isElementWithChildren(node) && node.type === elementName; - -const getChildNodes = (node: ReactElement): ReactNode[] => - Children.toArray(node.props.children); - -const isWhitespaceTextNode = (node: ReactNode): node is string => - typeof node === 'string' && node.trim().length === 0; - -const isMarkdownParagraphElement = ( - node: ReactNode -): node is ReactElement => - isElementWithChildren(node) && (node.type === MarkdownParagraph || isElementNamed(node, 'p')); - -const renderListItemChildren = (children: ReactNode): ReactNode => { - const childNodes = Children.toArray(children); - const firstContentIndex = childNodes.findIndex((child) => !isWhitespaceTextNode(child)); - const firstContent = childNodes[firstContentIndex]; - - if (firstContentIndex === -1 || !isMarkdownParagraphElement(firstContent)) { - return children; - } - - return [ - - {getChildNodes(firstContent)} - , - ...childNodes.slice(firstContentIndex + 1), - ]; -}; - -const getRowCells = (row: ReactElement): readonly ReactNode[] => - getChildNodes(row) - .filter((cell) => isElementNamed(cell, 'th') || isElementNamed(cell, 'td')) - .map((cell) => cell.props.children ?? ''); - -const getSectionRows = ( - section: ReactElement | undefined -): readonly (readonly ReactNode[])[] => { - if (!section) return []; - - return getChildNodes(section) - .filter((child) => isElementNamed(child, 'tr')) - .map(getRowCells); -}; - -const getNodeText = (node: ReactNode): string => childrenToText(node).trim(); - -const isMarkdownAstNode = (value: unknown): value is MarkdownAstNode => - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string'; - -const hasMarkdownAstChildren = (node: MarkdownAstNode): node is MarkdownAstParent => - Array.isArray(node.children); - -const isMarkdownAstList = (node: MarkdownAstNode): node is MarkdownAstListNode => - node.type === 'list' && hasMarkdownAstChildren(node); - -const isMarkdownAstListItem = (node: MarkdownAstNode): node is MarkdownAstListItemNode => - node.type === 'listItem' && hasMarkdownAstChildren(node); - -const isMarkdownAstParagraph = (node: MarkdownAstNode): boolean => node.type === 'paragraph'; - -const getMarkdownAstText = (node: MarkdownAstNode): string => { - if (typeof node.value === 'string') return node.value; - if (!hasMarkdownAstChildren(node)) return ''; - return node.children.map(getMarkdownAstText).join(''); -}; - -const isEmptyMarkdownAstListItem = ( - node: MarkdownAstNode | undefined -): node is MarkdownAstListItemNode => - isMarkdownAstNode(node) && - isMarkdownAstListItem(node) && - (node.children.length === 0 || - node.children.every( - (child) => isMarkdownAstParagraph(child) && !getMarkdownAstText(child).trim() - )); - -const getMarkdownListStart = (node: MarkdownAstListNode): number => node.start ?? 1; - -const shouldMergeOrderedLists = ( - currentNode: MarkdownAstNode, - nextNode: MarkdownAstNode | undefined -): nextNode is MarkdownAstListNode => { - if (!isMarkdownAstList(currentNode) || !currentNode.ordered) return false; - if (!nextNode || !isMarkdownAstList(nextNode) || !nextNode.ordered) return false; - - return ( - getMarkdownListStart(nextNode) === - getMarkdownListStart(currentNode) + currentNode.children.length - ); -}; - -const mergeAdjacentOrderedLists = (children: MarkdownAstNode[], index: number): void => { - const currentNode = children[index]; - if (!currentNode || !isMarkdownAstList(currentNode)) return; - - while (true) { - const nextNode = children[index + 1]; - if (!shouldMergeOrderedLists(currentNode, nextNode)) break; - - currentNode.children.push(...nextNode.children); - children.splice(index + 1, 1); - } -}; - -const mergeEmptyOrderedListMarker = (children: MarkdownAstNode[], index: number): void => { - const currentNode = children[index]; - const nextNode = children[index + 1]; - if (!currentNode || !nextNode || !isMarkdownAstList(currentNode) || !currentNode.ordered) return; - if (currentNode.children.length !== 1 || !isEmptyMarkdownAstListItem(currentNode.children[0])) { - return; - } - if (!isMarkdownAstParagraph(nextNode)) return; - - const listItem = currentNode.children[0]; - listItem.children = [nextNode]; - listItem.spread = false; - currentNode.spread = false; - - const followingNode = children[index + 2]; - const shouldNestFollowingUnorderedList = - followingNode !== undefined && - isMarkdownAstList(followingNode) && - followingNode.ordered !== true; - - if (shouldNestFollowingUnorderedList) { - listItem.children.push(followingNode); - children.splice(index + 1, 2); - return; - } - - children.splice(index + 1, 1); -}; - -const normalizeMarkdownAstLists = (parent: MarkdownAstParent): void => { - for (let index = 0; index < parent.children.length; index++) { - mergeEmptyOrderedListMarker(parent.children, index); - } - - for (let index = 0; index < parent.children.length; index++) { - mergeAdjacentOrderedLists(parent.children, index); - } - - for (let index = 0; index < parent.children.length; index++) { - const child = parent.children[index]; - if (child && hasMarkdownAstChildren(child)) normalizeMarkdownAstLists(child); - } -}; - -const remarkNormalizeEmptyOrderedListMarkers = - () => - (tree: unknown): void => { - if (!isMarkdownAstNode(tree) || !hasMarkdownAstChildren(tree)) return; - normalizeMarkdownAstLists(tree); - }; - -const getMarkdownTableOptions = ( - options: MarkdownTableOptions | undefined -): Required => ({ - ...DEFAULT_MARKDOWN_TABLE_OPTIONS, - ...options, -}); - -const MarkdownTableCell = ({ - children, - expanded, - expandable, - onToggle, -}: MarkdownTableCellProps) => { - if (!expandable) { - return {children}; - } - - const text = getNodeText(children); - - return ( - - ); -}; - -const parseMarkdownTable = (children: ReactNode): MarkdownTableData => { - const tableChildren = Children.toArray(children); - const head = tableChildren.find((child) => isElementNamed(child, 'thead')); - const bodies = tableChildren.filter((child) => isElementNamed(child, 'tbody')); - const headerRows = getSectionRows(head); - const bodyRows = bodies.flatMap(getSectionRows); - const headerCells = headerRows[0] ?? bodyRows[0] ?? []; - const dataRows = headerRows.length > 0 ? bodyRows : bodyRows.slice(1); - const columnCount = Math.max(headerCells.length, ...dataRows.map((row) => row.length)); - - return { - columns: Array.from({ length: columnCount }, (_, index) => ({ - id: `column-${index}`, - header: headerCells[index] ?? `Column ${index + 1}`, - })), - rows: dataRows.map((cells, rowIndex) => ({ - id: `row-${rowIndex}`, - cells: Array.from({ length: columnCount }, (_, cellIndex) => cells[cellIndex] ?? ''), - cellValues: Array.from({ length: columnCount }, (_, cellIndex) => - getNodeText(cells[cellIndex] ?? '') - ), - })), - }; -}; - -const MarkdownDataViewTable: FC = ({ children, options }) => { - const tableOptions = useMemo(() => getMarkdownTableOptions(options), [options]); - const { columns, rows } = useMemo(() => parseMarkdownTable(children), [children]); - const dataViewState = DataView.useDataViewState(); - const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()); - const data = useMemo( - () => rows.map((row) => ({ ...row, expandedRowIds })), - [expandedRowIds, rows] - ); - const toggleExpandedRow = useCallback((rowId: string) => { - setExpandedRowIds((current) => { - const next = new Set(current); - if (next.has(rowId)) { - next.delete(rowId); - } else { - next.add(rowId); - } - return next; - }); - }, []); - const handleTableClick = useCallback( - (event: MouseEvent) => { - if (!tableOptions.expandableCells || !(event.target instanceof Element)) return; - - const rowElement = event.target.closest('tbody tr[data-row-id]'); - const rowId = rowElement?.getAttribute('data-row-id'); - if (!rowId) return; - - toggleExpandedRow(rowId); - }, - [tableOptions.expandableCells, toggleExpandedRow] - ); - const makeColumns = useMemo>( - () => (columnHelper) => - columns.map((column, columnIndex) => - columnHelper.accessor((row) => row.cellValues[columnIndex] ?? '', { - id: column.id, - header: () => column.header, - cell: ({ row }) => { - return ( - toggleExpandedRow(row.original.id)} - > - {row.original.cells[columnIndex] ?? ''} - - ); - }, - enableResizing: false, - enableSorting: true, - }) - ), - [columns, tableOptions, toggleExpandedRow] - ); - - if (!columns.length) return null; - - return ( - - - - - .data-view-header-control]:!max-w-none [&_.data-view-header-control>span]:!overflow-visible [&_.data-view-header-control>span]:!text-clip ${tableOptions.expandableCells ? '[&_tbody_tr]:cursor-pointer' : ''}`} - density="compact" - layout="auto" - onClick={handleTableClick} - stickyTableHeader={false} - /> - - ); -}; - -const messageMarkdownComponents: Components = { - h1: ({ children }) => ( - -

{children}

-
- ), - h2: ({ children }) => ( - -

{children}

-
- ), - h3: ({ children }) => ( - -

{children}

-
- ), - h4: ({ children }) => ( - -

{children}

-
- ), - h5: ({ children }) => ( - -
{children}
-
- ), - h6: ({ children }) => ( - -
{children}
-
- ), - p: MarkdownParagraph, - ul: ({ children, className }) => ( -
    {children}
- ), - ol: ({ children, className, start }) => ( -
    - {children} -
- ), - li: ({ children, className }) => ( -
  • p]:my-0', - className - )} - > - {renderListItemChildren(children)} -
  • - ), - hr: () =>
    , - blockquote: ({ children, className }) => ( -
    - {children} -
    - ), - img: ({ src, alt }) => {alt, - // Most chat surfaces keep model-supplied links inert. Consumers that know how - // to handle links can provide their own renderer. - a: ({ children }) => {children}, - code: ({ children }) => {children}, - table: ({ children }) => {children}, -}; +export type { + MarkdownTableOptions, + MessageContentProps, +} from '@nemo/common/src/components/Chat/MessageContent/types'; /** * This component takes a content string from a chat response and converts into a user readable diff --git a/web/packages/common/src/components/Chat/MessageContent/markdownComponents.tsx b/web/packages/common/src/components/Chat/MessageContent/markdownComponents.tsx new file mode 100644 index 0000000000..2753099aa0 --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/markdownComponents.tsx @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { INLINE_CODE_CLASS } from '@nemo/common/src/components/Chat/MessageContent/constants'; +import { renderListItemChildren } from '@nemo/common/src/components/Chat/MessageContent/helpers'; +import { MarkdownDataViewTable } from '@nemo/common/src/components/Chat/MessageContent/MarkdownDataViewTable'; +import { MarkdownParagraph } from '@nemo/common/src/components/Chat/MessageContent/MarkdownParagraph'; +import { Text } from '@nvidia/foundations-react-core'; +import cn from 'classnames'; +import type { Components } from 'react-markdown'; + +export const messageMarkdownComponents: Components = { + h1: ({ children }) => ( + +

    {children}

    +
    + ), + h2: ({ children }) => ( + +

    {children}

    +
    + ), + h3: ({ children }) => ( + +

    {children}

    +
    + ), + h4: ({ children }) => ( + +

    {children}

    +
    + ), + h5: ({ children }) => ( + +
    {children}
    +
    + ), + h6: ({ children }) => ( + +
    {children}
    +
    + ), + p: MarkdownParagraph, + ul: ({ children, className }) => ( +
      {children}
    + ), + ol: ({ children, className, start }) => ( +
      + {children} +
    + ), + li: ({ children, className }) => ( +
  • p]:my-0', + className + )} + > + {renderListItemChildren(children)} +
  • + ), + hr: () =>
    , + blockquote: ({ children, className }) => ( +
    + {children} +
    + ), + img: ({ src, alt }) => {alt, + // Most chat surfaces keep model-supplied links inert. Consumers that know how + // to handle links can provide their own renderer. + a: ({ children }) => {children}, + code: ({ children }) => {children}, + table: ({ children }) => {children}, +}; diff --git a/web/packages/common/src/components/Chat/MessageContent/remarkPlugin.ts b/web/packages/common/src/components/Chat/MessageContent/remarkPlugin.ts new file mode 100644 index 0000000000..3adada48e8 --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/remarkPlugin.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MarkdownAstListItemNode, + MarkdownAstListNode, + MarkdownAstNode, + MarkdownAstParent, +} from '@nemo/common/src/components/Chat/MessageContent/types'; + +const isMarkdownAstNode = (value: unknown): value is MarkdownAstNode => + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string'; + +const hasMarkdownAstChildren = (node: MarkdownAstNode): node is MarkdownAstParent => + Array.isArray(node.children); + +const isMarkdownAstList = (node: MarkdownAstNode): node is MarkdownAstListNode => + node.type === 'list' && hasMarkdownAstChildren(node); + +const isMarkdownAstListItem = (node: MarkdownAstNode): node is MarkdownAstListItemNode => + node.type === 'listItem' && hasMarkdownAstChildren(node); + +const isMarkdownAstParagraph = (node: MarkdownAstNode): boolean => node.type === 'paragraph'; + +const getMarkdownAstText = (node: MarkdownAstNode): string => { + if (typeof node.value === 'string') return node.value; + if (!hasMarkdownAstChildren(node)) return ''; + return node.children.map(getMarkdownAstText).join(''); +}; + +const isEmptyMarkdownAstListItem = ( + node: MarkdownAstNode | undefined +): node is MarkdownAstListItemNode => + isMarkdownAstNode(node) && + isMarkdownAstListItem(node) && + (node.children.length === 0 || + node.children.every( + (child) => isMarkdownAstParagraph(child) && !getMarkdownAstText(child).trim() + )); + +const getMarkdownListStart = (node: MarkdownAstListNode): number => node.start ?? 1; + +const shouldMergeOrderedLists = ( + currentNode: MarkdownAstNode, + nextNode: MarkdownAstNode | undefined +): nextNode is MarkdownAstListNode => { + if (!isMarkdownAstList(currentNode) || !currentNode.ordered) return false; + if (!nextNode || !isMarkdownAstList(nextNode) || !nextNode.ordered) return false; + + return ( + getMarkdownListStart(nextNode) === + getMarkdownListStart(currentNode) + currentNode.children.length + ); +}; + +const mergeAdjacentOrderedLists = (children: MarkdownAstNode[], index: number): void => { + const currentNode = children[index]; + if (!currentNode || !isMarkdownAstList(currentNode)) return; + + while (true) { + const nextNode = children[index + 1]; + if (!shouldMergeOrderedLists(currentNode, nextNode)) break; + + currentNode.children.push(...nextNode.children); + children.splice(index + 1, 1); + } +}; + +const mergeEmptyOrderedListMarker = (children: MarkdownAstNode[], index: number): void => { + const currentNode = children[index]; + const nextNode = children[index + 1]; + if (!currentNode || !nextNode || !isMarkdownAstList(currentNode) || !currentNode.ordered) return; + if (currentNode.children.length !== 1 || !isEmptyMarkdownAstListItem(currentNode.children[0])) { + return; + } + if (!isMarkdownAstParagraph(nextNode)) return; + + const listItem = currentNode.children[0]; + listItem.children = [nextNode]; + listItem.spread = false; + currentNode.spread = false; + + const followingNode = children[index + 2]; + const shouldNestFollowingUnorderedList = + followingNode !== undefined && + isMarkdownAstList(followingNode) && + followingNode.ordered !== true; + + if (shouldNestFollowingUnorderedList) { + listItem.children.push(followingNode); + children.splice(index + 1, 2); + return; + } + + children.splice(index + 1, 1); +}; + +const normalizeMarkdownAstLists = (parent: MarkdownAstParent): void => { + for (let index = 0; index < parent.children.length; index++) { + mergeEmptyOrderedListMarker(parent.children, index); + } + + for (let index = 0; index < parent.children.length; index++) { + mergeAdjacentOrderedLists(parent.children, index); + } + + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + if (child && hasMarkdownAstChildren(child)) normalizeMarkdownAstLists(child); + } +}; + +export const remarkNormalizeEmptyOrderedListMarkers = + () => + (tree: unknown): void => { + if (!isMarkdownAstNode(tree) || !hasMarkdownAstChildren(tree)) return; + normalizeMarkdownAstLists(tree); + }; diff --git a/web/packages/common/src/components/Chat/MessageContent/types.ts b/web/packages/common/src/components/Chat/MessageContent/types.ts new file mode 100644 index 0000000000..5df591a7d3 --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/types.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropsWithChildren, ReactNode } from 'react'; +import type { Components } from 'react-markdown'; + +export interface MarkdownTableOptions { + expandableCells?: boolean; +} + +export interface MessageContentProps { + content?: string | null; + markdownLinkComponent?: Components['a']; + markdownTableOptions?: MarkdownTableOptions; + renderAsMarkdown?: boolean; +} + +export interface MarkdownTableColumn { + id: string; + header: ReactNode; +} + +export interface MarkdownTableRow { + id: string; + cells: readonly ReactNode[]; + cellValues: readonly string[]; + expandedRowIds?: ReadonlySet; +} + +export interface ElementWithChildrenProps { + children?: ReactNode; +} + +export interface MarkdownAstNode { + children?: MarkdownAstNode[]; + ordered?: boolean | null; + spread?: boolean; + start?: number | null; + type: string; + value?: unknown; +} + +export interface MarkdownAstParent extends MarkdownAstNode { + children: MarkdownAstNode[]; +} + +export interface MarkdownAstListNode extends MarkdownAstParent { + ordered?: boolean | null; + start?: number | null; + type: 'list'; +} + +export interface MarkdownAstListItemNode extends MarkdownAstParent { + spread?: boolean; + type: 'listItem'; +} + +export interface MarkdownTableData { + columns: readonly MarkdownTableColumn[]; + rows: readonly MarkdownTableRow[]; +} + +export interface MarkdownDataViewTableProps extends PropsWithChildren { + options?: MarkdownTableOptions; +} + +export interface MarkdownTableCellProps { + children: ReactNode; + expanded: boolean; + expandable: boolean; + onToggle: () => void; +} diff --git a/web/packages/studio/src/components/DatasetsTable/columns.tsx b/web/packages/studio/src/components/DatasetsTable/columns.tsx new file mode 100644 index 0000000000..e3776a6ce5 --- /dev/null +++ b/web/packages/studio/src/components/DatasetsTable/columns.tsx @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { dateTimeFilter } from '@nemo/common/src/components/DataView/dateTimeFilter'; +import * as DataView from '@nemo/common/src/components/DataView/internal'; +import { + ROW_ACTIONS_COLUMN_SIZE, + ROW_SELECTION_COLUMN_SIZE, +} from '@nemo/common/src/components/DataView/StudioDataView'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { + FilesetPurpose, + StorageConfigType, + type FilesetOutput as Dataset, +} from '@nemo/sdk/generated/platform/schema'; +import { Flex, Text } from '@nvidia/foundations-react-core'; +import { PURPOSE_LABELS } from '@studio/components/DatasetsTable/constants'; +import { getStorageBackend, getStoragePath } from '@studio/components/DatasetsTable/helpers'; +import { + type DatasetWithId, + type DatasetsTableProps, + type ModalOpenState, +} from '@studio/components/DatasetsTable/types'; +import { formatStorageBackendLabel } from '@studio/util/storageBackend'; +import { Cloud, Database } from 'lucide-react'; +import { type ComponentProps, type Dispatch, type SetStateAction } from 'react'; + +interface MakeDatasetsTableColumnsArgs { + enableSelection: DatasetsTableProps['enableSelection']; + selectionType: DatasetsTableProps['selectionType']; + enableFilters: DatasetsTableProps['enableFilters']; + enableActions: DatasetsTableProps['enableActions']; + getDatasetRoute: DatasetsTableProps['getDatasetRoute']; + renderRowActions: DatasetsTableProps['renderRowActions']; + setModalDataset: Dispatch>; + setModalOpen: Dispatch>; + handleDatasetDeleted: (deletedDataset: Dataset) => void; +} + +export function makeDatasetsTableColumns({ + enableSelection, + selectionType, + enableFilters, + enableActions, + getDatasetRoute, + renderRowActions, + setModalDataset, + setModalOpen, + handleDatasetDeleted, +}: MakeDatasetsTableColumnsArgs): ComponentProps< + typeof DataView.Root +>['makeColumns'] { + // Column definitions + const makeColumns: ComponentProps>['makeColumns'] = ( + { accessor }, + { rowSelectionColumn, rowActionsColumn } + ) => + [ + enableSelection && + rowSelectionColumn({ + size: ROW_SELECTION_COLUMN_SIZE, + ...(selectionType === 'single' && { + headerProps: { className: 'invisible' }, + }), + }), + accessor('name', { + header: 'Name', + enableSorting: enableFilters, + size: 175, + }), + accessor((row) => getStorageBackend(row.storage), { + id: 'storage_type', + header: 'Storage Backend', + size: 130, + meta: { + filter: { + label: 'Storage Backend', + type: 'single-select', + + options: [ + { value: '', label: 'All' }, + { value: StorageConfigType.local, label: 'Local' }, + { value: StorageConfigType.ngc, label: 'NGC' }, + { value: StorageConfigType.huggingface, label: 'Hugging Face' }, + { value: StorageConfigType.s3, label: 'S3' }, + ], + }, + }, + cell({ row }) { + const backend = getStorageBackend(row.original?.storage); + if (!backend) return null; + const label = formatStorageBackendLabel(backend); + const isLocal = backend === 'local'; + const Icon = isLocal ? Database : Cloud; + return ( + + + + {label} + + + ); + }, + }), + accessor((row) => row.purpose, { + id: 'purpose', + header: 'Purpose', + size: 110, + meta: { + filter: { + label: 'Purpose', + type: 'single-select', + options: [ + { value: '', label: 'All' }, + { value: FilesetPurpose.generic, label: 'Generic' }, + { value: FilesetPurpose.dataset, label: 'Dataset' }, + { value: FilesetPurpose.model, label: 'Model' }, + ], + }, + }, + cell({ row }) { + const purpose = row.original?.purpose; + return purpose ? {PURPOSE_LABELS[purpose] ?? purpose} : null; + }, + }), + accessor((row) => getStoragePath(row.storage), { + id: 'path', + header: 'Path', + size: 200, + cell({ row }) { + const path = getStoragePath(row.original?.storage); + return path ? ( + + {path} + + ) : null; + }, + }), + accessor('description', { + header: 'Description', + cell({ row }) { + return ( + + {row.original?.description} + + ); + }, + }), + accessor('created_at', { + id: 'created_at', + header: 'Created', + enableSorting: enableFilters, + size: 150, + maxSize: 150, + minSize: 150, + meta: { + filter: dateTimeFilter('Created At'), + }, + cell({ row }) { + return row.original?.created_at ? ( + + ) : null; + }, + }), + enableActions && + rowActionsColumn({ + size: ROW_ACTIONS_COLUMN_SIZE, + enableResizing: false, + rowActions: (data: DatasetWithId) => [ + ...(getDatasetRoute + ? [ + { + children: 'View', + onSelect: () => { + // Navigation handled by Link + }, + }, + ] + : []), + { + children: 'Edit', + onSelect: () => { + setModalDataset(data); + setModalOpen('edit'); + }, + }, + { + children: 'Delete', + danger: true, + onSelect: () => { + setModalDataset(data); + setModalOpen('delete'); + }, + }, + ], + cell: renderRowActions + ? ({ row }) => ( + + {renderRowActions(row.original, { + onNavigate: () => { + /* handled by Link */ + }, + onEdit: () => { + setModalDataset(row.original); + setModalOpen('edit'); + }, + onDelete: () => { + setModalDataset(row.original); + setModalOpen('delete'); + }, + onDatasetDeleted: handleDatasetDeleted, + })} + + ) + : undefined, + }), + ].filter((col): col is DataView.TanstackTable.ColumnDef => Boolean(col)); + + return makeColumns; +} diff --git a/web/packages/studio/src/components/DatasetsTable/constants.ts b/web/packages/studio/src/components/DatasetsTable/constants.ts new file mode 100644 index 0000000000..14980c7cd5 --- /dev/null +++ b/web/packages/studio/src/components/DatasetsTable/constants.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; + +export const PURPOSE_LABELS: Record = { + [FilesetPurpose.generic]: 'Generic', + [FilesetPurpose.dataset]: 'Dataset', + [FilesetPurpose.model]: 'Model', +}; diff --git a/web/packages/studio/src/components/DatasetsTable/helpers.ts b/web/packages/studio/src/components/DatasetsTable/helpers.ts new file mode 100644 index 0000000000..4a7fa69c37 --- /dev/null +++ b/web/packages/studio/src/components/DatasetsTable/helpers.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type HuggingfaceStorageConfig, + type LocalStorageConfig, + type NGCStorageConfig, + type S3StorageConfig, +} from '@nemo/sdk/generated/platform/schema'; +import { type StorageConfig } from '@studio/components/DatasetsTable/types'; +import { type StorageBackend } from '@studio/util/storageBackend'; + +export function getStorageBackend(storage: StorageConfig | undefined): StorageBackend | null { + return storage?.type ?? null; +} + +export function getStoragePath(storage: StorageConfig | undefined): string | null { + if (!storage) return null; + const s = storage as { + type?: string; + path?: string; + org?: string; + team?: string; + target?: string; + repo_id?: string; + bucket?: string; + prefix?: string; + }; + if (s.type === 'local' && 'path' in storage) { + return (storage as LocalStorageConfig).path; + } + if (s.type === 'ngc' && 'org' in storage && 'team' in storage && 'target' in storage) { + const ngc = storage as NGCStorageConfig; + return `${ngc.org}/${ngc.team}/${ngc.target}`; + } + if (s.type === 'huggingface' && 'repo_id' in storage) { + return (storage as HuggingfaceStorageConfig).repo_id; + } + if (s.type === 's3' && 'bucket' in storage) { + const s3 = storage as S3StorageConfig; + return s3.prefix ? `${s3.bucket}/${s3.prefix}` : s3.bucket; + } + return null; +} diff --git a/web/packages/studio/src/components/DatasetsTable/index.tsx b/web/packages/studio/src/components/DatasetsTable/index.tsx index d96ef28c30..9c25cdf053 100644 --- a/web/packages/studio/src/components/DatasetsTable/index.tsx +++ b/web/packages/studio/src/components/DatasetsTable/index.tsx @@ -1,34 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { dateTimeFilter } from '@nemo/common/src/components/DataView/dateTimeFilter'; -import * as DataView from '@nemo/common/src/components/DataView/internal'; -import { - ROW_ACTIONS_COLUMN_SIZE, - ROW_SELECTION_COLUMN_SIZE, - StudioDataView, -} from '@nemo/common/src/components/DataView/StudioDataView'; +import { StudioDataView } from '@nemo/common/src/components/DataView/StudioDataView'; import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage'; -import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; -import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; import { getEntityReference } from '@nemo/common/src/namedEntity'; -import { getSortParam } from '@nemo/common/src/utils/query'; -import { useFilesDeleteFileset, useFilesListFilesets } from '@nemo/sdk/generated/platform/api'; -import { - FilesetPurpose, - StorageConfigType, - type FilesetOutput as Dataset, - type GenericSortField, - type HuggingfaceStorageConfig, - type LocalStorageConfig, - type NGCStorageConfig, - type S3StorageConfig, -} from '@nemo/sdk/generated/platform/schema'; -import { Button, Flex, Text } from '@nvidia/foundations-react-core'; -import { invalidateDatasetCaches } from '@studio/api/datasets/invalidateDatasetCaches'; +import { Button } from '@nvidia/foundations-react-core'; import { DatasetCreateModal } from '@studio/components/DatasetCreateModal'; import { DatasetCreateModalMode } from '@studio/components/DatasetCreateModal/constants'; +import { makeDatasetsTableColumns } from '@studio/components/DatasetsTable/columns'; +import { type DatasetsTableProps } from '@studio/components/DatasetsTable/types'; +import { useDatasetsTable } from '@studio/components/DatasetsTable/useDatasetsTable'; import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; import { DocumentationButton } from '@studio/components/DocumentationButton'; import { Loading } from '@studio/components/Layouts/Loading'; @@ -36,107 +18,13 @@ import { NewDatasetButton } from '@studio/components/NewDatasetButton'; import { NewModelFilesetButton } from '@studio/components/NewModelFilesetButton'; import { FILESET_DETAILS_ENABLED } from '@studio/constants/environment'; import { LINK_DOCS_DATASETS } from '@studio/constants/links'; -import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { DatasetBulkDeleteModal } from '@studio/routes/FilesetListRoute/DatasetBulkDeleteModal'; import { getNewFilesetRoute } from '@studio/routes/utils'; -import { formatStorageBackendLabel, type StorageBackend } from '@studio/util/storageBackend'; -import { keepPreviousData } from '@tanstack/react-query'; -import { Cloud, X, Database, Trash } from 'lucide-react'; -import { - type ComponentProps, - type FC, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { X, Database, Trash } from 'lucide-react'; +import { type FC } from 'react'; +import { Link } from 'react-router-dom'; -type ModalOpenState = 'delete' | 'edit' | 'none'; - -type StorageConfig = - | LocalStorageConfig - | NGCStorageConfig - | HuggingfaceStorageConfig - | S3StorageConfig; - -function getStorageBackend(storage: StorageConfig | undefined): StorageBackend | null { - return storage?.type ?? null; -} - -const PURPOSE_LABELS: Record = { - [FilesetPurpose.generic]: 'Generic', - [FilesetPurpose.dataset]: 'Dataset', - [FilesetPurpose.model]: 'Model', -}; - -function getStoragePath(storage: StorageConfig | undefined): string | null { - if (!storage) return null; - const s = storage as { - type?: string; - path?: string; - org?: string; - team?: string; - target?: string; - repo_id?: string; - bucket?: string; - prefix?: string; - }; - if (s.type === 'local' && 'path' in storage) { - return (storage as LocalStorageConfig).path; - } - if (s.type === 'ngc' && 'org' in storage && 'team' in storage && 'target' in storage) { - const ngc = storage as NGCStorageConfig; - return `${ngc.org}/${ngc.team}/${ngc.target}`; - } - if (s.type === 'huggingface' && 'repo_id' in storage) { - return (storage as HuggingfaceStorageConfig).repo_id; - } - if (s.type === 's3' && 'bucket' in storage) { - const s3 = storage as S3StorageConfig; - return s3.prefix ? `${s3.bucket}/${s3.prefix}` : s3.bucket; - } - return null; -} - -export interface DatasetsTableProps { - /** Callback when datasets are selected */ - onDatasetsSelected?: (datasets: Dataset[]) => void; - /** Callback when a row is clicked */ - onRowClick?: (dataset: Dataset) => void; - /** Disable row actions (default: true) */ - enableActions?: boolean; - /** Enable bulk delete when items selected (default: false) */ - enableBulkDelete?: boolean; - /** Enable search bar and filters (default: false) */ - enableFilters?: boolean; - /** Enable checkbox selection (default: true) */ - enableSelection?: boolean; - /** Type of selection (default: 'multiple') */ - selectionType?: 'multiple' | 'single'; - /** Render dataset name as link - provide a function that returns the route */ - getDatasetRoute?: (dataset: Dataset) => string; - /** When set, restricts the fetched filesets to the given purpose. Pass FilesetPurpose.dataset in picker contexts that are specifically designed for dataset inputs. */ - purposeFilter?: FilesetPurpose; - /** Custom render for row actions */ - renderRowActions?: ( - dataset: Dataset, - callbacks: { - onNavigate: () => void; - onEdit: () => void; - onDelete: () => void; - onDatasetDeleted: (dataset: Dataset) => void; - } - ) => ReactNode; - attributes?: { - DataViewRoot?: ComponentProps> & { dataMode: 'manual' }; - DataViewContent?: ComponentProps; - }; -} - -type DatasetWithId = Dataset & { id: string }; +export type { DatasetsTableProps } from '@studio/components/DatasetsTable/types'; /** * A table that displays a list of datasets with optional filtering, search, and bulk operations. @@ -153,317 +41,48 @@ export const DatasetsTable: FC = ({ renderRowActions, purposeFilter, }) => { - const workspace = useWorkspaceFromPath(); - const navigate = useNavigate(); - - // DataView state for pagination, row selection, sorting, search, and filters - const dataViewState = useStudioDataViewState({ - defaultSort: { id: 'created_at', desc: true }, - }); - - const hasActiveFilters = dataViewState.debouncedColumnFilters.length > 0; - const hasSearchOrFilters = !!(dataViewState.debouncedSearchBar || hasActiveFilters); - - const [modalDataset, setModalDataset] = useState(); - const [modalOpen, setModalOpen] = useState(); - const { mutateAsync: deleteDataset } = useFilesDeleteFileset({ - mutation: { - onSuccess: (_data, variables) => { - invalidateDatasetCaches(variables.workspace, variables.name, ['list']); - }, - }, - }); - - // Reset filters and selections - const resetFilters = useCallback(() => { - onDatasetsSelected?.([]); - dataViewState.resetFilters(); - }, [dataViewState, onDatasetsSelected]); - const { - data: datasetsResponse, + workspace, + dataViewState, + hasSearchOrFilters, + modalDataset, + setModalDataset, + modalOpen, + setModalOpen, + datasetsResponse, + datasets, refetch, isPending, isFetching, error, - } = useFilesListFilesets( - workspace, - { - page: dataViewState.pagination.state.pageIndex + 1, - page_size: dataViewState.pagination.state.pageSize, - sort: enableFilters - ? (getSortParam(dataViewState.sorting.state) as GenericSortField) - : undefined, - filter: { - ...(enableFilters ? dataViewState.apiFilter.filter : undefined), - ...(purposeFilter !== undefined ? { purpose: purposeFilter } : {}), - }, - }, - { - query: { - placeholderData: keepPreviousData, - }, - } - ); - - // Ensure each dataset has a unique id for DataView row selection - const datasets = useMemo( - () => - (datasetsResponse?.data || []).map((dataset) => ({ - ...dataset, - id: dataset.id || `${dataset.workspace}/${dataset.name}`, - })), - [datasetsResponse?.data] - ); - - // Propagate row selection changes to onDatasetsSelected callback - const prevSelectionRef = useRef(dataViewState.rowSelection.state); - useEffect(() => { - const selection = dataViewState.rowSelection.state; - if (selection === prevSelectionRef.current) return; - prevSelectionRef.current = selection; - - if (!onDatasetsSelected) return; - - // For single selection, keep only the most recently selected row - const selectedIds = Object.keys(selection).filter((id) => selection[id]); - if (selectionType === 'single' && selectedIds.length > 1) { - const lastSelected = selectedIds[selectedIds.length - 1]; - dataViewState.rowSelection.set({ [lastSelected]: true }); - return; // The set above will re-trigger this effect with the corrected state - } - - const selectedDatasets = datasets.filter((d) => selection[d.id]); - onDatasetsSelected(selectedDatasets); - }, [ - dataViewState.rowSelection.state, - datasets, + resetFilters, + handleRowClick, + handleDatasetDeleted, + handleDeleteDataset, + handleBulkDeleteSuccess, + handleModalClose, + } = useDatasetsTable({ onDatasetsSelected, + onRowClick, + enableFilters, + enableSelection, selectionType, - dataViewState.rowSelection, - ]); - - // Row click handler - const handleRowClick = useCallback( - (dataset: DatasetWithId) => { - if (onRowClick) { - onRowClick(dataset); - } - if (getDatasetRoute) { - navigate(getDatasetRoute(dataset)); - } - if (enableSelection && !enableFilters) { - // In simple mode, clicking row selects it - dataViewState.rowSelection.set({ [dataset.id]: true }); - } - }, - [ - onRowClick, - getDatasetRoute, - navigate, - enableSelection, - enableFilters, - dataViewState.rowSelection, - ] - ); - - // Action handlers - const handleDatasetDeleted = useCallback( - (deletedDataset: Dataset) => { - const currentSelection = { ...dataViewState.rowSelection.state }; - delete currentSelection[deletedDataset.id || '']; - dataViewState.rowSelection.set(currentSelection); - }, - [dataViewState.rowSelection] - ); - - const handleDeleteDataset = async () => { - try { - if (!modalDataset?.workspace || !modalDataset?.name) return false; - await deleteDataset({ - workspace: modalDataset.workspace, - name: modalDataset.name, - }); - handleDatasetDeleted(modalDataset); - return true; - } catch { - return false; - } - }; - - const handleBulkDeleteSuccess = useCallback(() => { - onDatasetsSelected?.([]); - dataViewState.rowSelection.set({}); - refetch(); - }, [dataViewState.rowSelection, onDatasetsSelected, refetch]); - - const handleModalClose = () => setModalOpen('none'); + getDatasetRoute, + purposeFilter, + }); // Column definitions - const makeColumns: ComponentProps>['makeColumns'] = ( - { accessor }, - { rowSelectionColumn, rowActionsColumn } - ) => - [ - enableSelection && - rowSelectionColumn({ - size: ROW_SELECTION_COLUMN_SIZE, - ...(selectionType === 'single' && { - headerProps: { className: 'invisible' }, - }), - }), - accessor('name', { - header: 'Name', - enableSorting: enableFilters, - size: 175, - }), - accessor((row) => getStorageBackend(row.storage), { - id: 'storage_type', - header: 'Storage Backend', - size: 130, - meta: { - filter: { - label: 'Storage Backend', - type: 'single-select', - - options: [ - { value: '', label: 'All' }, - { value: StorageConfigType.local, label: 'Local' }, - { value: StorageConfigType.ngc, label: 'NGC' }, - { value: StorageConfigType.huggingface, label: 'Hugging Face' }, - { value: StorageConfigType.s3, label: 'S3' }, - ], - }, - }, - cell({ row }) { - const backend = getStorageBackend(row.original?.storage); - if (!backend) return null; - const label = formatStorageBackendLabel(backend); - const isLocal = backend === 'local'; - const Icon = isLocal ? Database : Cloud; - return ( - - - - {label} - - - ); - }, - }), - accessor((row) => row.purpose, { - id: 'purpose', - header: 'Purpose', - size: 110, - meta: { - filter: { - label: 'Purpose', - type: 'single-select', - options: [ - { value: '', label: 'All' }, - { value: FilesetPurpose.generic, label: 'Generic' }, - { value: FilesetPurpose.dataset, label: 'Dataset' }, - { value: FilesetPurpose.model, label: 'Model' }, - ], - }, - }, - cell({ row }) { - const purpose = row.original?.purpose; - return purpose ? {PURPOSE_LABELS[purpose] ?? purpose} : null; - }, - }), - accessor((row) => getStoragePath(row.storage), { - id: 'path', - header: 'Path', - size: 200, - cell({ row }) { - const path = getStoragePath(row.original?.storage); - return path ? ( - - {path} - - ) : null; - }, - }), - accessor('description', { - header: 'Description', - cell({ row }) { - return ( - - {row.original?.description} - - ); - }, - }), - accessor('created_at', { - id: 'created_at', - header: 'Created', - enableSorting: enableFilters, - size: 150, - maxSize: 150, - minSize: 150, - meta: { - filter: dateTimeFilter('Created At'), - }, - cell({ row }) { - return row.original?.created_at ? ( - - ) : null; - }, - }), - enableActions && - rowActionsColumn({ - size: ROW_ACTIONS_COLUMN_SIZE, - enableResizing: false, - rowActions: (data: DatasetWithId) => [ - ...(getDatasetRoute - ? [ - { - children: 'View', - onSelect: () => { - // Navigation handled by Link - }, - }, - ] - : []), - { - children: 'Edit', - onSelect: () => { - setModalDataset(data); - setModalOpen('edit'); - }, - }, - { - children: 'Delete', - danger: true, - onSelect: () => { - setModalDataset(data); - setModalOpen('delete'); - }, - }, - ], - cell: renderRowActions - ? ({ row }) => ( - - {renderRowActions(row.original, { - onNavigate: () => { - /* handled by Link */ - }, - onEdit: () => { - setModalDataset(row.original); - setModalOpen('edit'); - }, - onDelete: () => { - setModalDataset(row.original); - setModalOpen('delete'); - }, - onDatasetDeleted: handleDatasetDeleted, - })} - - ) - : undefined, - }), - ].filter((col): col is DataView.TanstackTable.ColumnDef => Boolean(col)); + const makeColumns = makeDatasetsTableColumns({ + enableSelection, + selectionType, + enableFilters, + enableActions, + getDatasetRoute, + renderRowActions, + setModalDataset, + setModalOpen, + handleDatasetDeleted, + }); // Loading state if (isPending) { diff --git a/web/packages/studio/src/components/DatasetsTable/types.ts b/web/packages/studio/src/components/DatasetsTable/types.ts new file mode 100644 index 0000000000..4149ed790d --- /dev/null +++ b/web/packages/studio/src/components/DatasetsTable/types.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as DataView from '@nemo/common/src/components/DataView/internal'; +import { + type FilesetOutput as Dataset, + type FilesetPurpose, + type HuggingfaceStorageConfig, + type LocalStorageConfig, + type NGCStorageConfig, + type S3StorageConfig, +} from '@nemo/sdk/generated/platform/schema'; +import { type ComponentProps, type ReactNode } from 'react'; + +export type ModalOpenState = 'delete' | 'edit' | 'none'; + +export type StorageConfig = + | LocalStorageConfig + | NGCStorageConfig + | HuggingfaceStorageConfig + | S3StorageConfig; + +export type DatasetWithId = Dataset & { id: string }; + +export interface DatasetsTableProps { + /** Callback when datasets are selected */ + onDatasetsSelected?: (datasets: Dataset[]) => void; + /** Callback when a row is clicked */ + onRowClick?: (dataset: Dataset) => void; + /** Disable row actions (default: true) */ + enableActions?: boolean; + /** Enable bulk delete when items selected (default: false) */ + enableBulkDelete?: boolean; + /** Enable search bar and filters (default: false) */ + enableFilters?: boolean; + /** Enable checkbox selection (default: true) */ + enableSelection?: boolean; + /** Type of selection (default: 'multiple') */ + selectionType?: 'multiple' | 'single'; + /** Render dataset name as link - provide a function that returns the route */ + getDatasetRoute?: (dataset: Dataset) => string; + /** When set, restricts the fetched filesets to the given purpose. Pass FilesetPurpose.dataset in picker contexts that are specifically designed for dataset inputs. */ + purposeFilter?: FilesetPurpose; + /** Custom render for row actions */ + renderRowActions?: ( + dataset: Dataset, + callbacks: { + onNavigate: () => void; + onEdit: () => void; + onDelete: () => void; + onDatasetDeleted: (dataset: Dataset) => void; + } + ) => ReactNode; + attributes?: { + DataViewRoot?: ComponentProps> & { dataMode: 'manual' }; + DataViewContent?: ComponentProps; + }; +} diff --git a/web/packages/studio/src/components/DatasetsTable/useDatasetsTable.ts b/web/packages/studio/src/components/DatasetsTable/useDatasetsTable.ts new file mode 100644 index 0000000000..24101c1ab4 --- /dev/null +++ b/web/packages/studio/src/components/DatasetsTable/useDatasetsTable.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { getSortParam } from '@nemo/common/src/utils/query'; +import { useFilesDeleteFileset, useFilesListFilesets } from '@nemo/sdk/generated/platform/api'; +import { + type FilesetOutput as Dataset, + type GenericSortField, +} from '@nemo/sdk/generated/platform/schema'; +import { invalidateDatasetCaches } from '@studio/api/datasets/invalidateDatasetCaches'; +import { + type DatasetWithId, + type DatasetsTableProps, + type ModalOpenState, +} from '@studio/components/DatasetsTable/types'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { keepPreviousData } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +type UseDatasetsTableArgs = Pick< + DatasetsTableProps, + | 'onDatasetsSelected' + | 'onRowClick' + | 'enableFilters' + | 'enableSelection' + | 'selectionType' + | 'getDatasetRoute' + | 'purposeFilter' +>; + +export function useDatasetsTable({ + onDatasetsSelected, + onRowClick, + enableFilters, + enableSelection, + selectionType, + getDatasetRoute, + purposeFilter, +}: UseDatasetsTableArgs) { + const workspace = useWorkspaceFromPath(); + const navigate = useNavigate(); + + // DataView state for pagination, row selection, sorting, search, and filters + const dataViewState = useStudioDataViewState({ + defaultSort: { id: 'created_at', desc: true }, + }); + + const hasActiveFilters = dataViewState.debouncedColumnFilters.length > 0; + const hasSearchOrFilters = !!(dataViewState.debouncedSearchBar || hasActiveFilters); + + const [modalDataset, setModalDataset] = useState(); + const [modalOpen, setModalOpen] = useState(); + const { mutateAsync: deleteDataset } = useFilesDeleteFileset({ + mutation: { + onSuccess: (_data, variables) => { + invalidateDatasetCaches(variables.workspace, variables.name, ['list']); + }, + }, + }); + + // Reset filters and selections + const resetFilters = useCallback(() => { + onDatasetsSelected?.([]); + dataViewState.resetFilters(); + }, [dataViewState, onDatasetsSelected]); + + const { + data: datasetsResponse, + refetch, + isPending, + isFetching, + error, + } = useFilesListFilesets( + workspace, + { + page: dataViewState.pagination.state.pageIndex + 1, + page_size: dataViewState.pagination.state.pageSize, + sort: enableFilters + ? (getSortParam(dataViewState.sorting.state) as GenericSortField) + : undefined, + filter: { + ...(enableFilters ? dataViewState.apiFilter.filter : undefined), + ...(purposeFilter !== undefined ? { purpose: purposeFilter } : {}), + }, + }, + { + query: { + placeholderData: keepPreviousData, + }, + } + ); + + // Ensure each dataset has a unique id for DataView row selection + const datasets = useMemo( + () => + (datasetsResponse?.data || []).map((dataset) => ({ + ...dataset, + id: dataset.id || `${dataset.workspace}/${dataset.name}`, + })), + [datasetsResponse?.data] + ); + + // Propagate row selection changes to onDatasetsSelected callback + const prevSelectionRef = useRef(dataViewState.rowSelection.state); + useEffect(() => { + const selection = dataViewState.rowSelection.state; + if (selection === prevSelectionRef.current) return; + prevSelectionRef.current = selection; + + if (!onDatasetsSelected) return; + + // For single selection, keep only the most recently selected row + const selectedIds = Object.keys(selection).filter((id) => selection[id]); + if (selectionType === 'single' && selectedIds.length > 1) { + const lastSelected = selectedIds[selectedIds.length - 1]; + dataViewState.rowSelection.set({ [lastSelected]: true }); + return; // The set above will re-trigger this effect with the corrected state + } + + const selectedDatasets = datasets.filter((d) => selection[d.id]); + onDatasetsSelected(selectedDatasets); + }, [ + dataViewState.rowSelection.state, + datasets, + onDatasetsSelected, + selectionType, + dataViewState.rowSelection, + ]); + + // Row click handler + const handleRowClick = useCallback( + (dataset: DatasetWithId) => { + if (onRowClick) { + onRowClick(dataset); + } + if (getDatasetRoute) { + navigate(getDatasetRoute(dataset)); + } + if (enableSelection && !enableFilters) { + // In simple mode, clicking row selects it + dataViewState.rowSelection.set({ [dataset.id]: true }); + } + }, + [ + onRowClick, + getDatasetRoute, + navigate, + enableSelection, + enableFilters, + dataViewState.rowSelection, + ] + ); + + // Action handlers + const handleDatasetDeleted = useCallback( + (deletedDataset: Dataset) => { + const currentSelection = { ...dataViewState.rowSelection.state }; + delete currentSelection[deletedDataset.id || '']; + dataViewState.rowSelection.set(currentSelection); + }, + [dataViewState.rowSelection] + ); + + const handleDeleteDataset = async () => { + try { + if (!modalDataset?.workspace || !modalDataset?.name) return false; + await deleteDataset({ + workspace: modalDataset.workspace, + name: modalDataset.name, + }); + handleDatasetDeleted(modalDataset); + return true; + } catch { + return false; + } + }; + + const handleBulkDeleteSuccess = useCallback(() => { + onDatasetsSelected?.([]); + dataViewState.rowSelection.set({}); + refetch(); + }, [dataViewState.rowSelection, onDatasetsSelected, refetch]); + + const handleModalClose = () => setModalOpen('none'); + + return { + workspace, + dataViewState, + hasSearchOrFilters, + modalDataset, + setModalDataset, + modalOpen, + setModalOpen, + datasetsResponse, + datasets, + refetch, + isPending, + isFetching, + error, + resetFilters, + handleRowClick, + handleDatasetDeleted, + handleDeleteDataset, + handleBulkDeleteSuccess, + handleModalClose, + }; +} diff --git a/web/packages/studio/src/components/ModelComparePrompts/ExpandableCell.tsx b/web/packages/studio/src/components/ModelComparePrompts/ExpandableCell.tsx new file mode 100644 index 0000000000..d55d60fe36 --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/ExpandableCell.tsx @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '@nvidia/foundations-react-core'; +import type { ExpandedCellState } from '@studio/components/ModelComparePrompts/types'; +import { Maximize2 } from 'lucide-react'; +import type { FC, ReactNode } from 'react'; + +/** Table cell with vertical scroll and an expand-to-modal button */ +export const ExpandableCell: FC<{ + content: string; + title: string; + onExpand: (state: ExpandedCellState) => void; + footer?: ReactNode; + boldContent?: boolean; +}> = ({ content, title, onExpand, footer, boldContent }) => { + return ( +
    + +
    + + {content} + +
    + {footer &&
    {footer}
    } +
    + ); +}; diff --git a/web/packages/studio/src/components/ModelComparePrompts/ModelColumnSelect.tsx b/web/packages/studio/src/components/ModelComparePrompts/ModelColumnSelect.tsx new file mode 100644 index 0000000000..dd4fa5991b --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/ModelColumnSelect.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels'; +import { ModelSelectV2, type ModelSelection } from '@nemo/common/src/components/ModelSelectV2'; +import { type FC, useCallback } from 'react'; + +/** Thin wrapper around ModelSelectV2 for table header use */ +export const ModelColumnSelect: FC<{ + modelGroups: ModelWorkspaceGroup[]; + isLoadingModels: boolean; + value: string | null; + disabled?: boolean; + onChange: (ref: string) => void; +}> = ({ modelGroups, isLoadingModels, value, disabled, onChange }) => { + const selectedModel: ModelSelection | null = value ? { model: value } : null; + + const handleValueChange = useCallback( + (selection: ModelSelection) => { + onChange(selection.model); + }, + [onChange] + ); + + return ( + + ); +}; diff --git a/web/packages/studio/src/components/ModelComparePrompts/ModelCompareTable.tsx b/web/packages/studio/src/components/ModelComparePrompts/ModelCompareTable.tsx new file mode 100644 index 0000000000..25acc0c5b6 --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/ModelCompareTable.tsx @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels'; +import { getPartsFromReference } from '@nemo/common/src/namedEntity'; +import type { FileSampleMethod } from '@nemo/common/src/utils/sampleTextLines'; +import { Button, Flex, Select, Text } from '@nvidia/foundations-react-core'; +import { StatsBadge } from '@studio/components/chat/StatsBadge'; +import type { DatasetInputFileResult } from '@studio/components/DatasetInputFile'; +import { FileSamplingMethodSelect } from '@studio/components/FileSamplingSnippet/FileSamplingMethodSelect'; +import { ExpandableCell } from '@studio/components/ModelComparePrompts/ExpandableCell'; +import { ModelColumnSelect } from '@studio/components/ModelComparePrompts/ModelColumnSelect'; +import type { + ExpandedCellState, + PromptRow, + ResponseStats, +} from '@studio/components/ModelComparePrompts/types'; +import { + PANEL_ROLE_COLORS, + PANEL_ROLE_DOT_CLASS, + PANEL_ROLE_LABELS, + type SharedModelEntry, +} from '@studio/routes/ModelCompareRoute/types'; +import { Trash2 } from 'lucide-react'; +import type { Dispatch, FC, SetStateAction } from 'react'; + +interface ModelCompareTableProps { + models: SharedModelEntry[]; + modelGroups: ModelWorkspaceGroup[]; + isLoadingModels: boolean; + promptRows: PromptRow[]; + fileResult: DatasetInputFileResult | null; + sampleMethod: FileSampleMethod; + setSampleMethod: Dispatch>; + sampleSize: number; + setSampleSize: Dispatch>; + rowCount: number; + isRunning: boolean; + hasPrompts: boolean; + hasAssignedModel: boolean; + promptKeyAutoDetected: boolean; + pickerSelectKey: number; + pickerValue: string | undefined; + datasetItems: { value: string; children: string }[]; + parseError: string | null; + averagesByModelId: Record; + anyAverages: boolean; + onRemoveModel: (id: number) => void; + onSetModel: (id: number, modelURN: string | null) => void; + clearResponses: (columnId?: number) => void; + runInference: () => void; + cancelRun: () => void; + handleDatasetSelect: (value: string) => void; + handlePromptKeyChange: (key: string) => void; + setExpandedCell: Dispatch>; +} + +export const ModelCompareTable: FC = ({ + models, + modelGroups, + isLoadingModels, + promptRows, + fileResult, + sampleMethod, + setSampleMethod, + sampleSize, + setSampleSize, + rowCount, + isRunning, + hasPrompts, + hasAssignedModel, + promptKeyAutoDetected, + pickerSelectKey, + pickerValue, + datasetItems, + parseError, + averagesByModelId, + anyAverages, + onRemoveModel, + onSetModel, + clearResponses, + runInference, + cancelRun, + handleDatasetSelect, + handlePromptKeyChange, + setExpandedCell, +}) => { + return ( + + + + {models.map((m) => ( + + ))} + + + {/* Row 1: sampling controls + role labels */} + + + {models.map((m, idx) => { + const roleColor = PANEL_ROLE_COLORS[Math.min(idx, PANEL_ROLE_COLORS.length - 1)]; + const colBorder = idx < models.length - 1 ? 'border-r ' : ''; + return ( + + ); + })} + + {/* Row 2: dataset picker + model selects */} + + + {models.map((m, idx) => ( + + ))} + + + + {promptRows.map((row, rowIdx) => { + const rowBottom = rowIdx < promptRows.length - 1 || anyAverages ? 'border-b ' : ''; + return ( + + + {models.map((m, idx) => { + const response = row.responses[m.id]; + const modelName = m.modelURN ? getPartsFromReference(m.modelURN).name : 'Model'; + const colBorder = idx < models.length - 1 ? 'border-r ' : ''; + if (response === undefined) { + return ( + + ); + } + if (response === null) { + return ( + + ); + } + return ( + + ); + })} + + ); + })} + + {hasPrompts && anyAverages && ( + + + + {models.map((m, idx) => { + const avg = averagesByModelId[m.id]; + return ( + + ); + })} + + + )} +
    + + + Prompts + + + {isRunning ? ( + + ) : ( + + )} + + + + + + {PANEL_ROLE_LABELS[roleColor]} + + + +
    + ({ + value: k.value, + children: k.label, + }))} + value={fileResult.keyMapping.promptKey ?? undefined} + onValueChange={handlePromptKeyChange} + placeholder="Select a field" + disabled={isRunning} + size="small" + className="w-full" + /> + + )} + + { + onSetModel(m.id, ref || null); + clearResponses(m.id); + }} + /> +
    + + + + - + + + + Error + + + setExpandedCell({ ...state, stats: response.stats })} + footer={} + /> +
    + Average + + {avg ? ( + + ) : ( + + — + + )} +
    + ); +}; diff --git a/web/packages/studio/src/components/ModelComparePrompts/constants.ts b/web/packages/studio/src/components/ModelComparePrompts/constants.ts new file mode 100644 index 0000000000..5ee65fe69b --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/constants.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_SAMPLE_SIZE = 5; + +/** Number of inference requests to run concurrently; the rest queue. */ +export const INFERENCE_BATCH_SIZE = 10; + +/** Sentinel item values for the dataset picker. */ +export const UPLOADED_FILE_VALUE = '__uploaded__'; +export const FILESET_PICKER_VALUE = '__fileset_picker__'; diff --git a/web/packages/studio/src/components/ModelComparePrompts/helpers.ts b/web/packages/studio/src/components/ModelComparePrompts/helpers.ts new file mode 100644 index 0000000000..6556f05faa --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/helpers.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { FileFormat, InputFileSchemaType } from '@nemo/common/src/types'; +import { extractUserFriendlyKeysFromRow, resolveKeyPath } from '@nemo/common/src/utils/file'; +import { detectFileStructure, validateFileFormat } from '@nemo/common/src/utils/fileValidation'; +import { type FileSampleMethod, sampleIndices } from '@nemo/common/src/utils/sampleTextLines'; +import type { DatasetInputFileResult } from '@studio/components/DatasetInputFile'; +import type { PromptRow } from '@studio/components/ModelComparePrompts/types'; + +/** Builds prompt rows from parsed dataset rows using the shared sampling controls. */ +export function buildPromptRowsFromParsedRows( + fileResult: DatasetInputFileResult, + sampleSize: number, + sampleMethod: FileSampleMethod +): PromptRow[] { + const promptKey = fileResult.keyMapping.promptKey; + if (!promptKey || !fileResult.parsedRows?.length) return []; + + const parsedRows = fileResult.parsedRows; + const indices = sampleIndices(parsedRows.length, sampleMethod, Math.max(1, sampleSize)); + + const rows: PromptRow[] = []; + for (const idx of indices) { + const row = parsedRows[idx]; + if (!row) continue; + const promptValue = resolveKeyPath(row, promptKey); + if (promptValue === null || promptValue === undefined) continue; + const prompt = typeof promptValue === 'string' ? promptValue : JSON.stringify(promptValue); + rows.push({ + sourceIndex: idx, + prompt, + responses: {}, + }); + } + return rows; +} + +/** + * Inline upload parser. Mirrors `DatasetInputFile`'s file path but runs without + * its full validation UI — errors surface as a small inline banner under the + * picker. We can't reuse `DatasetInputFile` here because we want a single + * dropdown that owns both sample selection and upload. + */ +export async function parseUploadedFile( + file: File +): Promise { + const validation = await validateFileFormat(file); + if (!validation.isValid || !validation.format) { + return { error: validation.error ?? 'Invalid file format' }; + } + const detection = await detectFileStructure(file, validation.format); + const text = await file.text(); + let parsedRows: Record[]; + try { + if (validation.format === FileFormat.JSONL) { + parsedRows = text + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); + } else { + const parsed: unknown = JSON.parse(text); + parsedRows = Array.isArray(parsed) + ? (parsed as Record[]) + : [parsed as Record]; + } + } catch (err) { + return { error: err instanceof Error ? err.message : 'Failed to parse file contents' }; + } + if (parsedRows.length === 0) { + return { error: 'File contains no rows' }; + } + const firstRow = (detection?.firstRow as Record | undefined) ?? parsedRows[0]; + const availableKeys = firstRow ? extractUserFriendlyKeysFromRow(firstRow) : []; + + // Auto-detect prompt key: prefer the detector's answer, then fall back to common keys. + let promptKey: string | null = null; + if (detection?.schemaType === InputFileSchemaType.COMPLETION) { + promptKey = detection.detectedFields.prompt ?? null; + } else if (detection?.schemaType === InputFileSchemaType.CHAT_COMPLETION) { + promptKey = detection.detectedMessages.user?.selector ?? null; + } + if (!promptKey) { + const candidates = ['prompt', 'question', 'input', 'text']; + promptKey = candidates.find((k) => typeof firstRow[k] === 'string') ?? null; + } + // If detection couldn't find a prompt column we still return the parsed file + // (with `promptKey: null`) so the inline column picker can let the user choose. + return { + fileUrl: `upload://${file.name}`, + format: validation.format, + validationResult: validation, + detectionResult: detection, + availableKeys, + keyMapping: { promptKey, completionKey: null, idealResponseKey: null }, + firstRow, + parsedRows, + rowCount: parsedRows.length, + }; +} diff --git a/web/packages/studio/src/components/ModelComparePrompts/index.tsx b/web/packages/studio/src/components/ModelComparePrompts/index.tsx index 939e9deba6..25530818f2 100644 --- a/web/packages/studio/src/components/ModelComparePrompts/index.tsx +++ b/web/packages/studio/src/components/ModelComparePrompts/index.tsx @@ -1,179 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels'; -import { ModelSelectV2, type ModelSelection } from '@nemo/common/src/components/ModelSelectV2'; import { UploadModal } from '@nemo/common/src/components/UploadModal'; -import type { SubmitUploadType } from '@nemo/common/src/components/UploadModal/types'; -import { useChatCompletion } from '@nemo/common/src/hooks/useChatCompletion'; -import { getPartsFromReference } from '@nemo/common/src/namedEntity'; -import { FileFormat, InputFileSchemaType } from '@nemo/common/src/types'; -import { extractUserFriendlyKeysFromRow, resolveKeyPath } from '@nemo/common/src/utils/file'; -import { detectFileStructure, validateFileFormat } from '@nemo/common/src/utils/fileValidation'; -import { type FileSampleMethod, sampleIndices } from '@nemo/common/src/utils/sampleTextLines'; -import { filesDownloadFile } from '@nemo/sdk/generated/platform/api'; -import { Button, Flex, Modal, Select, Text, Tooltip } from '@nvidia/foundations-react-core'; -import { SAMPLE_DATASETS } from '@studio/components/chat/sampleDatasets'; +import { Button, Flex, Modal, Text, Tooltip } from '@nvidia/foundations-react-core'; import { StatsBadge } from '@studio/components/chat/StatsBadge'; -import type { DatasetInputFileResult } from '@studio/components/DatasetInputFile'; -import { FileSamplingMethodSelect } from '@studio/components/FileSamplingSnippet/FileSamplingMethodSelect'; -import { - PANEL_ROLE_COLORS, - PANEL_ROLE_DOT_CLASS, - PANEL_ROLE_LABELS, - type SharedModelEntry, -} from '@studio/routes/ModelCompareRoute/types'; -import { logger } from '@studio/util/logger'; -import { Maximize2, Plus, Trash2 } from 'lucide-react'; -import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -const DEFAULT_SAMPLE_SIZE = 5; - -/** Number of inference requests to run concurrently; the rest queue. */ -const INFERENCE_BATCH_SIZE = 10; - -/** Sentinel item values for the dataset picker. */ -const UPLOADED_FILE_VALUE = '__uploaded__'; -const FILESET_PICKER_VALUE = '__fileset_picker__'; - -interface ResponseStats { - /** Wall-clock time from request fire to response, in ms. */ - totalMs: number; - /** From `usage.completion_tokens` when the gateway returns it; otherwise estimated from text length. */ - completionTokens: number; - /** Derived: completionTokens / (totalMs / 1000). */ - tokensPerSec: number; -} - -interface ResponseResult { - text: string; - stats: ResponseStats; -} - -interface PromptRow { - /** Index in the parsed dataset. */ - sourceIndex: number; - /** Resolved prompt text */ - prompt: string; - /** Model id -> response data (null = error, undefined = not yet run) */ - responses: Record; -} - -interface ExpandedCellState { - title: string; - content: string; - stats?: ResponseStats; -} - -/** Builds prompt rows from parsed dataset rows using the shared sampling controls. */ -function buildPromptRowsFromParsedRows( - fileResult: DatasetInputFileResult, - sampleSize: number, - sampleMethod: FileSampleMethod -): PromptRow[] { - const promptKey = fileResult.keyMapping.promptKey; - if (!promptKey || !fileResult.parsedRows?.length) return []; - - const parsedRows = fileResult.parsedRows; - const indices = sampleIndices(parsedRows.length, sampleMethod, Math.max(1, sampleSize)); - - const rows: PromptRow[] = []; - for (const idx of indices) { - const row = parsedRows[idx]; - if (!row) continue; - const promptValue = resolveKeyPath(row, promptKey); - if (promptValue === null || promptValue === undefined) continue; - const prompt = typeof promptValue === 'string' ? promptValue : JSON.stringify(promptValue); - rows.push({ - sourceIndex: idx, - prompt, - responses: {}, - }); - } - return rows; -} - -/** - * Inline upload parser. Mirrors `DatasetInputFile`'s file path but runs without - * its full validation UI — errors surface as a small inline banner under the - * picker. We can't reuse `DatasetInputFile` here because we want a single - * dropdown that owns both sample selection and upload. - */ -async function parseUploadedFile(file: File): Promise { - const validation = await validateFileFormat(file); - if (!validation.isValid || !validation.format) { - return { error: validation.error ?? 'Invalid file format' }; - } - const detection = await detectFileStructure(file, validation.format); - const text = await file.text(); - let parsedRows: Record[]; - try { - if (validation.format === FileFormat.JSONL) { - parsedRows = text - .trim() - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as Record); - } else { - const parsed: unknown = JSON.parse(text); - parsedRows = Array.isArray(parsed) - ? (parsed as Record[]) - : [parsed as Record]; - } - } catch (err) { - return { error: err instanceof Error ? err.message : 'Failed to parse file contents' }; - } - if (parsedRows.length === 0) { - return { error: 'File contains no rows' }; - } - const firstRow = (detection?.firstRow as Record | undefined) ?? parsedRows[0]; - const availableKeys = firstRow ? extractUserFriendlyKeysFromRow(firstRow) : []; - - // Auto-detect prompt key: prefer the detector's answer, then fall back to common keys. - let promptKey: string | null = null; - if (detection?.schemaType === InputFileSchemaType.COMPLETION) { - promptKey = detection.detectedFields.prompt ?? null; - } else if (detection?.schemaType === InputFileSchemaType.CHAT_COMPLETION) { - promptKey = detection.detectedMessages.user?.selector ?? null; - } - if (!promptKey) { - const candidates = ['prompt', 'question', 'input', 'text']; - promptKey = candidates.find((k) => typeof firstRow[k] === 'string') ?? null; - } - // If detection couldn't find a prompt column we still return the parsed file - // (with `promptKey: null`) so the inline column picker can let the user choose. - return { - fileUrl: `upload://${file.name}`, - format: validation.format, - validationResult: validation, - detectionResult: detection, - availableKeys, - keyMapping: { promptKey, completionKey: null, idealResponseKey: null }, - firstRow, - parsedRows, - rowCount: parsedRows.length, - }; -} - -interface ModelComparePromptsProps { - workspace: string; - modelGroups: ModelWorkspaceGroup[]; - isLoadingModels: boolean; - models: SharedModelEntry[]; - onRemoveModel: (id: number) => void; - onSetModel: (id: number, modelURN: string | null) => void; - /** Called when the view's readiness to add models changes (i.e. file is loaded with a valid prompt key) */ - onReadyChange?: (ready: boolean) => void; - /** Called when the user clicks the Add Model button. Omit to hide the button. */ - onAddModel?: () => void; - /** - * When set, default-select the matching `SAMPLE_DATASETS` entry on mount so - * the user lands on the agent's golden-prompts dataset without a click. - * Matching is by id equality (e.g. agent name "calculator-agent" matches the - * "calculator-agent" sample). Other samples remain pickable. - */ - agentName?: string | null; -} +import { ModelCompareTable } from '@studio/components/ModelComparePrompts/ModelCompareTable'; +import type { ModelComparePromptsProps } from '@studio/components/ModelComparePrompts/types'; +import { useModelComparePrompts } from '@studio/components/ModelComparePrompts/useModelComparePrompts'; +import { Plus } from 'lucide-react'; +import { type FC } from 'react'; export const ModelComparePrompts: FC = ({ workspace, @@ -186,561 +21,71 @@ export const ModelComparePrompts: FC = ({ agentName, onAddModel, }) => { - const [fileResult, setFileResult] = useState(null); - const [promptRows, setPromptRows] = useState([]); - const [isRunning, setIsRunning] = useState(false); - const [sampleSize, setSampleSize] = useState(DEFAULT_SAMPLE_SIZE); - const [sampleMethod, setSampleMethod] = useState('random'); - const [expandedCell, setExpandedCell] = useState(null); - const [pickerValue, setPickerValue] = useState(undefined); - // Bumped to remount the dataset Select after the "Select from dataset file..." - // sentinel is chosen, so the action can be retriggered (re-selecting the same - // option otherwise fires no change event). - const [pickerSelectKey, setPickerSelectKey] = useState(0); - const [uploadedFileName, setUploadedFileName] = useState(null); - const [parseError, setParseError] = useState(null); - const [isFilesetPickerOpen, setIsFilesetPickerOpen] = useState(false); - // True when the loaded file's prompt column was auto-detected. In that case - // we hide the manual column picker; we only surface it when detection failed. - const [promptKeyAutoDetected, setPromptKeyAutoDetected] = useState(false); - const { mutateAsync: createCompletion } = useChatCompletion(); - - // Monotonic run id. Incremented on invalidation; guards stale writeCell calls. - const runIdRef = useRef(0); - // AbortController for the active run; aborted when a new run starts, - // dataset/sampling changes, or the component unmounts. - const runAbortRef = useRef(null); - - const rowCount = fileResult?.rowCount ?? 0; - - const handleFileChange = useCallback((result: DatasetInputFileResult | null) => { - runIdRef.current += 1; - runAbortRef.current?.abort(); - setFileResult(result); - setPromptRows([]); - setPromptKeyAutoDetected(result?.keyMapping.promptKey != null); - if (result) { - setSampleSize(Math.min(DEFAULT_SAMPLE_SIZE, result.rowCount || DEFAULT_SAMPLE_SIZE)); - } - }, []); - - // Override the auto-detected prompt column. Updating `keyMapping.promptKey` - // triggers the row-rebuild effect below; fresh rows clear stale responses. - const handlePromptKeyChange = useCallback((key: string) => { - setFileResult((prev) => - prev ? { ...prev, keyMapping: { ...prev.keyMapping, promptKey: key } } : prev - ); - }, []); - - /** - * Clear cached inference responses. If `columnId` is provided, only that - * column's responses are cleared (e.g. when a new model is picked for the - * column). If omitted, all responses across all columns are cleared - * (e.g. on Run, or when picking new random prompts). - */ - const clearResponses = useCallback((columnId?: number) => { - setPromptRows((prev) => - prev.map((row) => { - if (columnId === undefined) { - return { ...row, responses: {} }; - } - const next = { ...row.responses }; - delete next[columnId]; - return { ...row, responses: next }; - }) - ); - }, []); - - const runInference = useCallback(async () => { - const activeModels = models - .map((m) => { - if (!m.modelURN) return null; - const { workspace: modelWorkspace, name } = getPartsFromReference(m.modelURN); - return { id: m.id, modelWorkspace, name }; - }) - .filter((m): m is { id: number; modelWorkspace: string; name: string } => m !== null); - - if (activeModels.length === 0 || promptRows.length === 0) return; - - // Snapshot inputs at start of run; any later change invalidates this run. - const snapshotPromptRows = promptRows; - const snapshotActiveModels = activeModels; - runIdRef.current += 1; - const myRunId = runIdRef.current; - - runAbortRef.current?.abort(); - const runController = new AbortController(); - runAbortRef.current = runController; - - setIsRunning(true); - clearResponses(); - - // Writes a single cell's result, but only if this run is still current. - const writeCell = (sourceIndex: number, modelId: number, result: ResponseResult | null) => { - if (runIdRef.current !== myRunId) return; - setPromptRows((prev) => - prev.map((row) => - row.sourceIndex === sourceIndex - ? { ...row, responses: { ...row.responses, [modelId]: result } } - : row - ) - ); - }; - - // Build task factories (not yet fired). Each one updates its own cell as - // soon as it resolves so results stream in. - const taskFactories: Array<() => Promise> = []; - snapshotActiveModels.forEach((model) => { - snapshotPromptRows.forEach((row) => { - taskFactories.push(() => { - const startTime = performance.now(); - return createCompletion({ - model: model.name, - workspace: model.modelWorkspace || workspace, - messages: [{ role: 'user', content: row.prompt }], - stream: false, - signal: runController.signal, - }) - .then((result) => { - const totalMs = performance.now() - startTime; - const content = - result && 'choices' in result - ? (result.choices[0]?.message?.content ?? null) - : null; - if (content === null) { - writeCell(row.sourceIndex, model.id, null); - return; - } - const usage = result && 'usage' in result ? result.usage : undefined; - // Fallback estimate: ~4 chars per token. Good enough for the badge when - // the gateway elides usage stats. - const completionTokens = - usage?.completion_tokens ?? Math.max(1, Math.round(content.length / 4)); - const tokensPerSec = totalMs > 0 ? completionTokens / (totalMs / 1000) : 0; - writeCell(row.sourceIndex, model.id, { - text: content, - stats: { totalMs, completionTokens, tokensPerSec }, - }); - }) - .catch((error) => { - logger.error('Inference request failed', error); - writeCell(row.sourceIndex, model.id, null); - }); - }); - }); - }); - - // Run tasks in capped-size batches so we don't flood the gateway. - try { - for (let i = 0; i < taskFactories.length; i += INFERENCE_BATCH_SIZE) { - if (runController.signal.aborted) break; - const batch = taskFactories.slice(i, i + INFERENCE_BATCH_SIZE).map((fn) => fn()); - await Promise.allSettled(batch); - } - } finally { - if (runAbortRef.current === runController) { - runAbortRef.current = null; - setIsRunning(false); - } - } - }, [models, promptRows, workspace, createCompletion, clearResponses]); - - // Cancel an in-flight run without clearing results. Bumping the run id makes - // any writes from aborted (rejected) requests no-op, so completed cells keep - // their results and still-pending cells stay blank. A later Run clears all. - const cancelRun = useCallback(() => { - runIdRef.current += 1; - runAbortRef.current?.abort(); - runAbortRef.current = null; - setIsRunning(false); - }, []); - - const hasPromptKey = fileResult?.keyMapping.promptKey != null; - const hasAssignedModel = models.some((m) => m.modelURN !== null); - const hasPrompts = promptRows.length > 0; - - /** - * Per-column averages across all completed responses. `tokensPerSec` is - * weighted (sum tokens / sum seconds) rather than a mean-of-means so short - * responses don't over-influence the rate. Returns null for columns with - * zero completed responses so the footer can render an em-dash. - */ - const averagesByModelId = useMemo(() => { - const result: Record = {}; - models.forEach((m) => { - let totalMs = 0; - let totalTokens = 0; - let count = 0; - promptRows.forEach((row) => { - const r = row.responses[m.id]; - if (!r) return; - totalMs += r.stats.totalMs; - totalTokens += r.stats.completionTokens; - count += 1; - }); - if (count === 0) { - result[m.id] = null; - return; - } - result[m.id] = { - totalMs: totalMs / count, - completionTokens: totalTokens / count, - tokensPerSec: totalMs > 0 ? totalTokens / (totalMs / 1000) : 0, - count, - }; - }); - return result; - }, [models, promptRows]); - - const anyAverages = Object.values(averagesByModelId).some((a) => a !== null); - - // Notify parent when readiness changes. "Ready" means the table is active - // (file is loaded and has a valid prompt key mapped). - const isReady = !!fileResult && hasPromptKey; - useEffect(() => { - onReadyChange?.(isReady); - }, [isReady, onReadyChange]); - - // Abort any active run on unmount (e.g. tab switch, navigation). - useEffect(() => { - return () => { - runAbortRef.current?.abort(); - }; - }, []); - - // Drive the prompt table from parsed preview rows + sampling controls (no separate file preview). - useEffect(() => { - if (!fileResult?.keyMapping.promptKey || !fileResult.parsedRows?.length) return; - - runIdRef.current += 1; - runAbortRef.current?.abort(); - setPromptRows(buildPromptRowsFromParsedRows(fileResult, sampleSize, sampleMethod)); - }, [fileResult, sampleSize, sampleMethod]); - - // Auto-select the agent's matching sample when the user lands on Run Prompts - // via the agent overlay. Tracks the last-auto-selected agent in a ref so we - // don't re-fire after the user clears the picker or picks a different file. - const autoSelectedAgentRef = useRef(null); - useEffect(() => { - if (!agentName) { - autoSelectedAgentRef.current = null; - return; - } - if (autoSelectedAgentRef.current === agentName) return; - const match = SAMPLE_DATASETS.find((s) => s.id === agentName); - if (!match) return; - autoSelectedAgentRef.current = agentName; - setPickerValue(match.id); - setUploadedFileName(null); - setParseError(null); - handleFileChange(match.build()); - // We intentionally re-run only on `agentName` change. Including - // `handleFileChange` (or the various setters) would re-fire this effect - // every time the parent re-renders and produce a seed loop — the agentRef - // guard above would still no-op the work, but the effect would still run - // and we want the dependencies to read true. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [agentName]); - - /** - * Single picker handler. Three branches: - * - sample id → synthesize the result via `sample.build()` (in-memory) - * - upload sentinel → click the hidden native file input - * - uploaded sentinel → no-op (it's the displayed value after a successful upload) - */ - const handleDatasetSelect = useCallback( - (value: string) => { - if (!value) return; - if (value === UPLOADED_FILE_VALUE) return; - if (value === FILESET_PICKER_VALUE) { - setIsFilesetPickerOpen(true); - setPickerSelectKey((k) => k + 1); - return; - } - const sample = SAMPLE_DATASETS.find((s) => s.id === value); - if (!sample) return; - setParseError(null); - setUploadedFileName(null); - setPickerValue(value); - handleFileChange(sample.build()); - }, - [handleFileChange] - ); - - const handleFilesetPickerSubmit = useCallback( - async (data: SubmitUploadType) => { - if (data.type !== 'dataset') return; - setIsFilesetPickerOpen(false); - setParseError(null); - try { - // `data.url` is a `fileset://` URI, not an HTTP URL — download via the - // SDK using the dataset's workspace/name and the file path. - const response = await filesDownloadFile( - data.dataset.workspace, - data.dataset.name, - data.path - ); - if (!response) { - setParseError('Failed to download file'); - return; - } - const text = await response.text(); - const filename = data.path.split('/').pop() ?? 'dataset.json'; - const file = new File([text], filename); - const result = await parseUploadedFile(file); - if ('error' in result) { - setParseError(result.error); - return; - } - setUploadedFileName(`${data.dataset.name}/${data.path}`); - setPickerValue(UPLOADED_FILE_VALUE); - handleFileChange(result); - } catch (err) { - setParseError(err instanceof Error ? err.message : 'Failed to load file'); - } - }, - [handleFileChange] - ); - - const datasetItems = useMemo(() => { - const items: { value: string; children: string }[] = SAMPLE_DATASETS.map((s) => ({ - value: s.id, - children: s.label, - })); - if (uploadedFileName) { - items.push({ value: UPLOADED_FILE_VALUE, children: uploadedFileName }); - } - items.push({ value: FILESET_PICKER_VALUE, children: 'Select from dataset file...' }); - return items; - }, [uploadedFileName]); + const { + fileResult, + promptRows, + isRunning, + sampleSize, + setSampleSize, + sampleMethod, + setSampleMethod, + expandedCell, + setExpandedCell, + pickerValue, + pickerSelectKey, + parseError, + isFilesetPickerOpen, + setIsFilesetPickerOpen, + promptKeyAutoDetected, + rowCount, + handlePromptKeyChange, + clearResponses, + runInference, + cancelRun, + hasAssignedModel, + hasPrompts, + averagesByModelId, + anyAverages, + handleDatasetSelect, + handleFilesetPickerSubmit, + datasetItems, + } = useModelComparePrompts({ workspace, models, onReadyChange, agentName }); return (
    {/* Results table fills remaining height; this is the main vertical scroll region. */}
    - - - - {models.map((m) => ( - - ))} - - - {/* Row 1: sampling controls + role labels */} - - - {models.map((m, idx) => { - const roleColor = PANEL_ROLE_COLORS[Math.min(idx, PANEL_ROLE_COLORS.length - 1)]; - const colBorder = idx < models.length - 1 ? 'border-r ' : ''; - return ( - - ); - })} - - {/* Row 2: dataset picker + model selects */} - - - {models.map((m, idx) => ( - - ))} - - - - {promptRows.map((row, rowIdx) => { - const rowBottom = rowIdx < promptRows.length - 1 || anyAverages ? 'border-b ' : ''; - return ( - - - {models.map((m, idx) => { - const response = row.responses[m.id]; - const modelName = m.modelURN - ? getPartsFromReference(m.modelURN).name - : 'Model'; - const colBorder = idx < models.length - 1 ? 'border-r ' : ''; - if (response === undefined) { - return ( - - ); - } - if (response === null) { - return ( - - ); - } - return ( - - ); - })} - - ); - })} - - {hasPrompts && anyAverages && ( - - - - {models.map((m, idx) => { - const avg = averagesByModelId[m.id]; - return ( - - ); - })} - - - )} -
    - - - Prompts - - - {isRunning ? ( - - ) : ( - - )} - - - - - - {PANEL_ROLE_LABELS[roleColor]} - - - -
    - ({ - value: k.value, - children: k.label, - }))} - value={fileResult.keyMapping.promptKey ?? undefined} - onValueChange={handlePromptKeyChange} - placeholder="Select a field" - disabled={isRunning} - size="small" - className="w-full" - /> - - )} - - { - onSetModel(m.id, ref || null); - clearResponses(m.id); - }} - /> -
    - - - - - - - - - Error - - - - setExpandedCell({ ...state, stats: response.stats }) - } - footer={} - /> -
    - Average - - {avg ? ( - - ) : ( - - — - - )} -
    +
    {onAddModel && (
    @@ -793,63 +138,3 @@ export const ModelComparePrompts: FC = ({
    ); }; - -/** Table cell with vertical scroll and an expand-to-modal button */ -const ExpandableCell: FC<{ - content: string; - title: string; - onExpand: (state: ExpandedCellState) => void; - footer?: React.ReactNode; - boldContent?: boolean; -}> = ({ content, title, onExpand, footer, boldContent }) => { - return ( -
    - -
    - - {content} - -
    - {footer &&
    {footer}
    } -
    - ); -}; - -/** Thin wrapper around ModelSelectV2 for table header use */ -const ModelColumnSelect: FC<{ - modelGroups: ModelWorkspaceGroup[]; - isLoadingModels: boolean; - value: string | null; - disabled?: boolean; - onChange: (ref: string) => void; -}> = ({ modelGroups, isLoadingModels, value, disabled, onChange }) => { - const selectedModel: ModelSelection | null = value ? { model: value } : null; - - const handleValueChange = useCallback( - (selection: ModelSelection) => { - onChange(selection.model); - }, - [onChange] - ); - - return ( - - ); -}; diff --git a/web/packages/studio/src/components/ModelComparePrompts/types.ts b/web/packages/studio/src/components/ModelComparePrompts/types.ts new file mode 100644 index 0000000000..f9e58928fa --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/types.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ModelWorkspaceGroup } from '@nemo/common/src/api/models/useModels'; +import type { SharedModelEntry } from '@studio/routes/ModelCompareRoute/types'; + +export interface ResponseStats { + /** Wall-clock time from request fire to response, in ms. */ + totalMs: number; + /** From `usage.completion_tokens` when the gateway returns it; otherwise estimated from text length. */ + completionTokens: number; + /** Derived: completionTokens / (totalMs / 1000). */ + tokensPerSec: number; +} + +export interface ResponseResult { + text: string; + stats: ResponseStats; +} + +export interface PromptRow { + /** Index in the parsed dataset. */ + sourceIndex: number; + /** Resolved prompt text */ + prompt: string; + /** Model id -> response data (null = error, undefined = not yet run) */ + responses: Record; +} + +export interface ExpandedCellState { + title: string; + content: string; + stats?: ResponseStats; +} + +export interface ModelComparePromptsProps { + workspace: string; + modelGroups: ModelWorkspaceGroup[]; + isLoadingModels: boolean; + models: SharedModelEntry[]; + onRemoveModel: (id: number) => void; + onSetModel: (id: number, modelURN: string | null) => void; + /** Called when the view's readiness to add models changes (i.e. file is loaded with a valid prompt key) */ + onReadyChange?: (ready: boolean) => void; + /** Called when the user clicks the Add Model button. Omit to hide the button. */ + onAddModel?: () => void; + /** + * When set, default-select the matching `SAMPLE_DATASETS` entry on mount so + * the user lands on the agent's golden-prompts dataset without a click. + * Matching is by id equality (e.g. agent name "calculator-agent" matches the + * "calculator-agent" sample). Other samples remain pickable. + */ + agentName?: string | null; +} diff --git a/web/packages/studio/src/components/ModelComparePrompts/useModelComparePrompts.ts b/web/packages/studio/src/components/ModelComparePrompts/useModelComparePrompts.ts new file mode 100644 index 0000000000..d531f9b7dd --- /dev/null +++ b/web/packages/studio/src/components/ModelComparePrompts/useModelComparePrompts.ts @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SubmitUploadType } from '@nemo/common/src/components/UploadModal/types'; +import { useChatCompletion } from '@nemo/common/src/hooks/useChatCompletion'; +import { getPartsFromReference } from '@nemo/common/src/namedEntity'; +import { type FileSampleMethod } from '@nemo/common/src/utils/sampleTextLines'; +import { filesDownloadFile } from '@nemo/sdk/generated/platform/api'; +import { SAMPLE_DATASETS } from '@studio/components/chat/sampleDatasets'; +import type { DatasetInputFileResult } from '@studio/components/DatasetInputFile'; +import { + DEFAULT_SAMPLE_SIZE, + FILESET_PICKER_VALUE, + INFERENCE_BATCH_SIZE, + UPLOADED_FILE_VALUE, +} from '@studio/components/ModelComparePrompts/constants'; +import { + buildPromptRowsFromParsedRows, + parseUploadedFile, +} from '@studio/components/ModelComparePrompts/helpers'; +import type { + ExpandedCellState, + ModelComparePromptsProps, + PromptRow, + ResponseResult, + ResponseStats, +} from '@studio/components/ModelComparePrompts/types'; +import { logger } from '@studio/util/logger'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type UseModelComparePromptsArgs = Pick< + ModelComparePromptsProps, + 'workspace' | 'models' | 'onReadyChange' | 'agentName' +>; + +export function useModelComparePrompts({ + workspace, + models, + onReadyChange, + agentName, +}: UseModelComparePromptsArgs) { + const [fileResult, setFileResult] = useState(null); + const [promptRows, setPromptRows] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const [sampleSize, setSampleSize] = useState(DEFAULT_SAMPLE_SIZE); + const [sampleMethod, setSampleMethod] = useState('random'); + const [expandedCell, setExpandedCell] = useState(null); + const [pickerValue, setPickerValue] = useState(undefined); + // Bumped to remount the dataset Select after the "Select from dataset file..." + // sentinel is chosen, so the action can be retriggered (re-selecting the same + // option otherwise fires no change event). + const [pickerSelectKey, setPickerSelectKey] = useState(0); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [parseError, setParseError] = useState(null); + const [isFilesetPickerOpen, setIsFilesetPickerOpen] = useState(false); + // True when the loaded file's prompt column was auto-detected. In that case + // we hide the manual column picker; we only surface it when detection failed. + const [promptKeyAutoDetected, setPromptKeyAutoDetected] = useState(false); + const { mutateAsync: createCompletion } = useChatCompletion(); + + // Monotonic run id. Incremented on invalidation; guards stale writeCell calls. + const runIdRef = useRef(0); + // AbortController for the active run; aborted when a new run starts, + // dataset/sampling changes, or the component unmounts. + const runAbortRef = useRef(null); + + const rowCount = fileResult?.rowCount ?? 0; + + const handleFileChange = useCallback((result: DatasetInputFileResult | null) => { + runIdRef.current += 1; + runAbortRef.current?.abort(); + setFileResult(result); + setPromptRows([]); + setPromptKeyAutoDetected(result?.keyMapping.promptKey != null); + if (result) { + setSampleSize(Math.min(DEFAULT_SAMPLE_SIZE, result.rowCount || DEFAULT_SAMPLE_SIZE)); + } + }, []); + + // Override the auto-detected prompt column. Updating `keyMapping.promptKey` + // triggers the row-rebuild effect below; fresh rows clear stale responses. + const handlePromptKeyChange = useCallback((key: string) => { + setFileResult((prev) => + prev ? { ...prev, keyMapping: { ...prev.keyMapping, promptKey: key } } : prev + ); + }, []); + + /** + * Clear cached inference responses. If `columnId` is provided, only that + * column's responses are cleared (e.g. when a new model is picked for the + * column). If omitted, all responses across all columns are cleared + * (e.g. on Run, or when picking new random prompts). + */ + const clearResponses = useCallback((columnId?: number) => { + setPromptRows((prev) => + prev.map((row) => { + if (columnId === undefined) { + return { ...row, responses: {} }; + } + const next = { ...row.responses }; + delete next[columnId]; + return { ...row, responses: next }; + }) + ); + }, []); + + const runInference = useCallback(async () => { + const activeModels = models + .map((m) => { + if (!m.modelURN) return null; + const { workspace: modelWorkspace, name } = getPartsFromReference(m.modelURN); + return { id: m.id, modelWorkspace, name }; + }) + .filter((m): m is { id: number; modelWorkspace: string; name: string } => m !== null); + + if (activeModels.length === 0 || promptRows.length === 0) return; + + // Snapshot inputs at start of run; any later change invalidates this run. + const snapshotPromptRows = promptRows; + const snapshotActiveModels = activeModels; + runIdRef.current += 1; + const myRunId = runIdRef.current; + + runAbortRef.current?.abort(); + const runController = new AbortController(); + runAbortRef.current = runController; + + setIsRunning(true); + clearResponses(); + + // Writes a single cell's result, but only if this run is still current. + const writeCell = (sourceIndex: number, modelId: number, result: ResponseResult | null) => { + if (runIdRef.current !== myRunId) return; + setPromptRows((prev) => + prev.map((row) => + row.sourceIndex === sourceIndex + ? { ...row, responses: { ...row.responses, [modelId]: result } } + : row + ) + ); + }; + + // Build task factories (not yet fired). Each one updates its own cell as + // soon as it resolves so results stream in. + const taskFactories: Array<() => Promise> = []; + snapshotActiveModels.forEach((model) => { + snapshotPromptRows.forEach((row) => { + taskFactories.push(() => { + const startTime = performance.now(); + return createCompletion({ + model: model.name, + workspace: model.modelWorkspace || workspace, + messages: [{ role: 'user', content: row.prompt }], + stream: false, + signal: runController.signal, + }) + .then((result) => { + const totalMs = performance.now() - startTime; + const content = + result && 'choices' in result + ? (result.choices[0]?.message?.content ?? null) + : null; + if (content === null) { + writeCell(row.sourceIndex, model.id, null); + return; + } + const usage = result && 'usage' in result ? result.usage : undefined; + // Fallback estimate: ~4 chars per token. Good enough for the badge when + // the gateway elides usage stats. + const completionTokens = + usage?.completion_tokens ?? Math.max(1, Math.round(content.length / 4)); + const tokensPerSec = totalMs > 0 ? completionTokens / (totalMs / 1000) : 0; + writeCell(row.sourceIndex, model.id, { + text: content, + stats: { totalMs, completionTokens, tokensPerSec }, + }); + }) + .catch((error) => { + logger.error('Inference request failed', error); + writeCell(row.sourceIndex, model.id, null); + }); + }); + }); + }); + + // Run tasks in capped-size batches so we don't flood the gateway. + try { + for (let i = 0; i < taskFactories.length; i += INFERENCE_BATCH_SIZE) { + if (runController.signal.aborted) break; + const batch = taskFactories.slice(i, i + INFERENCE_BATCH_SIZE).map((fn) => fn()); + await Promise.allSettled(batch); + } + } finally { + if (runAbortRef.current === runController) { + runAbortRef.current = null; + setIsRunning(false); + } + } + }, [models, promptRows, workspace, createCompletion, clearResponses]); + + // Cancel an in-flight run without clearing results. Bumping the run id makes + // any writes from aborted (rejected) requests no-op, so completed cells keep + // their results and still-pending cells stay blank. A later Run clears all. + const cancelRun = useCallback(() => { + runIdRef.current += 1; + runAbortRef.current?.abort(); + runAbortRef.current = null; + setIsRunning(false); + }, []); + + const hasPromptKey = fileResult?.keyMapping.promptKey != null; + const hasAssignedModel = models.some((m) => m.modelURN !== null); + const hasPrompts = promptRows.length > 0; + + /** + * Per-column averages across all completed responses. `tokensPerSec` is + * weighted (sum tokens / sum seconds) rather than a mean-of-means so short + * responses don't over-influence the rate. Returns null for columns with + * zero completed responses so the footer can render an em-dash. + */ + const averagesByModelId = useMemo(() => { + const result: Record = {}; + models.forEach((m) => { + let totalMs = 0; + let totalTokens = 0; + let count = 0; + promptRows.forEach((row) => { + const r = row.responses[m.id]; + if (!r) return; + totalMs += r.stats.totalMs; + totalTokens += r.stats.completionTokens; + count += 1; + }); + if (count === 0) { + result[m.id] = null; + return; + } + result[m.id] = { + totalMs: totalMs / count, + completionTokens: totalTokens / count, + tokensPerSec: totalMs > 0 ? totalTokens / (totalMs / 1000) : 0, + count, + }; + }); + return result; + }, [models, promptRows]); + + const anyAverages = Object.values(averagesByModelId).some((a) => a !== null); + + // Notify parent when readiness changes. "Ready" means the table is active + // (file is loaded and has a valid prompt key mapped). + const isReady = !!fileResult && hasPromptKey; + useEffect(() => { + onReadyChange?.(isReady); + }, [isReady, onReadyChange]); + + // Abort any active run on unmount (e.g. tab switch, navigation). + useEffect(() => { + return () => { + runAbortRef.current?.abort(); + }; + }, []); + + // Drive the prompt table from parsed preview rows + sampling controls (no separate file preview). + useEffect(() => { + if (!fileResult?.keyMapping.promptKey || !fileResult.parsedRows?.length) return; + + runIdRef.current += 1; + runAbortRef.current?.abort(); + setPromptRows(buildPromptRowsFromParsedRows(fileResult, sampleSize, sampleMethod)); + }, [fileResult, sampleSize, sampleMethod]); + + // Auto-select the agent's matching sample when the user lands on Run Prompts + // via the agent overlay. Tracks the last-auto-selected agent in a ref so we + // don't re-fire after the user clears the picker or picks a different file. + const autoSelectedAgentRef = useRef(null); + useEffect(() => { + if (!agentName) { + autoSelectedAgentRef.current = null; + return; + } + if (autoSelectedAgentRef.current === agentName) return; + const match = SAMPLE_DATASETS.find((s) => s.id === agentName); + if (!match) return; + autoSelectedAgentRef.current = agentName; + setPickerValue(match.id); + setUploadedFileName(null); + setParseError(null); + handleFileChange(match.build()); + // We intentionally re-run only on `agentName` change. Including + // `handleFileChange` (or the various setters) would re-fire this effect + // every time the parent re-renders and produce a seed loop — the agentRef + // guard above would still no-op the work, but the effect would still run + // and we want the dependencies to read true. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentName]); + + /** + * Single picker handler. Three branches: + * - sample id → synthesize the result via `sample.build()` (in-memory) + * - upload sentinel → click the hidden native file input + * - uploaded sentinel → no-op (it's the displayed value after a successful upload) + */ + const handleDatasetSelect = useCallback( + (value: string) => { + if (!value) return; + if (value === UPLOADED_FILE_VALUE) return; + if (value === FILESET_PICKER_VALUE) { + setIsFilesetPickerOpen(true); + setPickerSelectKey((k) => k + 1); + return; + } + const sample = SAMPLE_DATASETS.find((s) => s.id === value); + if (!sample) return; + setParseError(null); + setUploadedFileName(null); + setPickerValue(value); + handleFileChange(sample.build()); + }, + [handleFileChange] + ); + + const handleFilesetPickerSubmit = useCallback( + async (data: SubmitUploadType) => { + if (data.type !== 'dataset') return; + setIsFilesetPickerOpen(false); + setParseError(null); + try { + // `data.url` is a `fileset://` URI, not an HTTP URL — download via the + // SDK using the dataset's workspace/name and the file path. + const response = await filesDownloadFile( + data.dataset.workspace, + data.dataset.name, + data.path + ); + if (!response) { + setParseError('Failed to download file'); + return; + } + const text = await response.text(); + const filename = data.path.split('/').pop() ?? 'dataset.json'; + const file = new File([text], filename); + const result = await parseUploadedFile(file); + if ('error' in result) { + setParseError(result.error); + return; + } + setUploadedFileName(`${data.dataset.name}/${data.path}`); + setPickerValue(UPLOADED_FILE_VALUE); + handleFileChange(result); + } catch (err) { + setParseError(err instanceof Error ? err.message : 'Failed to load file'); + } + }, + [handleFileChange] + ); + + const datasetItems = useMemo(() => { + const items: { value: string; children: string }[] = SAMPLE_DATASETS.map((s) => ({ + value: s.id, + children: s.label, + })); + if (uploadedFileName) { + items.push({ value: UPLOADED_FILE_VALUE, children: uploadedFileName }); + } + items.push({ value: FILESET_PICKER_VALUE, children: 'Select from dataset file...' }); + return items; + }, [uploadedFileName]); + + return { + fileResult, + promptRows, + isRunning, + sampleSize, + setSampleSize, + sampleMethod, + setSampleMethod, + expandedCell, + setExpandedCell, + pickerValue, + pickerSelectKey, + parseError, + isFilesetPickerOpen, + setIsFilesetPickerOpen, + promptKeyAutoDetected, + rowCount, + handlePromptKeyChange, + clearResponses, + runInference, + cancelRun, + hasAssignedModel, + hasPrompts, + averagesByModelId, + anyAverages, + handleDatasetSelect, + handleFilesetPickerSubmit, + datasetItems, + }; +} diff --git a/web/packages/studio/src/components/evaluation/Configurations/form/InputFile.tsx b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile.tsx index 44db70a2fe..5b7203020e 100644 --- a/web/packages/studio/src/components/evaluation/Configurations/form/InputFile.tsx +++ b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile.tsx @@ -1,426 +1,50 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { CodeEditor } from '@nemo/common/src/components/CodeEditor'; -import { ContentType } from '@nemo/common/src/components/CodeEditor/constants'; import { UploadModal } from '@nemo/common/src/components/UploadModal'; -import { SubmitUploadType } from '@nemo/common/src/components/UploadModal/types'; -import { extractUserFriendlyKeysFromRow, getFileRowCount } from '@nemo/common/src/utils/file'; -import { - validateFileFormat, - detectFileStructure, - FileValidationResult, - FileFormatDetectionResult, -} from '@nemo/common/src/utils/fileValidation'; -import { - Banner, - Block, - Button, - Flex, - FormField, - Modal, - Select, - Stack, - Text, -} from '@nvidia/foundations-react-core'; -import { datasetFileContentQueryOptions } from '@studio/api/datasets/useDatasetFileContent'; -import { EvaluationTargetMode } from '@studio/api/evaluation/types'; +import { Button, FormField, Stack } from '@nvidia/foundations-react-core'; import { DetailRow } from '@studio/components/DetailRow'; -import { - CreateConfigFormData, - generateInferenceRequestTemplate, - useResetConfigForm, -} from '@studio/hooks/evaluation/useCreateConfigurationForm'; -import { useFileValidation } from '@studio/hooks/evaluation/useFileValidation'; -import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { InputFilePreviewModal } from '@studio/components/evaluation/Configurations/form/InputFile/InputFilePreviewModal'; +import { InputFileValidationBanner } from '@studio/components/evaluation/Configurations/form/InputFile/InputFileValidationBanner'; +import { InputFileProps } from '@studio/components/evaluation/Configurations/form/InputFile/types'; +import { useInputFile } from '@studio/components/evaluation/Configurations/form/InputFile/useInputFile'; import { getDatasetDisplayNameFromFilesUrl } from '@studio/util/files'; -import { logger } from '@studio/util/logger'; -import { useQueryClient } from '@tanstack/react-query'; -import { Plus, CircleCheck, CircleHelp, File as FileIcon } from 'lucide-react'; -import { FC, useState, useCallback, useEffect, useMemo } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Plus, File as FileIcon } from 'lucide-react'; +import { FC } from 'react'; +import { Controller } from 'react-hook-form'; -export interface InputFileProps { - disabled?: boolean; - /** Label for the input file field. Defaults to "Input File" */ - label?: string; - /** Whether to show the inference request template preview. Defaults to false */ - showTemplatePreview?: boolean; -} - -const SUCCESS_CHECK_ICON = ; -const HELP_ICON = ; +export type { InputFileProps }; export const InputFile: FC = ({ disabled, label = 'Input File', showTemplatePreview = false, }) => { - const { control, resetField, setValue, watch } = useFormContext(); - const [modalOpen, setModalOpen] = useState(false); - const [previewModalOpen, setPreviewModalOpen] = useState(false); - const [isValidating, setIsValidating] = useState(false); - const [availableKeys, setAvailableKeys] = useState>([]); - const workspace = useWorkspaceFromPath(); - const queryClient = useQueryClient(); - - // Hook to reset form while preserving key fields - const resetConfigForm = useResetConfigForm(); - - // Watch firstRowData from form instead of local state - const firstRowData = watch('configData.firstRowData'); - const targetMode = watch('configData.targetMode'); - const inputFileUrl = watch('configData.inputFile'); - - // Watch file metadata for preview - const inputFileFormat = watch('configData.inputFileFormat'); - const inputFileDatasetNamespace = watch('configData.inputFileDatasetNamespace'); - const inputFileDatasetName = watch('configData.inputFileDatasetName'); - const inputFilePath = watch('configData.inputFilePath'); - - // Use the file validation hook - const { updateFormFromFile } = useFileValidation({ setValue }); - - // Watch for validation results to display them - const fileValidationResult = watch('configData.fileValidationResult') as - | FileValidationResult - | undefined; - const fileDetectionResult = watch('configData.fileDetectionResult') as - | FileFormatDetectionResult - | undefined; - const detectedSchemaType = watch('configData.detectedSchemaType'); - const inferenceRequestTemplate = watch('configData.inferenceRequestTemplate'); - const templateSelectorInputPrompt = watch('configData.templateSelectorInputPrompt'); - - // Build template preview - only show if prompt is set - const templatePreview = useMemo(() => { - // Template preview requires at minimum a prompt to be set - if (!templateSelectorInputPrompt?.trim()) { - return null; - } - - if (inferenceRequestTemplate) { - return { - messages: inferenceRequestTemplate.messages, - }; - } - - return { - messages: [{ role: 'user', content: templateSelectorInputPrompt }], - }; - }, [inferenceRequestTemplate, templateSelectorInputPrompt]); - - // Helper function to clear all file-related fields - const clearFileRelatedFields = useCallback(() => { - // Reset form to defaults while preserving key fields - resetConfigForm(); - - // Clear local component state - setAvailableKeys([]); - setIsValidating(false); - }, [resetConfigForm]); - - const handleRemoveFileClick = () => { - resetField('configData.inputFile'); - clearFileRelatedFields(); - }; - - const handleReplaceFileClick = () => setModalOpen(true); - - // Extract available keys when we have first row data - useEffect(() => { - if (firstRowData) { - try { - const keys = extractUserFriendlyKeysFromRow(firstRowData); - setAvailableKeys(keys); - } catch { - setAvailableKeys([]); - } - } else { - setAvailableKeys([]); - } - }, [firstRowData]); - - // Regenerate inference request template when prompt changes - useEffect(() => { - if (templateSelectorInputPrompt?.trim()) { - const template = generateInferenceRequestTemplate(templateSelectorInputPrompt); - setValue('configData.inferenceRequestTemplate', template); - } else { - setValue('configData.inferenceRequestTemplate', undefined); - } - }, [templateSelectorInputPrompt, setValue]); - - // Helper function to render validation banner - const renderValidationBanner = () => { - if (isValidating) { - return ( - - Validating file format and structure... - - ); - } - - if (!fileValidationResult) { - return null; - } - - if (fileValidationResult.isValid) { - return ( - - - File Validation - - {/* File format validation message */} - - {SUCCESS_CHECK_ICON} - - {fileValidationResult.format?.toUpperCase()} is valid - - - - {/* Schema detection message */} - - {detectedSchemaType ? SUCCESS_CHECK_ICON : HELP_ICON} - - {detectedSchemaType - ? `Detected Schema: ${detectedSchemaType}` - : 'Schema could not be auto-detected'} - - - - {/* Key detection message - only shown if schema is complete */} - {fileDetectionResult && - detectedSchemaType && - fileDetectionResult.schemaType !== null && - fileDetectionResult.isComplete && ( - - {SUCCESS_CHECK_ICON} - All template strings detected - - )} - - {/* Manual mapping interface - shown when schema not detected or incomplete */} - {(!detectedSchemaType || - (fileDetectionResult && - fileDetectionResult.schemaType !== null && - !fileDetectionResult.isComplete)) && - availableKeys.length > 0 && ( - - Map required keys from your input data - - ( - - {({ ...args }) => ( - ({ - children: key.label, - value: key.value, - })), - ]} - onValueChange={(value: string) => { - // Store the original key value in the primary field - field.onChange(value); - // Also store the interpolated template string - const interpolatedValue = value ? `{{item.${value} | trim}}` : ''; - setValue( - 'configData.templateSelectorInputGroundTruth', - interpolatedValue - ); - }} - disabled={disabled} - placeholder="Select a key" - /> - )} - - )} - /> - - {/* Output Key - Only shown in offline mode */} - {targetMode === EvaluationTargetMode.OFFLINE && ( - ( - - {({ ...args }) => ( - ({ + children: key.label, + value: key.value, + })), + ]} + onValueChange={(value: string) => { + // Store the original key value in the primary field + field.onChange(value); + // Also store the interpolated template string + const interpolatedValue = value ? `{{item.${value} | trim}}` : ''; + setValue('configData.templateSelectorInputPrompt', interpolatedValue); + }} + disabled={disabled} + placeholder="Select a key" + /> + )} + + )} + /> + + ( + + {({ ...args }) => ( + ({ + children: key.label, + value: key.value, + })), + ]} + onValueChange={(value: string) => { + // Store the original key value in the primary field + field.onChange(value); + // Also store the interpolated template string + const interpolatedValue = value ? `{{item.${value} | trim}}` : ''; + setValue('configData.templateSelectorOutput', interpolatedValue); + }} + disabled={disabled} + placeholder="Select a key" + /> + )} + + )} + /> + )} + + )} + + {/* Template Preview - shown in all cases when we have valid file */} + {showTemplatePreview && templatePreview && ( + + Inference Request Template +
    +                {JSON.stringify(templatePreview, null, 2)}
    +              
    +
    + )} +
    +
    + ); + } + + return ( + + File validation failed: {fileValidationResult.error} +
    + + Please ensure your file is valid JSON/JSONL with either messages or prompt-completion + schema. + +
    + ); +}; diff --git a/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/constants.tsx b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/constants.tsx new file mode 100644 index 0000000000..6b4ecab83c --- /dev/null +++ b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/constants.tsx @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CircleCheck, CircleHelp } from 'lucide-react'; + +export const SUCCESS_CHECK_ICON = ; +export const HELP_ICON = ; diff --git a/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/helpers.ts b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/helpers.ts new file mode 100644 index 0000000000..993da0cef7 --- /dev/null +++ b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/helpers.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CreateConfigFormData } from '@studio/hooks/evaluation/useCreateConfigurationForm'; + +type InferenceRequestTemplate = CreateConfigFormData['configData']['inferenceRequestTemplate']; + +/** + * Build the inference request template preview. + * + * Template preview requires at minimum a prompt to be set; returns null when + * no prompt is present. + */ +export const buildTemplatePreview = ( + inferenceRequestTemplate: InferenceRequestTemplate, + templateSelectorInputPrompt: string | undefined +) => { + // Template preview requires at minimum a prompt to be set + if (!templateSelectorInputPrompt?.trim()) { + return null; + } + + if (inferenceRequestTemplate) { + return { + messages: inferenceRequestTemplate.messages, + }; + } + + return { + messages: [{ role: 'user', content: templateSelectorInputPrompt }], + }; +}; diff --git a/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/types.ts b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/types.ts new file mode 100644 index 0000000000..db71bc50a2 --- /dev/null +++ b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/types.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface InputFileProps { + disabled?: boolean; + /** Label for the input file field. Defaults to "Input File" */ + label?: string; + /** Whether to show the inference request template preview. Defaults to false */ + showTemplatePreview?: boolean; +} diff --git a/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/useInputFile.ts b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/useInputFile.ts new file mode 100644 index 0000000000..3921680d24 --- /dev/null +++ b/web/packages/studio/src/components/evaluation/Configurations/form/InputFile/useInputFile.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SubmitUploadType } from '@nemo/common/src/components/UploadModal/types'; +import { extractUserFriendlyKeysFromRow, getFileRowCount } from '@nemo/common/src/utils/file'; +import { + validateFileFormat, + detectFileStructure, + FileValidationResult, + FileFormatDetectionResult, +} from '@nemo/common/src/utils/fileValidation'; +import { datasetFileContentQueryOptions } from '@studio/api/datasets/useDatasetFileContent'; +import { buildTemplatePreview } from '@studio/components/evaluation/Configurations/form/InputFile/helpers'; +import { + CreateConfigFormData, + generateInferenceRequestTemplate, + useResetConfigForm, +} from '@studio/hooks/evaluation/useCreateConfigurationForm'; +import { useFileValidation } from '@studio/hooks/evaluation/useFileValidation'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { logger } from '@studio/util/logger'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +export const useInputFile = () => { + const { control, resetField, setValue, watch } = useFormContext(); + const [modalOpen, setModalOpen] = useState(false); + const [previewModalOpen, setPreviewModalOpen] = useState(false); + const [isValidating, setIsValidating] = useState(false); + const [availableKeys, setAvailableKeys] = useState>([]); + const workspace = useWorkspaceFromPath(); + const queryClient = useQueryClient(); + + // Hook to reset form while preserving key fields + const resetConfigForm = useResetConfigForm(); + + // Watch firstRowData from form instead of local state + const firstRowData = watch('configData.firstRowData'); + const targetMode = watch('configData.targetMode'); + const inputFileUrl = watch('configData.inputFile'); + + // Watch file metadata for preview + const inputFileFormat = watch('configData.inputFileFormat'); + const inputFileDatasetNamespace = watch('configData.inputFileDatasetNamespace'); + const inputFileDatasetName = watch('configData.inputFileDatasetName'); + const inputFilePath = watch('configData.inputFilePath'); + + // Use the file validation hook + const { updateFormFromFile } = useFileValidation({ setValue }); + + // Watch for validation results to display them + const fileValidationResult = watch('configData.fileValidationResult') as + | FileValidationResult + | undefined; + const fileDetectionResult = watch('configData.fileDetectionResult') as + | FileFormatDetectionResult + | undefined; + const detectedSchemaType = watch('configData.detectedSchemaType'); + const inferenceRequestTemplate = watch('configData.inferenceRequestTemplate'); + const templateSelectorInputPrompt = watch('configData.templateSelectorInputPrompt'); + + // Build template preview - only show if prompt is set + const templatePreview = useMemo(() => { + return buildTemplatePreview(inferenceRequestTemplate, templateSelectorInputPrompt); + }, [inferenceRequestTemplate, templateSelectorInputPrompt]); + + // Helper function to clear all file-related fields + const clearFileRelatedFields = useCallback(() => { + // Reset form to defaults while preserving key fields + resetConfigForm(); + + // Clear local component state + setAvailableKeys([]); + setIsValidating(false); + }, [resetConfigForm]); + + const handleRemoveFileClick = () => { + resetField('configData.inputFile'); + clearFileRelatedFields(); + }; + + const handleReplaceFileClick = () => setModalOpen(true); + + // Extract available keys when we have first row data + useEffect(() => { + if (firstRowData) { + try { + const keys = extractUserFriendlyKeysFromRow(firstRowData); + setAvailableKeys(keys); + } catch { + setAvailableKeys([]); + } + } else { + setAvailableKeys([]); + } + }, [firstRowData]); + + // Regenerate inference request template when prompt changes + useEffect(() => { + if (templateSelectorInputPrompt?.trim()) { + const template = generateInferenceRequestTemplate(templateSelectorInputPrompt); + setValue('configData.inferenceRequestTemplate', template); + } else { + setValue('configData.inferenceRequestTemplate', undefined); + } + }, [templateSelectorInputPrompt, setValue]); + + const handleFileSelected = useCallback( + async (file: SubmitUploadType) => { + if (file.type === 'file') return; + + // Clear previous file-related values when changing files + clearFileRelatedFields(); + + // Set the file URL first + setValue('configData.inputFile', file.url); + setIsValidating(true); + + try { + // Fetch and cache the file content using TanStack Query + // This will cache it for pagination without re-downloading + const fileContent = await queryClient.fetchQuery( + datasetFileContentQueryOptions({ + workspace: file.dataset.workspace!, + name: file.dataset.name!, + path: file.path, + }) + ); + + // Create a File object from the downloaded content + const fileName = file.path.split('/').pop() || 'file'; + const fileObj = new File([fileContent], fileName, { type: 'application/json' }); + + // Validate file format + const validationResult = await validateFileFormat(fileObj); + + if (validationResult.isValid && validationResult.format) { + // Detect file structure + const detectionResult = await detectFileStructure( + fileObj, + validationResult.format, + targetMode + ); + + // Store first row for manual mapping (from detection result if available) + setValue('configData.firstRowData', detectionResult?.firstRow || null); + + // Get total row count for pagination + const rowCount = await getFileRowCount(fileObj, validationResult.format); + setValue('configData.inputFileTotalRowCount', rowCount); + setValue('configData.inputFileCurrentRowIndex', 0); + + // Store file metadata for later pagination + setValue('configData.inputFileFormat', validationResult.format as 'json' | 'jsonl'); + setValue('configData.inputFileDatasetNamespace', file.dataset.workspace!); + setValue('configData.inputFileDatasetName', file.dataset.name!); + setValue('configData.inputFilePath', file.path); + + // Use the validation hook to update form fields + updateFormFromFile(validationResult, detectionResult || undefined); + } else { + // Use the validation hook to handle invalid files + setValue('configData.firstRowData', null); + updateFormFromFile(validationResult, undefined); + } + } catch (error) { + logger.error('Failed to validate selected file', error); + // Create error validation result + const errorResult: FileValidationResult = { + isValid: false, + format: null, + error: `Failed to validate file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + updateFormFromFile(errorResult, undefined); + } finally { + setIsValidating(false); + } + }, + [setValue, updateFormFromFile, clearFileRelatedFields, targetMode, queryClient] + ); + + return { + control, + setValue, + workspace, + queryClient, + modalOpen, + setModalOpen, + previewModalOpen, + setPreviewModalOpen, + isValidating, + availableKeys, + targetMode, + inputFileUrl, + inputFileFormat, + inputFileDatasetNamespace, + inputFileDatasetName, + inputFilePath, + fileValidationResult, + fileDetectionResult, + detectedSchemaType, + templatePreview, + handleRemoveFileClick, + handleReplaceFileClick, + handleFileSelected, + }; +}; diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/BulkActionsBar.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/BulkActionsBar.tsx new file mode 100644 index 0000000000..68974da94e --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/BulkActionsBar.tsx @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Flex, Text } from '@nvidia/foundations-react-core'; +import { BulkDeleteModal } from '@studio/components/filesets/FilesetFileExplorer/BulkDeleteModal'; +import type { FileSystemFile, FileSystemNode } from '@studio/components/FilesTable/utils'; +import { getTextWithCount } from '@studio/util/strings'; +import { Copy, Download, FolderOpen, Trash } from 'lucide-react'; +import type { FC } from 'react'; + +export interface BulkActionsBarProps { + selectedItems: FileSystemNode[]; + selectedFiles: FileSystemFile[]; + allSelectedAreFiles: boolean; + isReadWriteDataset: boolean; + workspace: string; + datasetName: string; + clearSelectedItems: () => void; + isDuplicating: boolean; + handleBulkDuplicate: (files: FileSystemFile[]) => Promise; + isDownloading: boolean; + handleBulkDownload: (files: FileSystemFile[]) => Promise; + onMove: () => void; +} + +export const BulkActionsBar: FC = ({ + selectedItems, + selectedFiles, + allSelectedAreFiles, + isReadWriteDataset, + workspace, + datasetName, + clearSelectedItems, + isDuplicating, + handleBulkDuplicate, + isDownloading, + handleBulkDownload, + onMove, +}) => ( + + + {getTextWithCount('row', selectedItems.length, 'rows')} selected + + + {isReadWriteDataset ? ( + + + Delete + + } + /> + ) : null} + {allSelectedAreFiles ? ( + <> + {isReadWriteDataset ? ( + <> + + + + ) : null} + + + ) : null} + + + +); diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerEmptyState.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerEmptyState.tsx new file mode 100644 index 0000000000..f9a2e30ed2 --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerEmptyState.tsx @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; +import { Anchor, Button, Flex } from '@nvidia/foundations-react-core'; +import type { FC } from 'react'; + +export interface FilesetFileExplorerEmptyStateProps { + searchQuery: string; + isReadWriteDataset: boolean; + onNewDirectory: () => void; + onUploadFile: () => void; +} + +export const FilesetFileExplorerEmptyState: FC = ({ + searchQuery, + isReadWriteDataset, + onNewDirectory, + onUploadFile, +}) => ( + + + Organize with folders or upload files by drag-and-drop or browsing.
    Visit the + docs for setup instructions.{' '} + + Documentation + + + ) : ( + 'This fileset is read-only.' + ) + } + actions={ + searchQuery || !isReadWriteDataset ? null : ( + + + + + ) + } + /> +
    +); diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerModals.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerModals.tsx new file mode 100644 index 0000000000..4e26652581 --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerModals.tsx @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AddToFolderModal } from '@studio/components/filesets/AddToFolderModal'; +import { DuplicateFileConfirmationModal } from '@studio/components/filesets/FilesetFileExplorer/DuplicateFileConfirmationModal'; +import { NewDirectoryModal } from '@studio/components/filesets/FilesetFileExplorer/NewDirectoryModal'; +import { UploadToFolderModal } from '@studio/components/filesets/FilesetFileExplorer/UploadToFolderModal'; +import type { ComponentProps, FC } from 'react'; + +export interface FilesetFileExplorerModalsProps { + newDirectoryOpen: boolean; + setNewDirectoryOpen: (open: boolean) => void; + addToFolderOpen: boolean; + setAddToFolderOpen: (open: boolean) => void; + uploadModalOpen: boolean; + setUploadModalOpen: (open: boolean) => void; + workspace: string; + datasetName: string; + currentFolder?: string; + folderContents: ComponentProps['folderContents']; + selectedItems: ComponentProps['selectedItems']; + clearSelectedItems: () => void; + pendingDuplicates: ComponentProps['duplicateFiles']; + confirmDuplicateUpload: ComponentProps['onConfirm']; + cancelDuplicateUpload: ComponentProps['onCancel']; + isUploading: boolean; + stagedUploadFiles: File[]; + filesList: ComponentProps['filesList']; + openFileDialog: ComponentProps['openFileDialog']; + handleConfirmUpload: ComponentProps['onConfirm']; +} + +export const FilesetFileExplorerModals: FC = ({ + newDirectoryOpen, + setNewDirectoryOpen, + addToFolderOpen, + setAddToFolderOpen, + uploadModalOpen, + setUploadModalOpen, + workspace, + datasetName, + currentFolder, + folderContents, + selectedItems, + clearSelectedItems, + pendingDuplicates, + confirmDuplicateUpload, + cancelDuplicateUpload, + isUploading, + stagedUploadFiles, + filesList, + openFileDialog, + handleConfirmUpload, +}) => ( + <> + setNewDirectoryOpen(false)} + workspace={workspace} + datasetName={datasetName} + currentFolder={currentFolder} + folderContents={folderContents} + onSuccess={() => setNewDirectoryOpen(false)} + /> + setAddToFolderOpen(false)} + selectedItems={selectedItems} + workspace={workspace} + datasetName={datasetName} + currentFolder={currentFolder} + folderContents={folderContents} + onComplete={clearSelectedItems} + /> + + setUploadModalOpen(false)} + files={stagedUploadFiles} + defaultFolder={currentFolder} + filesList={filesList} + openFileDialog={openFileDialog} + onConfirm={handleConfirmUpload} + /> + +); diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerToolbar.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerToolbar.tsx new file mode 100644 index 0000000000..d8eccafa2c --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/FilesetFileExplorerToolbar.tsx @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Flex, TableToolbar, TextInput } from '@nvidia/foundations-react-core'; +import { BulkActionsBar } from '@studio/components/filesets/FilesetFileExplorer/BulkActionsBar'; +import type { FileSystemFile, FileSystemNode } from '@studio/components/FilesTable/utils'; +import { Search } from 'lucide-react'; +import type { FC } from 'react'; + +export interface FilesetFileExplorerToolbarProps { + selectedItems: FileSystemNode[]; + selectedFiles: FileSystemFile[]; + allSelectedAreFiles: boolean; + isReadWriteDataset: boolean; + workspace: string; + datasetName: string; + clearSelectedItems: () => void; + isDuplicating: boolean; + handleBulkDuplicate: (files: FileSystemFile[]) => Promise; + isDownloading: boolean; + handleBulkDownload: (files: FileSystemFile[]) => Promise; + onMove: () => void; + searchQuery: string; + handleSearchQueryChange: (value: string, onClearSelection: () => void) => void; + onNewDirectory: () => void; + onUploadFile: () => void; +} + +export const FilesetFileExplorerToolbar: FC = ({ + selectedItems, + selectedFiles, + allSelectedAreFiles, + isReadWriteDataset, + workspace, + datasetName, + clearSelectedItems, + isDuplicating, + handleBulkDuplicate, + isDownloading, + handleBulkDownload, + onMove, + searchQuery, + handleSearchQueryChange, + onNewDirectory, + onUploadFile, +}) => ( + 0} + slotBulkActions={ + + } + > + + handleSearchQueryChange(value, clearSelectedItems)} + placeholder="Search" + slotStart={} + dismissible + data-testid="dataset-details-search-input" + className="min-w-0 flex-1" + /> + {isReadWriteDataset && ( + + + + + )} + + +); diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/constants.ts b/web/packages/studio/src/components/filesets/FilesetFileExplorer/constants.ts new file mode 100644 index 0000000000..18b2b86f06 --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/constants.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const PENDING_FILE_OID = '------PENDING------'; + +export const INDENT_PER_LEVEL = 20; diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/helpers.ts b/web/packages/studio/src/components/filesets/FilesetFileExplorer/helpers.ts new file mode 100644 index 0000000000..60feeb059f --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/helpers.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FileSystemNode } from '@studio/components/FilesTable/utils'; + +export const getItemId = (item: FileSystemNode) => [item.oid, item.type, item.path].join('-'); diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/index.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/index.tsx index e9528b774a..b6c2b08375 100644 --- a/web/packages/studio/src/components/filesets/FilesetFileExplorer/index.tsx +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/index.tsx @@ -1,106 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; import { useFilesRetrieveFileset } from '@nemo/sdk/generated/platform/api'; -import type { FilesetFileOutput } from '@nemo/sdk/generated/platform/schema'; -import { - Anchor, - Button, - Checkbox, - Flex, - ProgressBar, - Spinner, - Stack, - Table, - type TableRowDefinition, - TableToolbar, - Text, - TextInput, -} from '@nvidia/foundations-react-core'; -import { AddToFolderModal } from '@studio/components/filesets/AddToFolderModal'; -import { BulkDeleteModal } from '@studio/components/filesets/FilesetFileExplorer/BulkDeleteModal'; +import { Button, Flex, Spinner, Stack, Table, Text } from '@nvidia/foundations-react-core'; +import { PENDING_FILE_OID } from '@studio/components/filesets/FilesetFileExplorer/constants'; import { DatasetFileDropzone } from '@studio/components/filesets/FilesetFileExplorer/DatasetFileDropzone'; -import { DuplicateFileConfirmationModal } from '@studio/components/filesets/FilesetFileExplorer/DuplicateFileConfirmationModal'; +import { FilesetFileExplorerEmptyState } from '@studio/components/filesets/FilesetFileExplorer/FilesetFileExplorerEmptyState'; +import { FilesetFileExplorerModals } from '@studio/components/filesets/FilesetFileExplorer/FilesetFileExplorerModals'; +import { FilesetFileExplorerToolbar } from '@studio/components/filesets/FilesetFileExplorer/FilesetFileExplorerToolbar'; import { useFileActions } from '@studio/components/filesets/FilesetFileExplorer/hooks/useFileActions'; import { useFileSelection } from '@studio/components/filesets/FilesetFileExplorer/hooks/useFileSelection'; import { useFileUpload } from '@studio/components/filesets/FilesetFileExplorer/hooks/useFileUpload'; -import { NewDirectoryModal } from '@studio/components/filesets/FilesetFileExplorer/NewDirectoryModal'; -import { UploadToFolderModal } from '@studio/components/filesets/FilesetFileExplorer/UploadToFolderModal'; +import type { + ExtraColumn, + FilesetFileExplorerProps, +} from '@studio/components/filesets/FilesetFileExplorer/types'; +import { useFilesetFileExplorerColumns } from '@studio/components/filesets/FilesetFileExplorer/useFilesetFileExplorerColumns'; +import { useFilesetFileExplorerRows } from '@studio/components/filesets/FilesetFileExplorer/useFilesetFileExplorerRows'; import { useBulkDownload } from '@studio/components/filesets/hooks/useBulkDownload'; import { useBulkDuplicate } from '@studio/components/filesets/hooks/useBulkDuplicate'; -import { DirectoryQuickActions } from '@studio/components/FilesTable/DirectoryQuickActions'; -import { FileQuickActions } from '@studio/components/FilesTable/FileQuickActions'; -import type { FileSystemFile, FileSystemNode } from '@studio/components/FilesTable/utils'; +import type { FileSystemFile } from '@studio/components/FilesTable/utils'; import { useDatasetNavigator } from '@studio/hooks/useDatasetNavigator'; -import { getFolderSize, getHumanReadableFileSize } from '@studio/util/files'; -import { getTextWithCount } from '@studio/util/strings'; -import { - ArrowDown, - ArrowUp, - Copy, - Download, - X, - File, - FolderClosed, - FolderOpen, - Search, - Trash, -} from 'lucide-react'; -import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { X } from 'lucide-react'; +import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -const PENDING_FILE_OID = '------PENDING------'; -const getItemId = (item: FileSystemNode) => [item.oid, item.type, item.path].join('-'); - -/** - * Optional extra column injected by a consumer of FilesetFileExplorer. - * Each extra column is appended after the built-in Name and Size columns - * and rendered before the trailing quick-actions column. - * - * The cell renderer receives every FileSystemNode (files AND directories); - * return null for nodes that should render nothing. - */ -export interface ExtraColumn { - header: ReactNode; - cell: (node: FileSystemNode) => ReactNode; - /** Optional fixed header-cell width in px. */ - width?: number; -} - -export interface FilesetFileExplorerProps { - /** Dataset workspace */ - workspace: string; - /** Dataset name */ - datasetName: string; - /** Full dataset identifier (workspace/name) */ - datasetId: string; - /** Current folder path (from query param or state) */ - currentFolder?: string; - /** All files in the dataset (for navigation and search) */ - filesList: FilesetFileOutput[] | undefined; - /** Whether file-list data is loading */ - isLoading: boolean; - /** Whether files are currently being fetched */ - isFilesFetching: boolean; - /** Callback when a file is selected for viewing. When omitted, file rows are - * non-interactive (no row-click navigation, no "View File" quick action). - * Hosts that don't yet have a preview surface should leave this undefined - * rather than passing a no-op, so the view affordance isn't exposed. */ - onFileSelect?: (filePath: string) => void; - /** Gates the fileset metadata fetch. Defaults to true. - * Hosts that mount the explorer behind a panel animation can pass the panel's - * open state to suppress fetches while closed. */ - enabled?: boolean; - /** Purpose-specific columns appended after Name + Size and before quick-actions. - * Hosts use this to inject domain columns (e.g. dataset Schema) without - * pushing dataset-specific knowledge into the shared explorer. */ - extraColumns?: ExtraColumn[]; - /** Fires when the user explicitly toggles a folder open or closed (row click). - * Does NOT fire for the explorer's own auto-expansion from `currentFolder`. - * Hosts can use this to sync URL state (e.g. drop `?filesetFolder=` when the - * user collapses the folder that was named in the URL). */ - onFolderToggle?: (folderPath: string, isExpanded: boolean) => void; -} +export type { ExtraColumn, FilesetFileExplorerProps }; export const FilesetFileExplorer: FC = ({ workspace, @@ -240,202 +164,31 @@ export const FilesetFileExplorer: FC = ({ selectedItems.length > 0 && selectedFiles.length === selectedItems.length; // Table columns - const columns = useMemo( - () => [ - { - children: ( - 0 - ? 'indeterminate' - : false - } - onCheckedChange={(checked) => { - if (checked) { - selectAllItems(); - } else { - clearSelectedItems(); - } - }} - attributes={{ - CheckboxInput: { - 'aria-label': `Select all files and directories`, - 'aria-labelledby': undefined, - }, - }} - /> - ), - attributes: { - TableHeaderCell: { style: { width: 48 } }, - }, - }, - { - children: ( - - ), - }, - { - children: ( - - ), - }, - ...(extraColumns ?? []).map((col) => ({ - children: col.header, - attributes: - col.width !== undefined - ? { TableHeaderCell: { style: { width: col.width } } } - : undefined, - })), - { - children: <>, - attributes: { - TableHeaderCell: { style: { width: 58 } }, - }, - }, - ], - [ - selectedItems, - rowContents, - selectAllItems, - clearSelectedItems, - sortFiles, - sortOrder, - extraColumns, - ] - ); - - const INDENT_PER_LEVEL = 20; + const columns = useFilesetFileExplorerColumns({ + selectedItems, + rowContents, + selectAllItems, + clearSelectedItems, + sortFiles, + sortOrder, + extraColumns, + }); // Table rows - const rows: TableRowDefinition[] = useMemo( - () => - treeRows.map(({ node, depth }) => ({ - id: getItemId(node), - cells: [ - { - children: ( - { - if (checked) { - addSelectedItem(node); - } else { - removeSelectedItem(node); - } - }} - attributes={{ - CheckboxInput: { - 'aria-label': `Select path ${node.path}`, - 'aria-labelledby': undefined, - }, - }} - /> - ), - }, - { - children: ( - - {/* eslint-disable-next-line no-restricted-syntax -- dynamic tree indent */} -
    - - {node.type === 'directory' ? ( - expandedFolders.has(node.path) ? ( - - ) : ( - - ) - ) : ( - - )} -
    {searchQuery ? node.path : node.path.split('/').pop()}
    -
    -
    - {node.oid === PENDING_FILE_OID && ( - - )} -
    - ), - onCellSelect: () => { - if (node.type === 'file') { - onFileSelect?.(node.path); - } else if (node.type === 'directory') { - handleUserFolderToggle(node.path); - } - }, - attributes: { - TableDataCell: { - // Directories always toggle on click; files only do so when a - // view handler is wired up. Skip the pointer cursor for files - // without a handler so the row doesn't look interactive. - className: node.type === 'directory' || onFileSelect ? 'cursor-pointer' : undefined, - }, - }, - }, - { - children: - node.type === 'file' ? getHumanReadableFileSize(node.size) : getFolderSize(node), - }, - ...(extraColumns ?? []).map((col) => ({ - children: col.cell(node), - })), - { - children: - node.oid === PENDING_FILE_OID ? null : node.type === 'file' ? ( - - ) : node.type === 'directory' ? ( - - ) : null, - attributes: { - TableDataCell: { - style: { textOverflow: 'clip' }, - align: 'center', - className: 'h-[59px]', - }, - }, - }, - ], - })), - [ - treeRows, - expandedFolders, - handleUserFolderToggle, - datasetId, - currentFolder, - onFileSelect, - isReadWriteDataset, - selectedItems, - addSelectedItem, - removeSelectedItem, - searchQuery, - extraColumns, - ] - ); + const rows = useFilesetFileExplorerRows({ + treeRows, + expandedFolders, + handleUserFolderToggle, + datasetId, + currentFolder, + onFileSelect, + isReadWriteDataset, + selectedItems, + addSelectedItem, + removeSelectedItem, + searchQuery, + extraColumns, + }); return ( = ({ rowContents.length ? 'min-h-0 flex flex-col' : 'h-full min-h-0 flex flex-col' } > - 0} - slotBulkActions={ - - - {getTextWithCount('row', selectedItems.length, 'rows')} selected - - - {isReadWriteDataset ? ( - - - Delete - - } - /> - ) : null} - {allSelectedAreFiles ? ( - <> - {isReadWriteDataset ? ( - <> - - - - ) : null} - - - ) : null} - - - - } - > - - handleSearchQueryChange(value, clearSelectedItems)} - placeholder="Search" - slotStart={} - dismissible - data-testid="dataset-details-search-input" - className="min-w-0 flex-1" - /> - {isReadWriteDataset && ( - - - - - )} - - + setAddToFolderOpen(true)} + searchQuery={searchQuery} + handleSearchQueryChange={handleSearchQueryChange} + onNewDirectory={() => setNewDirectoryOpen(true)} + onUploadFile={handleOpenUploadModal} + /> {searchQuery && ( @@ -581,82 +244,39 @@ export const FilesetFileExplorer: FC = ({ )} {!rowContents.length ? ( - - - Organize with folders or upload files by drag-and-drop or browsing.{' '} -
    Visit the docs for setup instructions.{' '} - - Documentation - - - ) : ( - 'This fileset is read-only.' - ) - } - actions={ - searchQuery || !isReadWriteDataset ? null : ( - - - - - ) - } - /> -
    + setNewDirectoryOpen(true)} + onUploadFile={handleOpenUploadModal} + /> ) : ( )} - setNewDirectoryOpen(false)} + setNewDirectoryOpen(false)} - /> - setAddToFolderOpen(false)} selectedItems={selectedItems} - workspace={workspace} - datasetName={datasetName} - currentFolder={currentFolder} - folderContents={folderContents} - onComplete={clearSelectedItems} - /> - - setUploadModalOpen(false)} - files={stagedUploadFiles} - defaultFolder={currentFolder} + clearSelectedItems={clearSelectedItems} + pendingDuplicates={pendingDuplicates} + confirmDuplicateUpload={confirmDuplicateUpload} + cancelDuplicateUpload={cancelDuplicateUpload} + isUploading={isUploading} + stagedUploadFiles={stagedUploadFiles} filesList={filesList} openFileDialog={openFileDialog} - onConfirm={handleConfirmUpload} + handleConfirmUpload={handleConfirmUpload} /> )} diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/types.ts b/web/packages/studio/src/components/filesets/FilesetFileExplorer/types.ts new file mode 100644 index 0000000000..58f1b2238b --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/types.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FilesetFileOutput } from '@nemo/sdk/generated/platform/schema'; +import type { FileSystemNode } from '@studio/components/FilesTable/utils'; +import type { ReactNode } from 'react'; + +/** + * Optional extra column injected by a consumer of FilesetFileExplorer. + * Each extra column is appended after the built-in Name and Size columns + * and rendered before the trailing quick-actions column. + * + * The cell renderer receives every FileSystemNode (files AND directories); + * return null for nodes that should render nothing. + */ +export interface ExtraColumn { + header: ReactNode; + cell: (node: FileSystemNode) => ReactNode; + /** Optional fixed header-cell width in px. */ + width?: number; +} + +export interface FilesetFileExplorerProps { + /** Dataset workspace */ + workspace: string; + /** Dataset name */ + datasetName: string; + /** Full dataset identifier (workspace/name) */ + datasetId: string; + /** Current folder path (from query param or state) */ + currentFolder?: string; + /** All files in the dataset (for navigation and search) */ + filesList: FilesetFileOutput[] | undefined; + /** Whether file-list data is loading */ + isLoading: boolean; + /** Whether files are currently being fetched */ + isFilesFetching: boolean; + /** Callback when a file is selected for viewing. When omitted, file rows are + * non-interactive (no row-click navigation, no "View File" quick action). + * Hosts that don't yet have a preview surface should leave this undefined + * rather than passing a no-op, so the view affordance isn't exposed. */ + onFileSelect?: (filePath: string) => void; + /** Gates the fileset metadata fetch. Defaults to true. + * Hosts that mount the explorer behind a panel animation can pass the panel's + * open state to suppress fetches while closed. */ + enabled?: boolean; + /** Purpose-specific columns appended after Name + Size and before quick-actions. + * Hosts use this to inject domain columns (e.g. dataset Schema) without + * pushing dataset-specific knowledge into the shared explorer. */ + extraColumns?: ExtraColumn[]; + /** Fires when the user explicitly toggles a folder open or closed (row click). + * Does NOT fire for the explorer's own auto-expansion from `currentFolder`. + * Hosts can use this to sync URL state (e.g. drop `?filesetFolder=` when the + * user collapses the folder that was named in the URL). */ + onFolderToggle?: (folderPath: string, isExpanded: boolean) => void; +} diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/useFilesetFileExplorerColumns.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/useFilesetFileExplorerColumns.tsx new file mode 100644 index 0000000000..409b18253b --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/useFilesetFileExplorerColumns.tsx @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Checkbox } from '@nvidia/foundations-react-core'; +import type { SortOrder } from '@studio/components/filesets/FilesetFileExplorer/hooks/useFileActions'; +import type { ExtraColumn } from '@studio/components/filesets/FilesetFileExplorer/types'; +import type { FileSystemNode } from '@studio/components/FilesTable/utils'; +import { ArrowDown, ArrowUp } from 'lucide-react'; +import { useMemo } from 'react'; + +export interface UseFilesetFileExplorerColumnsOptions { + selectedItems: FileSystemNode[]; + rowContents: FileSystemNode[]; + selectAllItems: () => void; + clearSelectedItems: () => void; + sortFiles: (sortBy: 'name' | 'size') => void; + sortOrder: SortOrder; + extraColumns?: ExtraColumn[]; +} + +export function useFilesetFileExplorerColumns({ + selectedItems, + rowContents, + selectAllItems, + clearSelectedItems, + sortFiles, + sortOrder, + extraColumns, +}: UseFilesetFileExplorerColumnsOptions) { + return useMemo( + () => [ + { + children: ( + 0 + ? 'indeterminate' + : false + } + onCheckedChange={(checked) => { + if (checked) { + selectAllItems(); + } else { + clearSelectedItems(); + } + }} + attributes={{ + CheckboxInput: { + 'aria-label': `Select all files and directories`, + 'aria-labelledby': undefined, + }, + }} + /> + ), + attributes: { + TableHeaderCell: { style: { width: 48 } }, + }, + }, + { + children: ( + + ), + }, + { + children: ( + + ), + }, + ...(extraColumns ?? []).map((col) => ({ + children: col.header, + attributes: + col.width !== undefined + ? { TableHeaderCell: { style: { width: col.width } } } + : undefined, + })), + { + children: <>, + attributes: { + TableHeaderCell: { style: { width: 58 } }, + }, + }, + ], + [ + selectedItems, + rowContents, + selectAllItems, + clearSelectedItems, + sortFiles, + sortOrder, + extraColumns, + ] + ); +} diff --git a/web/packages/studio/src/components/filesets/FilesetFileExplorer/useFilesetFileExplorerRows.tsx b/web/packages/studio/src/components/filesets/FilesetFileExplorer/useFilesetFileExplorerRows.tsx new file mode 100644 index 0000000000..be4a632441 --- /dev/null +++ b/web/packages/studio/src/components/filesets/FilesetFileExplorer/useFilesetFileExplorerRows.tsx @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Checkbox, + Flex, + ProgressBar, + type TableRowDefinition, +} from '@nvidia/foundations-react-core'; +import { + INDENT_PER_LEVEL, + PENDING_FILE_OID, +} from '@studio/components/filesets/FilesetFileExplorer/constants'; +import { getItemId } from '@studio/components/filesets/FilesetFileExplorer/helpers'; +import type { ExtraColumn } from '@studio/components/filesets/FilesetFileExplorer/types'; +import { DirectoryQuickActions } from '@studio/components/FilesTable/DirectoryQuickActions'; +import { FileQuickActions } from '@studio/components/FilesTable/FileQuickActions'; +import type { FileSystemNode, TreeRow } from '@studio/components/FilesTable/utils'; +import { getFolderSize, getHumanReadableFileSize } from '@studio/util/files'; +import { File, FolderClosed, FolderOpen } from 'lucide-react'; +import { useMemo } from 'react'; + +export interface UseFilesetFileExplorerRowsOptions { + treeRows: TreeRow[]; + expandedFolders: Set; + handleUserFolderToggle: (path: string) => void; + datasetId: string; + currentFolder?: string; + onFileSelect?: (filePath: string) => void; + isReadWriteDataset: boolean; + selectedItems: FileSystemNode[]; + addSelectedItem: (item: FileSystemNode) => void; + removeSelectedItem: (item: FileSystemNode) => void; + searchQuery: string; + extraColumns?: ExtraColumn[]; +} + +export function useFilesetFileExplorerRows({ + treeRows, + expandedFolders, + handleUserFolderToggle, + datasetId, + currentFolder, + onFileSelect, + isReadWriteDataset, + selectedItems, + addSelectedItem, + removeSelectedItem, + searchQuery, + extraColumns, +}: UseFilesetFileExplorerRowsOptions): TableRowDefinition[] { + return useMemo( + () => + treeRows.map(({ node, depth }) => ({ + id: getItemId(node), + cells: [ + { + children: ( + { + if (checked) { + addSelectedItem(node); + } else { + removeSelectedItem(node); + } + }} + attributes={{ + CheckboxInput: { + 'aria-label': `Select path ${node.path}`, + 'aria-labelledby': undefined, + }, + }} + /> + ), + }, + { + children: ( + + {/* eslint-disable-next-line no-restricted-syntax -- dynamic tree indent */} +
    + + {node.type === 'directory' ? ( + expandedFolders.has(node.path) ? ( + + ) : ( + + ) + ) : ( + + )} +
    {searchQuery ? node.path : node.path.split('/').pop()}
    +
    +
    + {node.oid === PENDING_FILE_OID && ( + + )} +
    + ), + onCellSelect: () => { + if (node.type === 'file') { + onFileSelect?.(node.path); + } else if (node.type === 'directory') { + handleUserFolderToggle(node.path); + } + }, + attributes: { + TableDataCell: { + // Directories always toggle on click; files only do so when a + // view handler is wired up. Skip the pointer cursor for files + // without a handler so the row doesn't look interactive. + className: node.type === 'directory' || onFileSelect ? 'cursor-pointer' : undefined, + }, + }, + }, + { + children: + node.type === 'file' ? getHumanReadableFileSize(node.size) : getFolderSize(node), + }, + ...(extraColumns ?? []).map((col) => ({ + children: col.cell(node), + })), + { + children: + node.oid === PENDING_FILE_OID ? null : node.type === 'file' ? ( + + ) : node.type === 'directory' ? ( + + ) : null, + attributes: { + TableDataCell: { + style: { textOverflow: 'clip' }, + align: 'center', + className: 'h-[59px]', + }, + }, + }, + ], + })), + [ + treeRows, + expandedFolders, + handleUserFolderToggle, + datasetId, + currentFolder, + onFileSelect, + isReadWriteDataset, + selectedItems, + addSelectedItem, + removeSelectedItem, + searchQuery, + extraColumns, + ] + ); +} diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx new file mode 100644 index 0000000000..bc6e43b1cc --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/AgentDetailsContent.tsx @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KVPair } from '@nemo/common/src/components/KVPair'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { StatusBadge } from '@nemo/common/src/components/StatusBadge'; +import { isDefined } from '@nemo/common/src/utils/list'; +import type { Agent } from '@nemo/sdk/generated/agents/schema/Agent'; +import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; +import { + Accordion, + Block, + Button, + Flex, + Stack, + StatusIndicator, + Text, +} from '@nvidia/foundations-react-core'; +import type { AgentConfig } from '@studio/components/dataViews/AgentsDataView'; +import { getAgentModelNames } from '@studio/components/dataViews/AgentsDataView/utils'; +import { deploymentStatusColor } from '@studio/components/sidePanels/AgentPanels/AgentPanel/helpers'; +import { NoHealthyDeploymentsBanner } from '@studio/components/sidePanels/AgentPanels/AgentPanel/NoHealthyDeploymentsBanner'; +import type { WalkthroughStep } from '@studio/components/sidePanels/AgentPanels/AgentPanel/walkthrough'; +import type { AgentEvalJob } from '@studio/routes/agents/AgentEvaluationsRoute/api'; +import { getAgentEvaluationDetailRoute, getAgentEvaluationsListRoute } from '@studio/routes/utils'; +import type { FC, RefObject } from 'react'; +import { Link } from 'react-router-dom'; + +interface AgentDetailsContentProps { + workspace: string; + agentName?: string; + agent?: Agent; + agentDeployments: AgentDeployment[]; + agentEvals: AgentEvalJob[]; + isDeploymentsLoading: boolean; + isDeploying: boolean; + walkthroughStep: WalkthroughStep | null; + deployButtonRef: RefObject; + onSubmitEval: () => void; + onDeploy: () => void; + onSwitchToChat: (deployment: AgentDeployment) => void; + onDeleteDeployment: (deployment: AgentDeployment) => void; +} + +export const AgentDetailsContent: FC = ({ + workspace, + agentName, + agent, + agentDeployments, + agentEvals, + isDeploymentsLoading, + isDeploying, + walkthroughStep, + deployButtonRef, + onSubmitEval, + onDeploy, + onSwitchToChat, + onDeleteDeployment, +}) => ( + + + + {agentName} + {isDefined(agent?.description) && agent.description && ( + + {agent.description} + + )} + + +
    + +
    +
    +
    +
    + + + + {isDefined(agent?.description) && ( + + )} + {(() => { + const models = getAgentModelNames(agent?.config as AgentConfig | undefined); + return models.length > 0 ? ( + + ) : null; + })()} + {isDefined(agent?.config_format) && ( + + )} +
    + ), + value: 'agent-details', + }, + { + chevronPosition: 'start', + slotTrigger: 'Deployments', + slotContent: + !isDeploymentsLoading && agentDeployments.length === 0 ? ( + + ) : ( + + {agentDeployments.map((deployment) => ( + + + + {deployment.name} + {deployment.endpoint && ( + + {deployment.endpoint} + + )} + {deployment.error && ( + + {deployment.error} + + )} + + + + + + + + ))} + + ), + value: 'deployments', + }, + { + chevronPosition: 'start' as const, + slotTrigger: 'Recent Evaluations', + slotContent: + agentEvals.length === 0 ? ( + + No evaluation jobs found for this agent. + + + View all evaluations → + + + + ) : ( + + {agentEvals.map((job) => ( + + + + + {job.name} + + + + + + + + + ))} + + + View all evaluations → + + + + ), + value: 'evaluations', + }, + ]} + /> + +); diff --git a/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx new file mode 100644 index 0000000000..040370a71e --- /dev/null +++ b/web/packages/studio/src/components/sidePanels/AgentPanels/AgentPanel/ChatPlaygroundContent.tsx @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDeployment } from '@nemo/sdk/generated/agents/schema/AgentDeployment'; +import { Block, Select } from '@nvidia/foundations-react-core'; +import { ModelChat } from '@studio/components/ModelChat'; +import { NoHealthyDeploymentsBanner } from '@studio/components/sidePanels/AgentPanels/AgentPanel/NoHealthyDeploymentsBanner'; +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; +import type { FC, RefObject } from 'react'; + +interface ChatPlaygroundContentProps { + workspace: string; + agentName?: string; + chatDeployment?: AgentDeployment; + healthyDeployments: AgentDeployment[]; + isDeploymentsLoading: boolean; + isDeploying: boolean; + chatAreaRef: RefObject; + onSelectDeployment: (name: string) => void; + onDeploy: () => void; +} + +export const ChatPlaygroundContent: FC = ({ + workspace, + agentName, + chatDeployment, + healthyDeployments, + isDeploymentsLoading, + isDeploying, + chatAreaRef, + onSelectDeployment, + onDeploy, +}) => { + const deploymentSelectItems = healthyDeployments.flatMap((d) => + d.name + ? [ + { + value: d.name, + children: d.status ? `${d.name} · ${d.status}` : d.name, + }, + ] + : [] + ); + const noHealthyDeployments = !isDeploymentsLoading && healthyDeployments.length === 0; + + return ( +
    + {!noHealthyDeployments && healthyDeployments.length > 1 && ( + + setSelectedDeploymentName(v)} - /> - - )} - {noHealthyDeployments && ( - - setCreateDeploymentOpen(true)} - /> - - )} - - - -
    + setSelectedDeploymentName(v)} + onDeploy={() => setCreateDeploymentOpen(true)} + /> ); } else { content = ( - - - - {agentName} - {isDefined(agent?.description) && agent.description && ( - - {agent.description} - - )} - - -
    - -
    -
    -
    -
    - - - - {isDefined(agent?.description) && ( - - )} - {(() => { - const models = getAgentModelNames(agent?.config as AgentConfig | undefined); - return models.length > 0 ? ( - - ) : null; - })()} - {isDefined(agent?.config_format) && ( - - )} -
    - ), - value: 'agent-details', - }, - { - chevronPosition: 'start', - slotTrigger: 'Deployments', - slotContent: - !isDeploymentsLoading && agentDeployments.length === 0 ? ( - setCreateDeploymentOpen(true)} - message="No deployments for this agent." - /> - ) : ( - - {agentDeployments.map((deployment) => ( - - - - {deployment.name} - {deployment.endpoint && ( - - {deployment.endpoint} - - )} - {deployment.error && ( - - {deployment.error} - - )} - - - - - - - - ))} - - ), - value: 'deployments', - }, - { - chevronPosition: 'start' as const, - slotTrigger: 'Recent Evaluations', - slotContent: - agentEvals.length === 0 ? ( - - No evaluation jobs found for this agent. - - - View all evaluations → - - - - ) : ( - - {agentEvals.map((job) => ( - - - - - {job.name} - - - - - - - - - ))} - - - View all evaluations → - - - - ), - value: 'evaluations', - }, - ]} - /> - + setSubmitEvalOpen(true)} + onDeploy={() => setCreateDeploymentOpen(true)} + onSwitchToChat={switchToChat} + onDeleteDeployment={(deployment) => setDeleteDeploymentTarget(deployment)} + /> ); } @@ -488,47 +172,13 @@ export const AgentPanel: FC = ({ {content} - {walkthroughStep === 'deploy' && ( - - )} - {walkthroughStep === 'switch-to-chat' && ( - - )} - {walkthroughStep === 'wait' && ( - - )} - {walkthroughStep === 'chat' && ( - - )} + {deleteDeploymentTarget && ( { + const queryClient = useQueryClient(); + const toast = useToast(); + + const { data: agentsResponse } = useAgentsListAgents(workspace, undefined, { + query: { enabled: !!agentName }, + }); + + const { data: deploymentsResponse, isLoading: isDeploymentsLoading } = useAgentsListDeployments( + workspace, + undefined, + { + query: { + enabled: !!agentName, + // Poll quickly while any deployment is mid-transition (pending/starting/deleting) + // so the panel reflects controller-side progress; fall back to the long interval + // otherwise to match the agents table. + refetchInterval: (query) => { + const deployments = query.state.data?.data ?? []; + const transitional = deployments.some( + (d) => + d.agent === agentName && + (d.status === 'pending' || d.status === 'starting' || d.status === 'deleting') + ); + return transitional ? JOB_POLLING_INTERVAL_MS : JOB_POLLING_INTERVAL_LONG; + }, + }, + } + ); + + const agentsData = agentsResponse?.data; + const deploymentsData = deploymentsResponse?.data; + + // Recent evaluations targeting this agent. The platform's job filter API + // doesn't expose ``spec.agent`` as a top-level filter, so we fetch the + // workspace's eval jobs and filter client-side. Capped at the most recent + // N to keep the panel scannable; the full list is on the evaluations route. + const { data: agentEvalsData } = useQuery({ + queryKey: ['agent-eval-jobs', workspace, 'panel', agentName] as const, + queryFn: ({ signal }) => fetchAgentEvalJobs(workspace, signal), + enabled: !!agentName && !!workspace, + }); + + const deleteDeploymentMutation = useAgentsDeleteDeployment({ + mutation: { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: getAgentsListDeploymentsQueryKey(workspace), + }); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + }); + + const agent = agentName ? (agentsData ?? []).find((a) => a.name === agentName) : undefined; + const agentDeployments = useMemo( + () => (deploymentsData ?? []).filter((d) => d.agent === agentName), + [deploymentsData, agentName] + ); + + const agentEvals = useMemo(() => { + if (!agentName) return []; + const all = agentEvalsData ?? []; + // Match either the bare agent name or a workspace-prefixed ref. + const matches = all.filter((job) => { + const a = job.spec.agent; + if (typeof a !== 'string') return false; + const bare = a.includes('/') ? a.split('/').pop() : a; + return a === agentName || bare === agentName; + }); + return matches.slice(0, RECENT_EVAL_LIMIT); + }, [agentEvalsData, agentName]); + + const healthyDeployments = useMemo( + () => agentDeployments.filter((d) => d.status === 'running'), + [agentDeployments] + ); + + const isDeploying = useMemo( + () => agentDeployments.some((d) => d.status === 'pending' || d.status === 'starting'), + [agentDeployments] + ); + + const chatDeployment = useMemo(() => { + if (selectedDeploymentName) { + return healthyDeployments.find((d) => d.name === selectedDeploymentName); + } + return healthyDeployments[0]; + }, [healthyDeployments, selectedDeploymentName]); + + return { + isDeploymentsLoading, + agent, + agentDeployments, + agentEvals, + healthyDeployments, + isDeploying, + chatDeployment, + deleteDeploymentMutation, + }; +}; diff --git a/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/constants.ts b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/constants.ts new file mode 100644 index 0000000000..f5cb441e57 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/constants.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { FileFormat, type FileFormatType } from '@nemo/common/src/types'; + +export const INFER_FROM_EXISTING_MAX_FILES = 10; + +export const FORMAT_BY_EXTENSION: Record = { + json: FileFormat.JSON, + jsonl: FileFormat.JSONL, + csv: FileFormat.CSV, + parquet: FileFormat.PARQUET, +}; diff --git a/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/helpers.ts b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/helpers.ts new file mode 100644 index 0000000000..0e2da50f26 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/helpers.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FileFormatType } from '@nemo/common/src/types'; +import type { DatasetMetadataContent } from '@nemo/sdk/generated/platform/schema'; +import { FORMAT_BY_EXTENSION } from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/constants'; +import { + DEFAULT_SCHEMA_VALUE, + SHOW_ALL_VALUE, +} from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/SchemaSelectControl'; + +export function detectFormatFromPath(path: string): FileFormatType | null { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + return FORMAT_BY_EXTENSION[ext] ?? null; +} + +/** Resolve the effective JSON Schema applied to a file path, given a metadata + * payload. Mirrors backend resolution: explicit `schemas_by_path` mapping wins + * (string ref → schema_defs entry, inline object → the object itself); + * otherwise fall back to root `schema` (ref → schema_defs entry, inline → + * the object). Returns undefined when no schema applies. */ +export function resolveSchemaForFile( + metadata: DatasetMetadataContent | undefined, + path: string +): unknown { + if (!metadata) return undefined; + const mapped = metadata.schemas_by_path?.[path]; + if (typeof mapped === 'string') return metadata.schema_defs?.[mapped]; + if (mapped && typeof mapped === 'object') return mapped; + const root = metadata.schema; + if (root === undefined || root === null) return undefined; + if (typeof root === 'string') return metadata.schema_defs?.[root]; + return root; +} + +/** Look up the JSON Schema object that backs the given dropdown selection. */ +export function lookupSchemaForSelection( + metadata: DatasetMetadataContent | undefined, + selection: string +): Record | undefined { + if (!metadata) return undefined; + if (selection === SHOW_ALL_VALUE) return undefined; // Show All is whole-payload, not a single schema. + if (selection === DEFAULT_SCHEMA_VALUE) { + const root = metadata.schema; + if (root === undefined || root === null) return undefined; + if (typeof root === 'string') return metadata.schema_defs?.[root]; + return root as Record; + } + return metadata.schema_defs?.[selection]; +} + +/** True when a schema is an object-typed JSON Schema with a `properties` map. + * Those schemas are rendered "properties-only" in the editor; everything else + * is rendered as the whole schema object. */ +export function hasPropertiesMap(schema: Record | undefined): boolean { + if (!schema) return false; + const props = schema.properties; + return props !== null && typeof props === 'object' && !Array.isArray(props); +} + +/** Resolve what the editor should display for a given dropdown selection. + * For single-schema selections, this returns just the `properties` value + * when present (so the user edits field definitions, not the surrounding + * $schema / type wrapper). */ +export function deriveSelectionText( + metadata: DatasetMetadataContent | undefined, + selection: string +): string { + if (!metadata) return ''; + if (selection === SHOW_ALL_VALUE) return JSON.stringify(metadata, null, 2); + const schema = lookupSchemaForSelection(metadata, selection); + if (!schema) return ''; + if (hasPropertiesMap(schema)) { + return JSON.stringify(schema.properties, null, 2); + } + return JSON.stringify(schema, null, 2); +} + +/** Build the updated `metadata.dataset` payload for a single-schema edit. + * The `parsedEditorValue` is whatever the user typed in the editor — which + * is either the `properties` map (when the original schema had one) or the + * whole schema object. We look up the original schema to decide which case + * applies and rebuild the schema accordingly, preserving non-`properties` + * fields like `$schema`, `type`, `required`, etc. */ +export function applySingleSchemaEdit( + metadata: DatasetMetadataContent | undefined, + selection: string, + parsedEditorValue: Record +): DatasetMetadataContent | undefined { + const base: DatasetMetadataContent = metadata + ? { + schema: metadata.schema, + schema_defs: { ...(metadata.schema_defs ?? {}) }, + schemas_by_path: { ...(metadata.schemas_by_path ?? {}) }, + } + : { schema_defs: {}, schemas_by_path: {} }; + + const original = lookupSchemaForSelection(metadata, selection); + // If the original had a `properties` map, the editor was showing just that + // value — re-wrap into the original shell. Otherwise the editor was + // showing the full schema and the parsed value IS the new schema. + const newSchema: Record = hasPropertiesMap(original) + ? { ...(original as Record), properties: parsedEditorValue } + : parsedEditorValue; + + if (selection === DEFAULT_SCHEMA_VALUE) { + const root = base.schema; + if (typeof root === 'string') { + // Root is a ref to a schema_def; update that def in place. + base.schema_defs = { ...(base.schema_defs ?? {}), [root]: newSchema }; + } else { + // Inline (or absent) → set inline. + base.schema = newSchema; + } + return base; + } + + // selection is a schema_defs key + base.schema_defs = { ...(base.schema_defs ?? {}), [selection]: newSchema }; + return base; +} diff --git a/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/index.tsx b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/index.tsx index a6fee84e48..408b7e3de2 100644 --- a/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/index.tsx +++ b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/index.tsx @@ -3,35 +3,13 @@ import { CodeEditor } from '@nemo/common/src/components/CodeEditor'; import { ContentType } from '@nemo/common/src/components/CodeEditor/constants'; -import { FileFormat, SUPPORTED_FILE_FORMATS, type FileFormatType } from '@nemo/common/src/types'; -import { getFirstRow } from '@nemo/common/src/utils/file'; -import { - buildDatasetMetadata, - canonicalJson, - inferJsonSchema, - isSchemaAssignableFile, - parseAndValidate, - type PerFileInferred, -} from '@nemo/common/src/utils/jsonSchema'; -import { - getFilesRetrieveFilesetQueryKey, - useFilesUpdateFilesetMetadata, -} from '@nemo/sdk/generated/platform/api'; -import type { - DatasetMetadataContent, - FilesetFileOutput, - FilesetOutput, -} from '@nemo/sdk/generated/platform/schema'; +import { SUPPORTED_FILE_FORMATS } from '@nemo/common/src/types'; +import type { FilesetFileOutput, FilesetOutput } from '@nemo/sdk/generated/platform/schema'; import { Button, Flex, Stack, TableToolbar, Text } from '@nvidia/foundations-react-core'; -import { useDownloadFileHead } from '@studio/components/filesets/hooks/useDownloadFileHead'; -import { - DEFAULT_SCHEMA_VALUE, - SchemaSelectControl, - SHOW_ALL_VALUE, -} from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/SchemaSelectControl'; +import { SchemaSelectControl } from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/SchemaSelectControl'; import { SharedSchemaConfirmModal } from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/SharedSchemaConfirmModal'; -import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useRef, useState, type FC } from 'react'; +import { useDatasetSchemaEditor } from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/useDatasetSchemaEditor'; +import { type FC } from 'react'; export interface DatasetSchemaEditorProps { workspace: string; @@ -43,123 +21,6 @@ export interface DatasetSchemaEditorProps { selectedFilePath?: string; } -const INFER_FROM_EXISTING_MAX_FILES = 10; - -const FORMAT_BY_EXTENSION: Record = { - json: FileFormat.JSON, - jsonl: FileFormat.JSONL, - csv: FileFormat.CSV, - parquet: FileFormat.PARQUET, -}; - -function detectFormatFromPath(path: string): FileFormatType | null { - const ext = path.split('.').pop()?.toLowerCase() ?? ''; - return FORMAT_BY_EXTENSION[ext] ?? null; -} - -/** Resolve the effective JSON Schema applied to a file path, given a metadata - * payload. Mirrors backend resolution: explicit `schemas_by_path` mapping wins - * (string ref → schema_defs entry, inline object → the object itself); - * otherwise fall back to root `schema` (ref → schema_defs entry, inline → - * the object). Returns undefined when no schema applies. */ -function resolveSchemaForFile(metadata: DatasetMetadataContent | undefined, path: string): unknown { - if (!metadata) return undefined; - const mapped = metadata.schemas_by_path?.[path]; - if (typeof mapped === 'string') return metadata.schema_defs?.[mapped]; - if (mapped && typeof mapped === 'object') return mapped; - const root = metadata.schema; - if (root === undefined || root === null) return undefined; - if (typeof root === 'string') return metadata.schema_defs?.[root]; - return root; -} - -/** Look up the JSON Schema object that backs the given dropdown selection. */ -function lookupSchemaForSelection( - metadata: DatasetMetadataContent | undefined, - selection: string -): Record | undefined { - if (!metadata) return undefined; - if (selection === SHOW_ALL_VALUE) return undefined; // Show All is whole-payload, not a single schema. - if (selection === DEFAULT_SCHEMA_VALUE) { - const root = metadata.schema; - if (root === undefined || root === null) return undefined; - if (typeof root === 'string') return metadata.schema_defs?.[root]; - return root as Record; - } - return metadata.schema_defs?.[selection]; -} - -/** True when a schema is an object-typed JSON Schema with a `properties` map. - * Those schemas are rendered "properties-only" in the editor; everything else - * is rendered as the whole schema object. */ -function hasPropertiesMap(schema: Record | undefined): boolean { - if (!schema) return false; - const props = schema.properties; - return props !== null && typeof props === 'object' && !Array.isArray(props); -} - -/** Resolve what the editor should display for a given dropdown selection. - * For single-schema selections, this returns just the `properties` value - * when present (so the user edits field definitions, not the surrounding - * $schema / type wrapper). */ -function deriveSelectionText( - metadata: DatasetMetadataContent | undefined, - selection: string -): string { - if (!metadata) return ''; - if (selection === SHOW_ALL_VALUE) return JSON.stringify(metadata, null, 2); - const schema = lookupSchemaForSelection(metadata, selection); - if (!schema) return ''; - if (hasPropertiesMap(schema)) { - return JSON.stringify(schema.properties, null, 2); - } - return JSON.stringify(schema, null, 2); -} - -/** Build the updated `metadata.dataset` payload for a single-schema edit. - * The `parsedEditorValue` is whatever the user typed in the editor — which - * is either the `properties` map (when the original schema had one) or the - * whole schema object. We look up the original schema to decide which case - * applies and rebuild the schema accordingly, preserving non-`properties` - * fields like `$schema`, `type`, `required`, etc. */ -function applySingleSchemaEdit( - metadata: DatasetMetadataContent | undefined, - selection: string, - parsedEditorValue: Record -): DatasetMetadataContent | undefined { - const base: DatasetMetadataContent = metadata - ? { - schema: metadata.schema, - schema_defs: { ...(metadata.schema_defs ?? {}) }, - schemas_by_path: { ...(metadata.schemas_by_path ?? {}) }, - } - : { schema_defs: {}, schemas_by_path: {} }; - - const original = lookupSchemaForSelection(metadata, selection); - // If the original had a `properties` map, the editor was showing just that - // value — re-wrap into the original shell. Otherwise the editor was - // showing the full schema and the parsed value IS the new schema. - const newSchema: Record = hasPropertiesMap(original) - ? { ...(original as Record), properties: parsedEditorValue } - : parsedEditorValue; - - if (selection === DEFAULT_SCHEMA_VALUE) { - const root = base.schema; - if (typeof root === 'string') { - // Root is a ref to a schema_def; update that def in place. - base.schema_defs = { ...(base.schema_defs ?? {}), [root]: newSchema }; - } else { - // Inline (or absent) → set inline. - base.schema = newSchema; - } - return base; - } - - // selection is a schema_defs key - base.schema_defs = { ...(base.schema_defs ?? {}), [selection]: newSchema }; - return base; -} - /** * Dataset-specific schema editor orchestrator. * @@ -177,344 +38,39 @@ export const DatasetSchemaEditor: FC = ({ filesList, selectedFilePath, }) => { - const savedMetadata: DatasetMetadataContent | undefined = fileset.metadata?.dataset; - - const defKeys = useMemo( - () => Object.keys(savedMetadata?.schema_defs ?? {}).sort(), - [savedMetadata] - ); - // Two flavors of root schema: inline object (shows separately as "Default") - // vs string ref to a schema_defs key (that key shows with a "(default)" - // marker, no separate entry). - const rootSchema = savedMetadata?.schema; - const defaultDefKey = - typeof rootSchema === 'string' && defKeys.includes(rootSchema) ? rootSchema : undefined; - const hasInlineDefault = - rootSchema !== undefined && rootSchema !== null && typeof rootSchema !== 'string'; - - const pickInitialSelection = useCallback((): string => { - if (hasInlineDefault) return DEFAULT_SCHEMA_VALUE; - if (defaultDefKey) return defaultDefKey; - return defKeys[0] ?? SHOW_ALL_VALUE; - }, [hasInlineDefault, defaultDefKey, defKeys]); - - // Lazy initializer so mount-with-file lands at the mapped schema directly - // (no extra render). Subsequent file-path transitions are handled by the - // useEffect below. - const [selectedSchema, setSelectedSchema] = useState(() => { - if (selectedFilePath) { - const mapped = savedMetadata?.schemas_by_path?.[selectedFilePath]; - if (typeof mapped === 'string' && defKeys.includes(mapped)) return mapped; - } - if (hasInlineDefault) return DEFAULT_SCHEMA_VALUE; - if (defaultDefKey) return defaultDefKey; - return defKeys[0] ?? SHOW_ALL_VALUE; - }); - - // When savedMetadata changes, drop the selected value if it no longer - // corresponds to a real schema. Keep it stable otherwise. - useEffect(() => { - if ( - selectedSchema === SHOW_ALL_VALUE || - (selectedSchema === DEFAULT_SCHEMA_VALUE && hasInlineDefault) || - defKeys.includes(selectedSchema) - ) { - return; - } - setSelectedSchema(pickInitialSelection()); - }, [selectedSchema, hasInlineDefault, defKeys, pickInitialSelection]); - - // Handle file-path TRANSITIONS only (initial mount is handled by the lazy - // useState initializer above). File preview opened: jump to file's mapped - // schema. File preview closed: jump to Show All. - const prevFilePathRef = useRef(selectedFilePath); - useEffect(() => { - const prev = prevFilePathRef.current; - const cur = selectedFilePath; - if (prev === cur) return; - prevFilePathRef.current = cur; - if (!cur) { - setSelectedSchema(SHOW_ALL_VALUE); - return; - } - const mapped = savedMetadata?.schemas_by_path?.[cur]; - if (typeof mapped === 'string' && defKeys.includes(mapped)) { - setSelectedSchema(mapped); - return; - } - if (mapped && typeof mapped === 'object') { - setSelectedSchema(SHOW_ALL_VALUE); - return; - } - if (defaultDefKey) { - setSelectedSchema(defaultDefKey); - return; - } - if (hasInlineDefault) { - setSelectedSchema(DEFAULT_SCHEMA_VALUE); - return; - } - setSelectedSchema(SHOW_ALL_VALUE); - }, [selectedFilePath, savedMetadata, defKeys, defaultDefKey, hasInlineDefault]); - - // Per-selection unsaved edits: switching selections preserves what the user - // had been typing. Saving or Resetting clears the entry for that selection. - const [editsBySelection, setEditsBySelection] = useState>({}); - - const derivedText = useMemo( - () => deriveSelectionText(savedMetadata, selectedSchema), - [savedMetadata, selectedSchema] - ); - const text = editsBySelection[selectedSchema] ?? derivedText; - const userEdited = selectedSchema in editsBySelection; - - const handleEditorChange = useCallback( - (next: string) => { - setEditsBySelection((prev) => ({ ...prev, [selectedSchema]: next })); - }, - [selectedSchema] - ); - - const handleReset = useCallback(() => { - setEditsBySelection((prev) => { - if (!(selectedSchema in prev)) return prev; - const next = { ...prev }; - delete next[selectedSchema]; - return next; - }); - setInferError(null); - }, [selectedSchema]); - - // Inference state lives here too (used by "Infer from existing files"). - const [isInferring, setIsInferring] = useState(false); - const [inferError, setInferError] = useState(null); - - const downloadFileHead = useDownloadFileHead(); - - const supportedExistingFiles = useMemo( - () => - (filesList ?? []) - .filter((f) => detectFormatFromPath(f.path) !== null) - // Sort root-level files first, then deeper paths; alphabetical within - // each depth. `buildDatasetMetadata` picks the first-encountered - // canonical as the default on ties, so this makes the default come - // from a top-level file (matching user expectation when both root - // and nested files exist). - .slice() - .sort((a, b) => { - const aDepth = a.path.split('/').length; - const bDepth = b.path.split('/').length; - if (aDepth !== bDepth) return aDepth - bDepth; - return a.path.localeCompare(b.path); - }), - [filesList] - ); - - const handleInferFromExisting = useCallback(async () => { - if (supportedExistingFiles.length === 0) return; - setIsInferring(true); - setInferError(null); - try { - const decoder = new TextDecoder('utf-8'); - const perFile: PerFileInferred[] = []; - for (const file of supportedExistingFiles.slice(0, INFER_FROM_EXISTING_MAX_FILES)) { - const format = detectFormatFromPath(file.path); - if (!format) continue; - const buffer = await downloadFileHead({ - workspace, - datasetName, - path: file.path, - bytes: file.size, - }); - if (!buffer) continue; - const textContent = decoder.decode(buffer); - const blob = new File([textContent], file.path); - try { - const row = await getFirstRow(blob, format); - if (row && typeof row === 'object') { - perFile.push({ path: file.path, schema: inferJsonSchema(row) }); - } - } catch { - // Skip unparseable files - the merged result still includes the rest. - } - } - if (perFile.length === 0) { - setInferError('Could not infer a schema from existing files.'); - return; - } - // "Infer from existing files" is a full re-inference: the resulting - // metadata.dataset REPLACES the prior contents (no merging with - // savedMetadata). Merging would mint new defs alongside outdated ones - // whenever the inference algorithm improves, leaving orphan schemas. - // Manual schemas added in Show All are also dropped here — the user - // should use Show All if they want to layer custom defs on top. - const inferred = buildDatasetMetadata(perFile); - setEditsBySelection((prev) => ({ - ...prev, - [SHOW_ALL_VALUE]: JSON.stringify(inferred, null, 2), - })); - setSelectedSchema(SHOW_ALL_VALUE); - } finally { - setIsInferring(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- workspace/datasetName captured inside downloadFileHead's own useCallback - }, [supportedExistingFiles, downloadFileHead]); - - const { mutateAsync: updateMetadata, isPending: isSaving } = useFilesUpdateFilesetMetadata(); - const queryClient = useQueryClient(); - - const validation = useMemo(() => { - if (!text.trim()) return { valid: true as const, errors: [] }; - const result = parseAndValidate(text); - return result.valid ? { valid: true as const, errors: [] } : result; - }, [text]); - - // Count of files affected by saving the current edit. - // Single-schema view: iterate filesList, count files whose resolved - // schema is the selected one (explicit ref OR implicit default). - // Show All view: diff parsed metadata against `savedMetadata` and count - // files whose RESOLVED schema would change. - const sharedReferrerCount = useMemo(() => { - // Only data files (`.json` / `.jsonl`) carry a schema in this UI. Non-data - // files inflated the "Schema is used by N files" count on external - // datasets where READMEs, images, and other artifacts dominate the tree. - const files = (filesList ?? []).filter((f) => isSchemaAssignableFile(f.path)); - if (selectedSchema === SHOW_ALL_VALUE) { - const parsed = parseAndValidate(text); - if (!parsed.valid) return 0; - const newMetadata = parsed.value as DatasetMetadataContent; - let affected = 0; - for (const f of files) { - const before = resolveSchemaForFile(savedMetadata, f.path); - const after = resolveSchemaForFile(newMetadata, f.path); - if (canonicalJson(before) !== canonicalJson(after)) affected += 1; - } - return affected; - } - const byPath = savedMetadata?.schemas_by_path ?? {}; - const isDefault = selectedSchema === DEFAULT_SCHEMA_VALUE || selectedSchema === defaultDefKey; - let count = 0; - for (const f of files) { - const mapped = byPath[f.path]; - if (typeof mapped === 'string') { - if (mapped === selectedSchema) count += 1; - continue; - } - if (mapped && typeof mapped === 'object') continue; - if (isDefault) count += 1; - } - return count; - }, [selectedSchema, savedMetadata, defaultDefKey, filesList, text]); - - const [pendingShareConfirm, setPendingShareConfirm] = useState(false); - - const performSave = useCallback(async () => { - if (!validation.valid) return; - - const trimmed = text.trim(); - const isClearing = trimmed === '' && selectedSchema === SHOW_ALL_VALUE; - - // Clearing only makes sense in the Show All view (wipes the whole - // metadata.dataset payload). For single-schema selections, empty text - // is ambiguous and isn't supported here - the user can switch to - // Show All to clear, or edit the field they want to remove. - if (trimmed === '' && !isClearing) return; - - let newDataset: DatasetMetadataContent | null; - if (isClearing) { - newDataset = null; - } else { - const parsed = parseAndValidate(text); - if (!parsed.valid) return; - if (selectedSchema === SHOW_ALL_VALUE) { - newDataset = parsed.value as DatasetMetadataContent; - } else { - const result = applySingleSchemaEdit( - savedMetadata, - selectedSchema, - parsed.value as Record - ); - if (!result) return; - newDataset = result; - } - } - - await updateMetadata({ - workspace, - name: datasetName, - // `dataset: null` clears the field on the backend (matches the - // pydantic `DatasetMetadataContent | None` default). - data: { metadata: { dataset: newDataset as DatasetMetadataContent } }, - }); - await queryClient.invalidateQueries({ - queryKey: getFilesRetrieveFilesetQueryKey(workspace, datasetName), - }); - setEditsBySelection((prev) => { - if (!(selectedSchema in prev)) return prev; - const next = { ...prev }; - delete next[selectedSchema]; - return next; - }); - }, [ - validation.valid, - text, + const { + defKeys, + defaultDefKey, + hasInlineDefault, selectedSchema, - savedMetadata, - updateMetadata, + setSelectedSchema, + text, + userEdited, + handleEditorChange, + handleReset, + isInferring, + inferError, + supportedExistingFiles, + handleInferFromExisting, + isSaving, + validation, + sharedReferrerCount, + pendingShareConfirm, + setPendingShareConfirm, + handleSave, + handleConfirmShared, + handleSetDefault, + canInferFromExisting, + canSave, + canSetDefault, + isEmpty, + } = useDatasetSchemaEditor({ workspace, datasetName, - queryClient, - ]); - - const handleSave = useCallback(async () => { - if (sharedReferrerCount > 1) { - setPendingShareConfirm(true); - return; - } - await performSave(); - }, [sharedReferrerCount, performSave]); - - const handleConfirmShared = useCallback(async () => { - await performSave(); - setPendingShareConfirm(false); - }, [performSave]); - - const handleSetDefault = useCallback(async () => { - // Only valid for a schema_defs key that isn't already the default. - if ( - selectedSchema === SHOW_ALL_VALUE || - selectedSchema === DEFAULT_SCHEMA_VALUE || - !savedMetadata?.schema_defs?.[selectedSchema] - ) { - return; - } - const newMetadata: DatasetMetadataContent = { - ...savedMetadata, - schema: selectedSchema, - schema_defs: { ...(savedMetadata.schema_defs ?? {}) }, - schemas_by_path: { ...(savedMetadata.schemas_by_path ?? {}) }, - }; - await updateMetadata({ - workspace, - name: datasetName, - data: { metadata: { dataset: newMetadata } }, - }); - await queryClient.invalidateQueries({ - queryKey: getFilesRetrieveFilesetQueryKey(workspace, datasetName), - }); - }, [selectedSchema, savedMetadata, updateMetadata, workspace, datasetName, queryClient]); - - const canInferFromExisting = supportedExistingFiles.length > 0 && !isInferring; - const canSave = userEdited && validation.valid && !isSaving; - // "Set Default" is meaningful only when a real schema_defs entry is - // selected AND it isn't already the default. - const canSetDefault = - selectedSchema !== SHOW_ALL_VALUE && - selectedSchema !== DEFAULT_SCHEMA_VALUE && - !!savedMetadata?.schema_defs?.[selectedSchema] && - selectedSchema !== defaultDefKey && - !isSaving; - const isEmpty = !savedMetadata && !userEdited; + fileset, + filesList, + selectedFilePath, + }); if (isEmpty) { return ( diff --git a/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/useDatasetSchemaEditor.ts b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/useDatasetSchemaEditor.ts new file mode 100644 index 0000000000..ba4c18ba23 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetDetailRoute/DatasetSchemaEditor/useDatasetSchemaEditor.ts @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getFirstRow } from '@nemo/common/src/utils/file'; +import { + buildDatasetMetadata, + canonicalJson, + inferJsonSchema, + isSchemaAssignableFile, + parseAndValidate, + type PerFileInferred, +} from '@nemo/common/src/utils/jsonSchema'; +import { + getFilesRetrieveFilesetQueryKey, + useFilesUpdateFilesetMetadata, +} from '@nemo/sdk/generated/platform/api'; +import type { + DatasetMetadataContent, + FilesetFileOutput, + FilesetOutput, +} from '@nemo/sdk/generated/platform/schema'; +import { useDownloadFileHead } from '@studio/components/filesets/hooks/useDownloadFileHead'; +import { INFER_FROM_EXISTING_MAX_FILES } from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/constants'; +import { + applySingleSchemaEdit, + deriveSelectionText, + detectFormatFromPath, + resolveSchemaForFile, +} from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/helpers'; +import { + DEFAULT_SCHEMA_VALUE, + SHOW_ALL_VALUE, +} from '@studio/routes/FilesetDetailRoute/DatasetSchemaEditor/SchemaSelectControl'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export interface UseDatasetSchemaEditorParams { + workspace: string; + datasetName: string; + fileset: FilesetOutput; + filesList: FilesetFileOutput[] | undefined; + selectedFilePath?: string; +} + +export function useDatasetSchemaEditor({ + workspace, + datasetName, + fileset, + filesList, + selectedFilePath, +}: UseDatasetSchemaEditorParams) { + const savedMetadata: DatasetMetadataContent | undefined = fileset.metadata?.dataset; + + const defKeys = useMemo( + () => Object.keys(savedMetadata?.schema_defs ?? {}).sort(), + [savedMetadata] + ); + // Two flavors of root schema: inline object (shows separately as "Default") + // vs string ref to a schema_defs key (that key shows with a "(default)" + // marker, no separate entry). + const rootSchema = savedMetadata?.schema; + const defaultDefKey = + typeof rootSchema === 'string' && defKeys.includes(rootSchema) ? rootSchema : undefined; + const hasInlineDefault = + rootSchema !== undefined && rootSchema !== null && typeof rootSchema !== 'string'; + + const pickInitialSelection = useCallback((): string => { + if (hasInlineDefault) return DEFAULT_SCHEMA_VALUE; + if (defaultDefKey) return defaultDefKey; + return defKeys[0] ?? SHOW_ALL_VALUE; + }, [hasInlineDefault, defaultDefKey, defKeys]); + + // Lazy initializer so mount-with-file lands at the mapped schema directly + // (no extra render). Subsequent file-path transitions are handled by the + // useEffect below. + const [selectedSchema, setSelectedSchema] = useState(() => { + if (selectedFilePath) { + const mapped = savedMetadata?.schemas_by_path?.[selectedFilePath]; + if (typeof mapped === 'string' && defKeys.includes(mapped)) return mapped; + } + if (hasInlineDefault) return DEFAULT_SCHEMA_VALUE; + if (defaultDefKey) return defaultDefKey; + return defKeys[0] ?? SHOW_ALL_VALUE; + }); + + // When savedMetadata changes, drop the selected value if it no longer + // corresponds to a real schema. Keep it stable otherwise. + useEffect(() => { + if ( + selectedSchema === SHOW_ALL_VALUE || + (selectedSchema === DEFAULT_SCHEMA_VALUE && hasInlineDefault) || + defKeys.includes(selectedSchema) + ) { + return; + } + setSelectedSchema(pickInitialSelection()); + }, [selectedSchema, hasInlineDefault, defKeys, pickInitialSelection]); + + // Handle file-path TRANSITIONS only (initial mount is handled by the lazy + // useState initializer above). File preview opened: jump to file's mapped + // schema. File preview closed: jump to Show All. + const prevFilePathRef = useRef(selectedFilePath); + useEffect(() => { + const prev = prevFilePathRef.current; + const cur = selectedFilePath; + if (prev === cur) return; + prevFilePathRef.current = cur; + if (!cur) { + setSelectedSchema(SHOW_ALL_VALUE); + return; + } + const mapped = savedMetadata?.schemas_by_path?.[cur]; + if (typeof mapped === 'string' && defKeys.includes(mapped)) { + setSelectedSchema(mapped); + return; + } + if (mapped && typeof mapped === 'object') { + setSelectedSchema(SHOW_ALL_VALUE); + return; + } + if (defaultDefKey) { + setSelectedSchema(defaultDefKey); + return; + } + if (hasInlineDefault) { + setSelectedSchema(DEFAULT_SCHEMA_VALUE); + return; + } + setSelectedSchema(SHOW_ALL_VALUE); + }, [selectedFilePath, savedMetadata, defKeys, defaultDefKey, hasInlineDefault]); + + // Per-selection unsaved edits: switching selections preserves what the user + // had been typing. Saving or Resetting clears the entry for that selection. + const [editsBySelection, setEditsBySelection] = useState>({}); + + const derivedText = useMemo( + () => deriveSelectionText(savedMetadata, selectedSchema), + [savedMetadata, selectedSchema] + ); + const text = editsBySelection[selectedSchema] ?? derivedText; + const userEdited = selectedSchema in editsBySelection; + + const handleEditorChange = useCallback( + (next: string) => { + setEditsBySelection((prev) => ({ ...prev, [selectedSchema]: next })); + }, + [selectedSchema] + ); + + const handleReset = useCallback(() => { + setEditsBySelection((prev) => { + if (!(selectedSchema in prev)) return prev; + const next = { ...prev }; + delete next[selectedSchema]; + return next; + }); + setInferError(null); + }, [selectedSchema]); + + // Inference state lives here too (used by "Infer from existing files"). + const [isInferring, setIsInferring] = useState(false); + const [inferError, setInferError] = useState(null); + + const downloadFileHead = useDownloadFileHead(); + + const supportedExistingFiles = useMemo( + () => + (filesList ?? []) + .filter((f) => detectFormatFromPath(f.path) !== null) + // Sort root-level files first, then deeper paths; alphabetical within + // each depth. `buildDatasetMetadata` picks the first-encountered + // canonical as the default on ties, so this makes the default come + // from a top-level file (matching user expectation when both root + // and nested files exist). + .slice() + .sort((a, b) => { + const aDepth = a.path.split('/').length; + const bDepth = b.path.split('/').length; + if (aDepth !== bDepth) return aDepth - bDepth; + return a.path.localeCompare(b.path); + }), + [filesList] + ); + + const handleInferFromExisting = useCallback(async () => { + if (supportedExistingFiles.length === 0) return; + setIsInferring(true); + setInferError(null); + try { + const decoder = new TextDecoder('utf-8'); + const perFile: PerFileInferred[] = []; + for (const file of supportedExistingFiles.slice(0, INFER_FROM_EXISTING_MAX_FILES)) { + const format = detectFormatFromPath(file.path); + if (!format) continue; + const buffer = await downloadFileHead({ + workspace, + datasetName, + path: file.path, + bytes: file.size, + }); + if (!buffer) continue; + const textContent = decoder.decode(buffer); + const blob = new File([textContent], file.path); + try { + const row = await getFirstRow(blob, format); + if (row && typeof row === 'object') { + perFile.push({ path: file.path, schema: inferJsonSchema(row) }); + } + } catch { + // Skip unparseable files - the merged result still includes the rest. + } + } + if (perFile.length === 0) { + setInferError('Could not infer a schema from existing files.'); + return; + } + // "Infer from existing files" is a full re-inference: the resulting + // metadata.dataset REPLACES the prior contents (no merging with + // savedMetadata). Merging would mint new defs alongside outdated ones + // whenever the inference algorithm improves, leaving orphan schemas. + // Manual schemas added in Show All are also dropped here — the user + // should use Show All if they want to layer custom defs on top. + const inferred = buildDatasetMetadata(perFile); + setEditsBySelection((prev) => ({ + ...prev, + [SHOW_ALL_VALUE]: JSON.stringify(inferred, null, 2), + })); + setSelectedSchema(SHOW_ALL_VALUE); + } finally { + setIsInferring(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- workspace/datasetName captured inside downloadFileHead's own useCallback + }, [supportedExistingFiles, downloadFileHead]); + + const { mutateAsync: updateMetadata, isPending: isSaving } = useFilesUpdateFilesetMetadata(); + const queryClient = useQueryClient(); + + const validation = useMemo(() => { + if (!text.trim()) return { valid: true as const, errors: [] }; + const result = parseAndValidate(text); + return result.valid ? { valid: true as const, errors: [] } : result; + }, [text]); + + // Count of files affected by saving the current edit. + // Single-schema view: iterate filesList, count files whose resolved + // schema is the selected one (explicit ref OR implicit default). + // Show All view: diff parsed metadata against `savedMetadata` and count + // files whose RESOLVED schema would change. + const sharedReferrerCount = useMemo(() => { + // Only data files (`.json` / `.jsonl`) carry a schema in this UI. Non-data + // files inflated the "Schema is used by N files" count on external + // datasets where READMEs, images, and other artifacts dominate the tree. + const files = (filesList ?? []).filter((f) => isSchemaAssignableFile(f.path)); + if (selectedSchema === SHOW_ALL_VALUE) { + const parsed = parseAndValidate(text); + if (!parsed.valid) return 0; + const newMetadata = parsed.value as DatasetMetadataContent; + let affected = 0; + for (const f of files) { + const before = resolveSchemaForFile(savedMetadata, f.path); + const after = resolveSchemaForFile(newMetadata, f.path); + if (canonicalJson(before) !== canonicalJson(after)) affected += 1; + } + return affected; + } + const byPath = savedMetadata?.schemas_by_path ?? {}; + const isDefault = selectedSchema === DEFAULT_SCHEMA_VALUE || selectedSchema === defaultDefKey; + let count = 0; + for (const f of files) { + const mapped = byPath[f.path]; + if (typeof mapped === 'string') { + if (mapped === selectedSchema) count += 1; + continue; + } + if (mapped && typeof mapped === 'object') continue; + if (isDefault) count += 1; + } + return count; + }, [selectedSchema, savedMetadata, defaultDefKey, filesList, text]); + + const [pendingShareConfirm, setPendingShareConfirm] = useState(false); + + const performSave = useCallback(async () => { + if (!validation.valid) return; + + const trimmed = text.trim(); + const isClearing = trimmed === '' && selectedSchema === SHOW_ALL_VALUE; + + // Clearing only makes sense in the Show All view (wipes the whole + // metadata.dataset payload). For single-schema selections, empty text + // is ambiguous and isn't supported here - the user can switch to + // Show All to clear, or edit the field they want to remove. + if (trimmed === '' && !isClearing) return; + + let newDataset: DatasetMetadataContent | null; + if (isClearing) { + newDataset = null; + } else { + const parsed = parseAndValidate(text); + if (!parsed.valid) return; + if (selectedSchema === SHOW_ALL_VALUE) { + newDataset = parsed.value as DatasetMetadataContent; + } else { + const result = applySingleSchemaEdit( + savedMetadata, + selectedSchema, + parsed.value as Record + ); + if (!result) return; + newDataset = result; + } + } + + await updateMetadata({ + workspace, + name: datasetName, + // `dataset: null` clears the field on the backend (matches the + // pydantic `DatasetMetadataContent | None` default). + data: { metadata: { dataset: newDataset as DatasetMetadataContent } }, + }); + await queryClient.invalidateQueries({ + queryKey: getFilesRetrieveFilesetQueryKey(workspace, datasetName), + }); + setEditsBySelection((prev) => { + if (!(selectedSchema in prev)) return prev; + const next = { ...prev }; + delete next[selectedSchema]; + return next; + }); + }, [ + validation.valid, + text, + selectedSchema, + savedMetadata, + updateMetadata, + workspace, + datasetName, + queryClient, + ]); + + const handleSave = useCallback(async () => { + if (sharedReferrerCount > 1) { + setPendingShareConfirm(true); + return; + } + await performSave(); + }, [sharedReferrerCount, performSave]); + + const handleConfirmShared = useCallback(async () => { + await performSave(); + setPendingShareConfirm(false); + }, [performSave]); + + const handleSetDefault = useCallback(async () => { + // Only valid for a schema_defs key that isn't already the default. + if ( + selectedSchema === SHOW_ALL_VALUE || + selectedSchema === DEFAULT_SCHEMA_VALUE || + !savedMetadata?.schema_defs?.[selectedSchema] + ) { + return; + } + const newMetadata: DatasetMetadataContent = { + ...savedMetadata, + schema: selectedSchema, + schema_defs: { ...(savedMetadata.schema_defs ?? {}) }, + schemas_by_path: { ...(savedMetadata.schemas_by_path ?? {}) }, + }; + await updateMetadata({ + workspace, + name: datasetName, + data: { metadata: { dataset: newMetadata } }, + }); + await queryClient.invalidateQueries({ + queryKey: getFilesRetrieveFilesetQueryKey(workspace, datasetName), + }); + }, [selectedSchema, savedMetadata, updateMetadata, workspace, datasetName, queryClient]); + + const canInferFromExisting = supportedExistingFiles.length > 0 && !isInferring; + const canSave = userEdited && validation.valid && !isSaving; + // "Set Default" is meaningful only when a real schema_defs entry is + // selected AND it isn't already the default. + const canSetDefault = + selectedSchema !== SHOW_ALL_VALUE && + selectedSchema !== DEFAULT_SCHEMA_VALUE && + !!savedMetadata?.schema_defs?.[selectedSchema] && + selectedSchema !== defaultDefKey && + !isSaving; + const isEmpty = !savedMetadata && !userEdited; + + return { + defKeys, + defaultDefKey, + hasInlineDefault, + selectedSchema, + setSelectedSchema, + text, + userEdited, + handleEditorChange, + handleReset, + isInferring, + inferError, + supportedExistingFiles, + handleInferFromExisting, + isSaving, + validation, + sharedReferrerCount, + pendingShareConfirm, + setPendingShareConfirm, + handleSave, + handleConfirmShared, + handleSetDefault, + canInferFromExisting, + canSave, + canSetDefault, + isEmpty, + }; +} diff --git a/web/packages/studio/src/routes/FilesetNewRoute/CustomFilesetForm.tsx b/web/packages/studio/src/routes/FilesetNewRoute/CustomFilesetForm.tsx new file mode 100644 index 0000000000..ec3525a7d1 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetNewRoute/CustomFilesetForm.tsx @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { RadioCard } from '@nemo/common/src/components/RadioCard'; +import { type DatasetQualityReport } from '@nemo/common/src/utils/datasetQuality'; +import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; +import { + Flex, + Grid, + RadioGroupRoot, + Spinner, + Stack, + TabsContent, + TabsTrigger, + TabsRoot, + Text, + TabsList, + Upload, +} from '@nvidia/foundations-react-core'; +import { DatasetQualityReportView } from '@studio/routes/FilesetNewRoute/components/DatasetQualityReportView'; +import { PURPOSE_OPTIONS, DATASET_TYPE_CUSTOM } from '@studio/routes/FilesetNewRoute/constants'; +import { toFileList } from '@studio/routes/FilesetNewRoute/helpers'; +import { DatasetFormFields, DatasetType } from '@studio/routes/FilesetNewRoute/types'; +import { SecretSearchableSelect } from '@studio/routes/SecretsListRoute/SecretSearchableSelect'; +import { FC, FormEventHandler, RefObject } from 'react'; +import { Control, Controller, FieldErrors, UseFormSetValue } from 'react-hook-form'; + +interface CustomFilesetFormProps { + control: Control; + errors: FieldErrors; + setValue: UseFormSetValue; + isSubmitPending: boolean; + purpose: FilesetPurpose; + activeTab: DatasetType; + workspace: string; + storageTab: 'local' | 'external'; + setStorageTab: (value: 'local' | 'external') => void; + selectedSecretName: string | undefined; + secretKeyLabel: string; + isValidating: boolean; + qualityReports: DatasetQualityReport[]; + qualityReportRef: RefObject; + onFormSubmit: FormEventHandler; + onFilesChange: (files: File[]) => void; + onClearQualityReports: () => void; + onRequestNewSecret: () => void; +} + +export const CustomFilesetForm: FC = ({ + control, + errors, + setValue, + isSubmitPending, + purpose, + activeTab, + workspace, + storageTab, + setStorageTab, + selectedSecretName, + secretKeyLabel, + isValidating, + qualityReports, + qualityReportRef, + onFormSubmit, + onFilesChange, + onClearQualityReports, + onRequestNewSecret, +}) => { + return ( + <> + + Filesets organize files by purpose. Pick a purpose to control which metadata fields are + available, give the fileset a name, and choose where its files live. + +
    + + + + + + + + + Purpose + + Purpose determines which metadata fields are available and can't be changed after + the fileset is created. + + ( + field.onChange(value as FilesetPurpose)} + > + + {PURPOSE_OPTIONS.map((option) => ( + + ))} + + + )} + /> + + + + Source + + Upload files for local read/write access, or provide a URL and a workspace secret for + external read-only access. + + + { + const next = value as 'local' | 'external'; + setStorageTab(next); + if (next === 'local') { + setValue('url', '', { shouldValidate: false }); + setValue('secretKey', '', { shouldValidate: false }); + } else { + onClearQualityReports(); + } + }} + > + + Upload + External + + + + { + const list = Array.isArray(files) ? files : files ? [files] : undefined; + void onFilesChange(toFileList(list)); + }} + > + Supports JSONL, JSON, CSV, and Parquet files up to 50 MB. + + {purpose === FilesetPurpose.dataset && ( + + {isValidating && } + {qualityReports.map((report) => ( + + ))} + + )} + + + + + + + + + + + + + + ); +}; diff --git a/web/packages/studio/src/routes/FilesetNewRoute/SampleDatasetSection.tsx b/web/packages/studio/src/routes/FilesetNewRoute/SampleDatasetSection.tsx new file mode 100644 index 0000000000..f58fc020f9 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetNewRoute/SampleDatasetSection.tsx @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Badge, Block, Card, Flex, Grid, GridItem, Text } from '@nvidia/foundations-react-core'; +import { SAMPLE_DATASETS, SampleDataset } from '@studio/constants/sampleDatasets'; +import { FileCheck } from 'lucide-react'; +import { FC } from 'react'; + +interface SampleDatasetSectionProps { + selectedSampleDataset: SampleDataset; + onSelectSample: (dataset: SampleDataset) => void; +} + +export const SampleDatasetSection: FC = ({ + selectedSampleDataset, + onSelectSample, +}) => { + return ( + <> + + + Choose from the following pre-configured sample datasets. + + + + {SAMPLE_DATASETS.map((dataset) => ( + + onSelectSample(dataset)} + className="cursor-pointer shadow-none!" + slotHeader={ + + + Sample Dataset + + } + > + + {dataset.name} + {dataset.description} + + + + ))} + + + ); +}; diff --git a/web/packages/studio/src/routes/FilesetNewRoute/constants.ts b/web/packages/studio/src/routes/FilesetNewRoute/constants.ts new file mode 100644 index 0000000000..b67661abcf --- /dev/null +++ b/web/packages/studio/src/routes/FilesetNewRoute/constants.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; + +export const DATASET_NAME_REQUIRED_MESSAGE = 'Name is required.'; + +export const DATASET_NAME_PATTERN_MESSAGE = + 'Name must start with a lowercase letter, be 2–63 characters, and contain only lowercase letters, digits, hyphens, dots, underscores, plus, and @ (no consecutive hyphens, cannot end with a hyphen).'; + +/** Per-purpose copy shown in the purpose selector. Kept adjacent to the enum so each value has user-facing explanation. */ +export const PURPOSE_OPTIONS: { + value: FilesetPurpose; + label: string; + description: string; +}[] = [ + { + value: FilesetPurpose.generic, + label: 'Generic', + description: + "Default. Use for files that don't fit the Dataset or Model categories. Doesn't add purpose-specific metadata fields.", + }, + { + value: FilesetPurpose.dataset, + label: 'Dataset', + description: + 'For training and evaluation data. Enables dataset-specific metadata, including schema information.', + }, + { + value: FilesetPurpose.model, + label: 'Model', + description: + 'For model weights and checkpoints. Enables model-specific metadata, including tool-calling and model configuration fields.', + }, +]; + +export const DATASET_TYPE_CUSTOM = 'custom'; +export const DATASET_TYPE_SAMPLE = 'sample'; diff --git a/web/packages/studio/src/routes/FilesetNewRoute/helpers.ts b/web/packages/studio/src/routes/FilesetNewRoute/helpers.ts new file mode 100644 index 0000000000..751bccfa58 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetNewRoute/helpers.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getErrorMessage as getApiErrorMessage } from '@studio/api/common/utils'; +import { isHuggingFaceUrl, isNgcUrl } from '@studio/util/storageConfigFromUrl'; + +export function getSampleDatasetName(workspace: string, sampleId: string): string { + const truncatedProjectName = workspace.split('-')[1] || workspace; + return `${sampleId}-${truncatedProjectName}`; +} + +/** + * User-facing error for external storage create failure, with a stable prefix per storage type + * so the toast never shows raw [object Object] from API detail. + */ +export function getExternalStorageCreateErrorMessage(err: unknown, externalUrl: string): string { + let prefix: string; + try { + const parsed = new URL(externalUrl); + if (isNgcUrl(parsed)) { + prefix = 'Failed to create fileset from NGC. '; + } else if (isHuggingFaceUrl(parsed)) { + prefix = 'Failed to create fileset from Hugging Face. '; + } else { + prefix = 'Failed to create fileset from external storage. '; + } + } catch { + prefix = 'Failed to create fileset from external storage. '; + } + const detail = + err && typeof err === 'object' + ? getApiErrorMessage(err as Error, 'Please check your URL and credentials.') + : 'Please check your URL and credentials.'; + return prefix + detail; +} + +/** Normalize form files (may be File[] or KUI Upload's FileUploadItem[]) to File[]. */ +export function toFileList(value: unknown): File[] { + if (!value) return []; + const arr = Array.isArray(value) ? value : [value]; + return arr.flatMap((item) => + item instanceof File + ? [item] + : (item as { file?: File }).file + ? [(item as { file: File }).file] + : [] + ); +} diff --git a/web/packages/studio/src/routes/FilesetNewRoute/index.tsx b/web/packages/studio/src/routes/FilesetNewRoute/index.tsx index 7929c8826e..3f83686d41 100644 --- a/web/packages/studio/src/routes/FilesetNewRoute/index.tsx +++ b/web/packages/studio/src/routes/FilesetNewRoute/index.tsx @@ -2,177 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 import { zodResolver } from '@hookform/resolvers/zod'; -import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; -import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; -import { RadioCard } from '@nemo/common/src/components/RadioCard'; -import { getEntityReference } from '@nemo/common/src/namedEntity'; -import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { checkDatasetQuality, type DatasetQualityReport, } from '@nemo/common/src/utils/datasetQuality'; -import { FILESET_NAME_MAX_LENGTH, FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName'; -import { - filesUploadFile, - getFilesListFilesetFilesQueryKey, - getFilesListFilesetsQueryKey, - getFilesRetrieveFilesetQueryKey, - useFilesCreateFileset, -} from '@nemo/sdk/generated/platform/api'; -import { - FilesetOutput, - FilesetPurpose, - CreateFilesetRequest, -} from '@nemo/sdk/generated/platform/schema'; -import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files'; -import { - Badge, - Block, - Button, - Card, - Flex, - Grid, - GridItem, - RadioGroupRoot, - SegmentedControl, - SidePanel, - Spinner, - Stack, - TabsContent, - TabsTrigger, - TabsRoot, - Text, - TabsList, - Upload, -} from '@nvidia/foundations-react-core'; -import { getErrorMessage as getApiErrorMessage } from '@studio/api/common/utils'; +import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; +import { Button, SegmentedControl, SidePanel, Stack } from '@nvidia/foundations-react-core'; import { useSampleDatasetFiles } from '@studio/api/datasets/useSampleDatasetFiles'; -import { FILESET_DETAILS_ENABLED } from '@studio/constants/environment'; import { SAMPLE_DATASETS, SampleDataset } from '@studio/constants/sampleDatasets'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { DatasetQualityReportView } from '@studio/routes/FilesetNewRoute/components/DatasetQualityReportView'; -import { CreateSecretModal } from '@studio/routes/SecretsListRoute/CreateSecretModal'; -import { SecretSearchableSelect } from '@studio/routes/SecretsListRoute/SecretSearchableSelect'; +import { DATASET_TYPE_CUSTOM, DATASET_TYPE_SAMPLE } from '@studio/routes/FilesetNewRoute/constants'; +import { CustomFilesetForm } from '@studio/routes/FilesetNewRoute/CustomFilesetForm'; +import { getSampleDatasetName } from '@studio/routes/FilesetNewRoute/helpers'; +import { SampleDatasetSection } from '@studio/routes/FilesetNewRoute/SampleDatasetSection'; import { - getFilesetDetailRoute, - getFilesetDetailsRoute, - getWorkspaceFilesetsRoute, -} from '@studio/routes/utils'; + DatasetCreateFilesetFormSchema, + DatasetFormFields, + DatasetType, +} from '@studio/routes/FilesetNewRoute/types'; +import { useCreateFileset } from '@studio/routes/FilesetNewRoute/useCreateFileset'; +import { CreateSecretModal } from '@studio/routes/SecretsListRoute/CreateSecretModal'; +import { getWorkspaceFilesetsRoute } from '@studio/routes/utils'; import { handleFormErrorsGeneric } from '@studio/util/forms/error'; -import { - isHuggingFaceUrl, - isNgcUrl, - storageConfigFromUrl, -} from '@studio/util/storageConfigFromUrl'; -import { QueryObserverResult, useQueryClient } from '@tanstack/react-query'; -import { FileCheck } from 'lucide-react'; +import { isHuggingFaceUrl, isNgcUrl } from '@studio/util/storageConfigFromUrl'; +import { QueryObserverResult } from '@tanstack/react-query'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import { z } from 'zod'; - -const DATASET_NAME_REQUIRED_MESSAGE = 'Name is required.'; - -const DATASET_NAME_PATTERN_MESSAGE = - 'Name must start with a lowercase letter, be 2–63 characters, and contain only lowercase letters, digits, hyphens, dots, underscores, plus, and @ (no consecutive hyphens, cannot end with a hyphen).'; - -/** Per-purpose copy shown in the purpose selector. Kept adjacent to the enum so each value has user-facing explanation. */ -const PURPOSE_OPTIONS: { - value: FilesetPurpose; - label: string; - description: string; -}[] = [ - { - value: FilesetPurpose.generic, - label: 'Generic', - description: - "Default. Use for files that don't fit the Dataset or Model categories. Doesn't add purpose-specific metadata fields.", - }, - { - value: FilesetPurpose.dataset, - label: 'Dataset', - description: - 'For training and evaluation data. Enables dataset-specific metadata, including schema information.', - }, - { - value: FilesetPurpose.model, - label: 'Model', - description: - 'For model weights and checkpoints. Enables model-specific metadata, including tool-calling and model configuration fields.', - }, -]; - -/** - * Override the SDK-generated name validation. The generated zod uses the Files - * service DTO's loose pattern (`^[\w\-.]+$`, max 255); the entity store - * downstream enforces a stricter RFC-1035-ish pattern. Validate against the - * strict pattern here so the user sees a useful inline error instead of a 422. - */ -const DatasetCreateFilesetFormSchema = FilesCreateFilesetBody.extend({ - name: z - .string() - .trim() - .min(1, DATASET_NAME_REQUIRED_MESSAGE) - .max(FILESET_NAME_MAX_LENGTH) - .regex(FILESET_NAME_REGEXP, DATASET_NAME_PATTERN_MESSAGE), - purpose: z.nativeEnum(FilesetPurpose), -}); - -type CreateFilesetFormFields = z.infer; - -/** Form extends schema with optional files (Upload/sample) and external storage inputs (url/secretKey). */ -type DatasetFormFields = CreateFilesetFormFields & { - files?: File[]; - url?: string; - secretKey?: string; -}; - -const DATASET_TYPE_CUSTOM = 'custom'; -const DATASET_TYPE_SAMPLE = 'sample'; -type DatasetType = typeof DATASET_TYPE_CUSTOM | typeof DATASET_TYPE_SAMPLE; - -function getSampleDatasetName(workspace: string, sampleId: string): string { - const truncatedProjectName = workspace.split('-')[1] || workspace; - return `${sampleId}-${truncatedProjectName}`; -} - -/** - * User-facing error for external storage create failure, with a stable prefix per storage type - * so the toast never shows raw [object Object] from API detail. - */ -function getExternalStorageCreateErrorMessage(err: unknown, externalUrl: string): string { - let prefix: string; - try { - const parsed = new URL(externalUrl); - if (isNgcUrl(parsed)) { - prefix = 'Failed to create fileset from NGC. '; - } else if (isHuggingFaceUrl(parsed)) { - prefix = 'Failed to create fileset from Hugging Face. '; - } else { - prefix = 'Failed to create fileset from external storage. '; - } - } catch { - prefix = 'Failed to create fileset from external storage. '; - } - const detail = - err && typeof err === 'object' - ? getApiErrorMessage(err as Error, 'Please check your URL and credentials.') - : 'Please check your URL and credentials.'; - return prefix + detail; -} - -/** Normalize form files (may be File[] or KUI Upload's FileUploadItem[]) to File[]. */ -function toFileList(value: unknown): File[] { - if (!value) return []; - const arr = Array.isArray(value) ? value : [value]; - return arr.flatMap((item) => - item instanceof File - ? [item] - : (item as { file?: File }).file - ? [(item as { file: File }).file] - : [] - ); -} export const FilesetNewRoute: FC = () => { const workspace = useWorkspaceFromPath(); @@ -192,8 +48,6 @@ export const FilesetNewRoute: FC = () => { }, [qualityReports]); const navigate = useNavigate(); - const toast = useToast(); - const queryClient = useQueryClient(); const sampleFilesRef = useRef> | null>(null); const [storageTab, setStorageTab] = useState<'local' | 'external'>('local'); @@ -247,56 +101,6 @@ export const FilesetNewRoute: FC = () => { enabled: false, }); - const { mutateAsync: createFileset } = useFilesCreateFileset({ - mutation: { - onSuccess: (fileset) => { - queryClient.resetQueries({ queryKey: getFilesListFilesetsQueryKey(fileset.workspace) }); - queryClient.resetQueries({ - queryKey: getFilesRetrieveFilesetQueryKey(fileset.workspace, fileset.name), - }); - queryClient.resetQueries({ - queryKey: getFilesListFilesetFilesQueryKey(fileset.workspace, fileset.name), - }); - }, - }, - }); - - /** Step: create fileset. Present failures to user in caller. */ - const createFilesetStep = useCallback( - async (workspace: string, data: CreateFilesetRequest): Promise => { - const fileset: FilesetOutput = await createFileset({ - workspace, - data: { - name: data.name, - description: data.description ?? '', - project: data.project, - storage: data.storage ?? undefined, - purpose: data.purpose ?? FilesetPurpose.generic, - metadata: data.metadata, - custom_fields: data.custom_fields, - cache: data.cache, - }, - }); - return fileset; - }, - [createFileset] - ); - - /** Step: upload files to dataset. Present failures to user in caller. */ - const uploadFilesToDatasetStep = useCallback( - async (workspace: string, fileset: FilesetOutput, files: File[]): Promise => { - await Promise.all( - files.map(async (file) => { - const blob = new Blob([await file.arrayBuffer()], { - type: file.type || 'application/octet-stream', - }); - return filesUploadFile(workspace, fileset.name, file.name, blob); - }) - ); - }, - [] - ); - /** * Runs dataset quality checks on newly selected JSONL files and updates the report state. * Only runs when purpose is 'dataset'; clears reports for other purposes or non-JSONL files. @@ -367,135 +171,15 @@ export const FilesetNewRoute: FC = () => { const hasValidationErrors = purpose === FilesetPurpose.dataset && qualityReports.some((r) => r.hasErrors); - const onSubmit = useCallback( - async (data: DatasetFormFields) => { - const { success, error } = DatasetCreateFilesetFormSchema.safeParse(data); - if (!success) { - toast.error(error.message); - return; - } - - if (hasValidationErrors) { - toast.error('Fix dataset validation errors before creating this fileset.'); - return; - } - - setIsSubmitPending(true); - - // Step 1 (sample only): fetch sample files via lazy query - let files: File[]; - if (activeTab === DATASET_TYPE_SAMPLE) { - if (!sampleFilesRef.current) { - toast.error('No sample files could be loaded.'); - setIsSubmitPending(false); - return; - } - const result = (await sampleFilesRef.current) as QueryObserverResult; - if (result.isError || result.error) { - toast.error(getApiErrorMessage(result.error as Error, 'Failed to load sample files')); - setIsSubmitPending(false); - return; - } - if (!result.data?.length) { - toast.error('No sample files could be loaded.'); - setIsSubmitPending(false); - return; - } - files = result.data; - } else { - files = toFileList(getValues('files')); - } - - // Step 2: create fileset. Sample tab always produces a dataset-purpose fileset - // (preconfigured samples are training/eval data by definition); Custom tab - // uses whatever the user picked in the Purpose selector. - const effectivePurpose = - activeTab === DATASET_TYPE_SAMPLE ? FilesetPurpose.dataset : data.purpose; - let createPayload: CreateFilesetRequest = { - name: data.name, - description: data.description ?? '', - project: data.project, - storage: data.storage ?? undefined, - purpose: effectivePurpose, - metadata: data.metadata, - custom_fields: data.custom_fields, - cache: data.cache, - }; - const url = getValues('url'); - const secretRef = getValues('secretKey')?.trim() || undefined; - if (storageTab === 'external' && url?.trim()) { - try { - createPayload = { - ...createPayload, - storage: storageConfigFromUrl({ - url: url.trim(), - secretKey: secretRef, - }), - }; - } catch (e) { - toast.error( - e instanceof Error - ? e.message - : 'Invalid external storage URL or credential. For NGC, select a secret with your API key.' - ); - setIsSubmitPending(false); - return; - } - } - - let fileset: FilesetOutput; - try { - fileset = await createFilesetStep(workspace, createPayload); - } catch (err) { - const message = - storageTab === 'external' && url?.trim() - ? getExternalStorageCreateErrorMessage(err, url.trim()) - : getApiErrorMessage(err as Error, 'Failed to create fileset'); - toast.error(message); - setIsSubmitPending(false); - return; - } - - // Step 3: upload files to dataset - if (files.length) { - try { - await uploadFilesToDatasetStep(workspace, fileset, files); - } catch (err) { - toast.error(getApiErrorMessage(err as Error, 'Failed to upload files')); - setIsSubmitPending(false); - return; - } - } - - setIsSubmitPending(false); - if ( - FILESET_DETAILS_ENABLED && - (fileset.purpose === FilesetPurpose.dataset || fileset.purpose === FilesetPurpose.model) - ) { - navigate(getFilesetDetailRoute(workspace, fileset.name)); - return; - } - navigate( - getFilesetDetailsRoute( - workspace, - getEntityReference(fileset, { encode: true }), - undefined, - true - ) - ); - }, - [ - activeTab, - createFilesetStep, - getValues, - hasValidationErrors, - navigate, - storageTab, - toast, - uploadFilesToDatasetStep, - workspace, - ] - ); + const onSubmit = useCreateFileset({ + workspace, + activeTab, + storageTab, + hasValidationErrors, + getValues, + sampleFilesRef, + setIsSubmitPending, + }); const handleClose = useCallback(() => { navigate(getWorkspaceFilesetsRoute(workspace)); @@ -549,184 +233,35 @@ export const FilesetNewRoute: FC = () => { ]} /> {activeTab === DATASET_TYPE_CUSTOM && ( - <> - - Filesets organize files by purpose. Pick a purpose to control which metadata fields - are available, give the fileset a name, and choose where its files live. - -
    - - - - - - - - - Purpose - - Purpose determines which metadata fields are available and can't be - changed after the fileset is created. - - ( - field.onChange(value as FilesetPurpose)} - > - - {PURPOSE_OPTIONS.map((option) => ( - - ))} - - - )} - /> - - - - Source - - Upload files for local read/write access, or provide a URL and a workspace - secret for external read-only access. - - - { - const next = value as 'local' | 'external'; - setStorageTab(next); - if (next === 'local') { - setValue('url', '', { shouldValidate: false }); - setValue('secretKey', '', { shouldValidate: false }); - } else { - setQualityReports([]); - } - }} - > - - Upload - External - - - - { - const list = Array.isArray(files) - ? files - : files - ? [files] - : undefined; - void handleFilesChange(toFileList(list)); - }} - > - Supports JSONL, JSON, CSV, and Parquet files up to 50 MB. - - {purpose === FilesetPurpose.dataset && ( - - {isValidating && ( - - )} - {qualityReports.map((report) => ( - - ))} - - )} - - - - - - setCreateSecretModalOpen(true)} - triggerPlaceholder="" - formFieldProps={{ - slotLabel: secretKeyLabel, - slotInfo: - 'Select a secret that stores the credential for this URL, or choose New Secret to create one.', - slotError: errors.secretKey?.message, - }} - /> - - - - - - - + setQualityReports([])} + onRequestNewSecret={() => setCreateSecretModalOpen(true)} + /> )} {activeTab === DATASET_TYPE_SAMPLE && ( - <> - - - Choose from the following pre-configured sample datasets. - - - - {SAMPLE_DATASETS.map((dataset) => ( - - handleSelectSample(dataset)} - className="cursor-pointer shadow-none!" - slotHeader={ - - - Sample Dataset - - } - > - - {dataset.name} - {dataset.description} - - - - ))} - - + )} diff --git a/web/packages/studio/src/routes/FilesetNewRoute/types.ts b/web/packages/studio/src/routes/FilesetNewRoute/types.ts new file mode 100644 index 0000000000..b370bce3bb --- /dev/null +++ b/web/packages/studio/src/routes/FilesetNewRoute/types.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { FILESET_NAME_MAX_LENGTH, FILESET_NAME_REGEXP } from '@nemo/common/src/utils/filesetName'; +import { FilesetPurpose } from '@nemo/sdk/generated/platform/schema'; +import { FilesCreateFilesetBody } from '@nemo/sdk/generated/platform/zod/files'; +import { + DATASET_NAME_PATTERN_MESSAGE, + DATASET_NAME_REQUIRED_MESSAGE, + DATASET_TYPE_CUSTOM, + DATASET_TYPE_SAMPLE, +} from '@studio/routes/FilesetNewRoute/constants'; +import { z } from 'zod'; + +/** + * Override the SDK-generated name validation. The generated zod uses the Files + * service DTO's loose pattern (`^[\w\-.]+$`, max 255); the entity store + * downstream enforces a stricter RFC-1035-ish pattern. Validate against the + * strict pattern here so the user sees a useful inline error instead of a 422. + */ +export const DatasetCreateFilesetFormSchema = FilesCreateFilesetBody.extend({ + name: z + .string() + .trim() + .min(1, DATASET_NAME_REQUIRED_MESSAGE) + .max(FILESET_NAME_MAX_LENGTH) + .regex(FILESET_NAME_REGEXP, DATASET_NAME_PATTERN_MESSAGE), + purpose: z.nativeEnum(FilesetPurpose), +}); + +export type CreateFilesetFormFields = z.infer; + +/** Form extends schema with optional files (Upload/sample) and external storage inputs (url/secretKey). */ +export type DatasetFormFields = CreateFilesetFormFields & { + files?: File[]; + url?: string; + secretKey?: string; +}; + +export type DatasetType = typeof DATASET_TYPE_CUSTOM | typeof DATASET_TYPE_SAMPLE; diff --git a/web/packages/studio/src/routes/FilesetNewRoute/useCreateFileset.ts b/web/packages/studio/src/routes/FilesetNewRoute/useCreateFileset.ts new file mode 100644 index 0000000000..ed22136e12 --- /dev/null +++ b/web/packages/studio/src/routes/FilesetNewRoute/useCreateFileset.ts @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEntityReference } from '@nemo/common/src/namedEntity'; +import { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { + filesUploadFile, + getFilesListFilesetFilesQueryKey, + getFilesListFilesetsQueryKey, + getFilesRetrieveFilesetQueryKey, + useFilesCreateFileset, +} from '@nemo/sdk/generated/platform/api'; +import { + FilesetOutput, + FilesetPurpose, + CreateFilesetRequest, +} from '@nemo/sdk/generated/platform/schema'; +import { getErrorMessage as getApiErrorMessage } from '@studio/api/common/utils'; +import { FILESET_DETAILS_ENABLED } from '@studio/constants/environment'; +import { DATASET_TYPE_SAMPLE } from '@studio/routes/FilesetNewRoute/constants'; +import { + getExternalStorageCreateErrorMessage, + toFileList, +} from '@studio/routes/FilesetNewRoute/helpers'; +import { + DatasetCreateFilesetFormSchema, + DatasetFormFields, + DatasetType, +} from '@studio/routes/FilesetNewRoute/types'; +import { getFilesetDetailRoute, getFilesetDetailsRoute } from '@studio/routes/utils'; +import { storageConfigFromUrl } from '@studio/util/storageConfigFromUrl'; +import { QueryObserverResult, useQueryClient } from '@tanstack/react-query'; +import { MutableRefObject, useCallback } from 'react'; +import { UseFormGetValues } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +interface UseCreateFilesetParams { + workspace: string; + activeTab: DatasetType; + storageTab: 'local' | 'external'; + hasValidationErrors: boolean; + getValues: UseFormGetValues; + sampleFilesRef: MutableRefObject> | null>; + setIsSubmitPending: (value: boolean) => void; +} + +export function useCreateFileset({ + workspace, + activeTab, + storageTab, + hasValidationErrors, + getValues, + sampleFilesRef, + setIsSubmitPending, +}: UseCreateFilesetParams): (data: DatasetFormFields) => Promise { + const navigate = useNavigate(); + const toast = useToast(); + const queryClient = useQueryClient(); + + const { mutateAsync: createFileset } = useFilesCreateFileset({ + mutation: { + onSuccess: (fileset) => { + queryClient.resetQueries({ queryKey: getFilesListFilesetsQueryKey(fileset.workspace) }); + queryClient.resetQueries({ + queryKey: getFilesRetrieveFilesetQueryKey(fileset.workspace, fileset.name), + }); + queryClient.resetQueries({ + queryKey: getFilesListFilesetFilesQueryKey(fileset.workspace, fileset.name), + }); + }, + }, + }); + + /** Step: create fileset. Present failures to user in caller. */ + const createFilesetStep = useCallback( + async (workspace: string, data: CreateFilesetRequest): Promise => { + const fileset: FilesetOutput = await createFileset({ + workspace, + data: { + name: data.name, + description: data.description ?? '', + project: data.project, + storage: data.storage ?? undefined, + purpose: data.purpose ?? FilesetPurpose.generic, + metadata: data.metadata, + custom_fields: data.custom_fields, + cache: data.cache, + }, + }); + return fileset; + }, + [createFileset] + ); + + /** Step: upload files to dataset. Present failures to user in caller. */ + const uploadFilesToDatasetStep = useCallback( + async (workspace: string, fileset: FilesetOutput, files: File[]): Promise => { + await Promise.all( + files.map(async (file) => { + const blob = new Blob([await file.arrayBuffer()], { + type: file.type || 'application/octet-stream', + }); + return filesUploadFile(workspace, fileset.name, file.name, blob); + }) + ); + }, + [] + ); + + const onSubmit = useCallback( + async (data: DatasetFormFields) => { + const { success, error } = DatasetCreateFilesetFormSchema.safeParse(data); + if (!success) { + toast.error(error.message); + return; + } + + if (hasValidationErrors) { + toast.error('Fix dataset validation errors before creating this fileset.'); + return; + } + + setIsSubmitPending(true); + + // Step 1 (sample only): fetch sample files via lazy query + let files: File[]; + if (activeTab === DATASET_TYPE_SAMPLE) { + if (!sampleFilesRef.current) { + toast.error('No sample files could be loaded.'); + setIsSubmitPending(false); + return; + } + const result = (await sampleFilesRef.current) as QueryObserverResult; + if (result.isError || result.error) { + toast.error(getApiErrorMessage(result.error as Error, 'Failed to load sample files')); + setIsSubmitPending(false); + return; + } + if (!result.data?.length) { + toast.error('No sample files could be loaded.'); + setIsSubmitPending(false); + return; + } + files = result.data; + } else { + files = toFileList(getValues('files')); + } + + // Step 2: create fileset. Sample tab always produces a dataset-purpose fileset + // (preconfigured samples are training/eval data by definition); Custom tab + // uses whatever the user picked in the Purpose selector. + const effectivePurpose = + activeTab === DATASET_TYPE_SAMPLE ? FilesetPurpose.dataset : data.purpose; + let createPayload: CreateFilesetRequest = { + name: data.name, + description: data.description ?? '', + project: data.project, + storage: data.storage ?? undefined, + purpose: effectivePurpose, + metadata: data.metadata, + custom_fields: data.custom_fields, + cache: data.cache, + }; + const url = getValues('url'); + const secretRef = getValues('secretKey')?.trim() || undefined; + if (storageTab === 'external' && url?.trim()) { + try { + createPayload = { + ...createPayload, + storage: storageConfigFromUrl({ + url: url.trim(), + secretKey: secretRef, + }), + }; + } catch (e) { + toast.error( + e instanceof Error + ? e.message + : 'Invalid external storage URL or credential. For NGC, select a secret with your API key.' + ); + setIsSubmitPending(false); + return; + } + } + + let fileset: FilesetOutput; + try { + fileset = await createFilesetStep(workspace, createPayload); + } catch (err) { + const message = + storageTab === 'external' && url?.trim() + ? getExternalStorageCreateErrorMessage(err, url.trim()) + : getApiErrorMessage(err as Error, 'Failed to create fileset'); + toast.error(message); + setIsSubmitPending(false); + return; + } + + // Step 3: upload files to dataset + if (files.length) { + try { + await uploadFilesToDatasetStep(workspace, fileset, files); + } catch (err) { + toast.error(getApiErrorMessage(err as Error, 'Failed to upload files')); + setIsSubmitPending(false); + return; + } + } + + setIsSubmitPending(false); + if ( + FILESET_DETAILS_ENABLED && + (fileset.purpose === FilesetPurpose.dataset || fileset.purpose === FilesetPurpose.model) + ) { + navigate(getFilesetDetailRoute(workspace, fileset.name)); + return; + } + navigate( + getFilesetDetailsRoute( + workspace, + getEntityReference(fileset, { encode: true }), + undefined, + true + ) + ); + }, + [ + activeTab, + createFilesetStep, + getValues, + hasValidationErrors, + navigate, + sampleFilesRef, + setIsSubmitPending, + storageTab, + toast, + uploadFilesToDatasetStep, + workspace, + ] + ); + + return onSubmit; +} diff --git a/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/components/AgentGroupSection.tsx b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/components/AgentGroupSection.tsx new file mode 100644 index 0000000000..00ddb8e3b7 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/components/AgentGroupSection.tsx @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Collapsible, Stack, Text } from '@nvidia/foundations-react-core'; +import { SuggestionTile } from '@studio/routes/agents/AgentSuggestionsRoute/components/SuggestionTile'; +import type { + OptimizationSuggestion, + SuggestionTileProps, +} from '@studio/routes/agents/AgentSuggestionsRoute/types'; +import { suggestionIdentity } from '@studio/routes/agents/AgentSuggestionsRoute/utils'; +import { ChevronRight } from 'lucide-react'; +import { type FC } from 'react'; + +interface AgentGroupSectionProps { + group: { name: string; models: string[]; items: OptimizationSuggestion[] }; + getApplyState: (suggestion: OptimizationSuggestion) => { + isApplying: boolean; + isApplied: boolean; + error: string | null; + }; + getEvalState: (suggestion: OptimizationSuggestion) => SuggestionTileProps['evalState']; + onApply: SuggestionTileProps['onApply']; +} + +export const AgentGroupSection: FC = ({ + group, + getApplyState, + getEvalState, + onApply, +}) => ( + + + {group.name} + {group.models.length > 0 && ( + + {group.models.join(', ')} + + )} + + } + > + + {group.items.map((suggestion) => { + const applyState = getApplyState(suggestion); + return ( + + ); + })} + + +); diff --git a/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/components/StatsSection.tsx b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/components/StatsSection.tsx new file mode 100644 index 0000000000..41b44e02c3 --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/components/StatsSection.tsx @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Card, Flex, Grid, Stack, Text } from '@nvidia/foundations-react-core'; +import { SeverityStat } from '@studio/routes/agents/AgentSuggestionsRoute/components/SeverityStat'; +import { StatColumn } from '@studio/routes/agents/AgentSuggestionsRoute/components/StatColumn'; +import type { FC } from 'react'; + +interface SeverityCounts { + high: number; + low: number; +} + +interface StatsSectionProps { + stats: { agentCount: number; modelCount: number } & SeverityCounts; + previousStats: SeverityCounts; + hasPreviousRun: boolean; +} + +export const StatsSection: FC = ({ stats, previousStats, hasPreviousRun }) => ( + + + + + + + + + + + Suggestions + + + + + + + + {hasPreviousRun && ( + + + + Previous run + + + + + + + + )} + +); diff --git a/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/index.tsx b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/index.tsx index 621ac11a93..37a6bdd928 100644 --- a/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/index.tsx +++ b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/index.tsx @@ -6,16 +6,11 @@ import { FilterPanel } from '@nemo/common/src/components/DataView/FilterPanel'; import { Root as DataView } from '@nemo/common/src/components/DataView/internal'; import { StudioAppliedFilters } from '@nemo/common/src/components/DataView/StudioAppliedFilters'; import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage'; -import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; -import { useAgentsListAgents } from '@nemo/sdk/generated/agents/api'; import { Banner, Block, Button, - Card, - Collapsible, Flex, - Grid, PageHeader, Stack, Text, @@ -23,66 +18,23 @@ import { } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { Loading } from '@studio/components/Layouts/Loading'; -import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { loadSnapshot } from '@studio/routes/agents/AgentSuggestionsRoute/api'; +import { AgentGroupSection } from '@studio/routes/agents/AgentSuggestionsRoute/components/AgentGroupSection'; import { ApplyEvalConfigModal } from '@studio/routes/agents/AgentSuggestionsRoute/components/ApplyEvalConfigModal'; import { EmptyState, NoAgentsEmptyState, } from '@studio/routes/agents/AgentSuggestionsRoute/components/EmptyState'; import { SectionHeading } from '@studio/routes/agents/AgentSuggestionsRoute/components/SectionHeading'; -import { SeverityStat } from '@studio/routes/agents/AgentSuggestionsRoute/components/SeverityStat'; -import { StatColumn } from '@studio/routes/agents/AgentSuggestionsRoute/components/StatColumn'; +import { StatsSection } from '@studio/routes/agents/AgentSuggestionsRoute/components/StatsSection'; import { SuggestionTile } from '@studio/routes/agents/AgentSuggestionsRoute/components/SuggestionTile'; -import { - SCOPE_AGENT, - SCOPE_OPTIONS, - SCOPE_WORKSPACE, - SEVERITY_ORDER, - STALE_SUGGESTION_MS, - TYPE_OPTIONS, -} from '@studio/routes/agents/AgentSuggestionsRoute/constants'; -import type { - EvalConfigChoice, - OptimizationSuggestion, -} from '@studio/routes/agents/AgentSuggestionsRoute/types'; -import { useOptimizerSuggestions } from '@studio/routes/agents/AgentSuggestionsRoute/useOptimizerSuggestions'; -import { - capitalize, - countSeverities, - snapshotAgentNames, - snapshotModelNames, - suggestionIdentity, -} from '@studio/routes/agents/AgentSuggestionsRoute/utils'; -import { getAgentsListRoute } from '@studio/routes/utils'; -import { useQuery } from '@tanstack/react-query'; -import { ChevronRight, Filter, Search } from 'lucide-react'; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, - type FC, -} from 'react'; -import { useLocation } from 'react-router-dom'; - -type MultiState = Record; -interface SuggestionFilter { - agent?: MultiState; - severity?: MultiState; - type?: MultiState; - scope?: MultiState; -} +import { useAgentOptimizations } from '@studio/routes/agents/AgentSuggestionsRoute/useAgentOptimizations'; +import { suggestionIdentity } from '@studio/routes/agents/AgentSuggestionsRoute/utils'; +import { Filter, Search } from 'lucide-react'; +import { type FC } from 'react'; export const AgentOptimizationsRoute: FC = () => { - const workspace = useWorkspaceFromPath(); - const location = useLocation(); const { - suggestions, - previousSuggestions, + workspace, isSuggestionsLoading, suggestionsLoadError, refetchSuggestions, @@ -90,316 +42,32 @@ export const AgentOptimizationsRoute: FC = () => { step, error, run, - apply, getApplyState, getEvalState, - } = useOptimizerSuggestions(workspace); - - const snapshotQuery = useQuery({ - queryKey: ['agent-optimizer', 'snapshot', workspace] as const, - queryFn: ({ signal }) => loadSnapshot(workspace, signal), - enabled: !!workspace, - retry: false, - }); - const snapshot = snapshotQuery.data ?? null; - - // Workspace agent count drives whether auto-run fires and which empty - // state we show — running the optimizer against zero agents is pointless. - const agentsListQuery = useAgentsListAgents( - workspace, - { page: 1, page_size: 1 }, - { query: { enabled: !!workspace } } - ); - const totalAgentsInWorkspace = - agentsListQuery.data?.pagination?.total_results ?? agentsListQuery.data?.data?.length ?? 0; - const hasAgentsInWorkspace = totalAgentsInWorkspace > 0; - - const breadcrumbItems = useMemo( - () => [ - { slotLabel: 'Agents', href: getAgentsListRoute(workspace) }, - { slotLabel: 'Optimizations' }, - ], - [workspace] - ); - useBreadcrumbs({ items: breadcrumbItems }); - - const isSnapshotStale = useMemo(() => { - if (!snapshot?.agents) return false; - const timestamps = Object.values(snapshot.agents) - .map((a) => Date.parse(a.updatedAt)) - .filter((t) => !Number.isNaN(t)); - if (timestamps.length === 0) return false; - return Date.now() - Math.max(...timestamps) > STALE_SUGGESTION_MS; - }, [snapshot]); - - const didAutoRun = useRef(false); - // Reset the auto-run guard when the workspace changes so the next workspace - // visited gets its own initial run. - useEffect(() => { - didAutoRun.current = false; - }, [workspace]); - useEffect(() => { - if (didAutoRun.current) return; - if (isSuggestionsLoading || snapshotQuery.isLoading || agentsListQuery.isLoading) return; - if (!hasAgentsInWorkspace) return; - const fromNav = (location.state as { autoRun?: boolean } | null)?.autoRun; - const isEmptyFirstLoad = !suggestionsLoadError && suggestions.length === 0; - if (fromNav || isEmptyFirstLoad || isSnapshotStale) { - didAutoRun.current = true; - void run(); - } - }, [ - isSuggestionsLoading, - snapshotQuery.isLoading, - agentsListQuery.isLoading, + agentsListQuery, hasAgentsInWorkspace, - suggestionsLoadError, - suggestions.length, - isSnapshotStale, - location.state, - run, - ]); - - const isRunning = phase === 'running'; - - const [showFilters, setShowFilters] = useState(false); - const [agentSearch, setAgentSearch] = useState(''); - // Suggestion the user just clicked Apply on — drives the eval-config - // chooser modal. ``null`` keeps the modal closed. - const [pendingApply, setPendingApply] = useState(null); - const dataViewState = useStudioDataViewState({}); - - const handleApplyClicked = useCallback( - (suggestion: OptimizationSuggestion) => { - // Only ``model_optimization`` suggestions actually run an eval — for - // everything else (guardrails, data_safety, new_model_scan) there's - // nothing for the user to choose, so apply immediately. - if (suggestion.type === 'model_optimization' && suggestion.agent) { - setPendingApply(suggestion); - return; - } - void apply(suggestion); - }, - [apply] - ); - - const handleEvalConfigChosen = useCallback( - (choice: EvalConfigChoice) => { - const target = pendingApply; - setPendingApply(null); - if (!target) return; - void apply( - target, - choice.filesetOverride ? { evalConfigOverride: choice.filesetOverride } : undefined - ); - }, - [apply, pendingApply] - ); - - // Single pass over ``suggestions`` builds the four filter dropdown - // option sets — replaces four independent ``useMemo``s that each iterated - // the array. - const { agentOptions, scopeOptions, severityOptions, typeOptions } = useMemo(() => { - const agents = new Set(); - const scopes = new Set(); - const severities = new Set(); - const types = new Set(); - for (const s of suggestions) { - if (s.agent) agents.add(s.agent); - scopes.add(s.agent ? SCOPE_AGENT : SCOPE_WORKSPACE); - severities.add(s.severity ?? 'low'); - types.add(s.type); - } - return { - agentOptions: Array.from(agents) - .sort() - .map((value) => ({ value, label: value })), - scopeOptions: SCOPE_OPTIONS.filter((opt) => scopes.has(opt.value)), - severityOptions: Array.from(severities) - .sort((a, b) => (SEVERITY_ORDER[a] ?? 99) - (SEVERITY_ORDER[b] ?? 99)) - .map((value) => ({ value, label: capitalize(value) })), - typeOptions: TYPE_OPTIONS.filter((opt) => types.has(opt.value)), - }; - }, [suggestions]); - - const makeColumns = useCallback< - NonNullable>['makeColumns']> - >( - ({ accessor }) => [ - accessor((s: OptimizationSuggestion) => s.type, { - id: 'type', - header: 'Type', - enableSorting: false, - meta: { - filter: { - type: 'multi-select', - label: 'Type', - options: typeOptions, - }, - }, - }), - accessor((s: OptimizationSuggestion) => s.severity ?? 'low', { - id: 'severity', - header: 'Priority', - enableSorting: false, - meta: { - filter: { - type: 'multi-select', - label: 'Priority', - options: severityOptions, - }, - }, - }), - accessor((s: OptimizationSuggestion) => (s.agent ? SCOPE_AGENT : SCOPE_WORKSPACE), { - id: 'scope', - header: 'Scope', - enableSorting: false, - meta: { - filter: { - type: 'multi-select', - label: 'Scope', - options: scopeOptions, - }, - }, - }), - accessor((s: OptimizationSuggestion) => s.agent ?? '', { - id: 'agent', - header: 'Agent', - enableSorting: false, - meta: { - filter: { - type: 'multi-select', - label: 'Agent', - options: agentOptions, - }, - }, - }), - ], - [typeOptions, severityOptions, scopeOptions, agentOptions] - ); - - const filterState = dataViewState.apiFilter.filter; - const agentFilter = filterState?.agent; - const severityFilter = filterState?.severity; - const typeFilter = filterState?.type; - const scopeFilter = filterState?.scope; - - const filteredSuggestions = useMemo(() => { - const agentKeys = agentFilter ? Object.keys(agentFilter) : []; - const severityKeys = severityFilter ? Object.keys(severityFilter) : []; - const typeKeys = typeFilter ? Object.keys(typeFilter) : []; - const scopeKeys = scopeFilter ? Object.keys(scopeFilter) : []; - return suggestions.filter((s) => { - if (agentKeys.length > 0) { - if (!s.agent || !agentFilter?.[s.agent]) return false; - } - if (scopeKeys.length > 0) { - const scope = s.agent ? SCOPE_AGENT : SCOPE_WORKSPACE; - if (!scopeFilter?.[scope]) return false; - } - if (severityKeys.length > 0 && !severityFilter?.[s.severity ?? 'low']) return false; - if (typeKeys.length > 0 && !typeFilter?.[s.type]) return false; - return true; - }); - }, [suggestions, agentFilter, severityFilter, typeFilter, scopeFilter]); - - // Workspace-wide suggestions sit above per-agent groups in the layout. - const workspaceSuggestions = useMemo( - () => filteredSuggestions.filter((s) => !s.agent), - [filteredSuggestions] - ); - - // Group agent-scoped suggestions by agent name. Track all referenced models - // per agent so multi-model agents match search against any of their models; - // also expose the first model as a primary label for the accordion trigger. - const agentGroups = useMemo(() => { - const groups = new Map }>(); - for (const s of filteredSuggestions) { - if (!s.agent) continue; - let group = groups.get(s.agent); - if (!group) { - group = { items: [], models: new Set() }; - groups.set(s.agent, group); - } - group.items.push(s); - if (s.model) group.models.add(s.model); - } - return Array.from(groups.entries()) - .map(([name, value]) => ({ - name, - items: value.items, - models: Array.from(value.models), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [filteredSuggestions]); - - const visibleAgentGroups = useMemo(() => { - const q = agentSearch.trim().toLowerCase(); - if (!q) return agentGroups; - return agentGroups.filter( - (g) => g.name.toLowerCase().includes(q) || g.models.some((m) => m.toLowerCase().includes(q)) - ); - }, [agentGroups, agentSearch]); - - const stats = useMemo(() => { - const snapshotAgents = snapshotAgentNames(snapshot); - const snapshotModels = snapshotModelNames(snapshot); - const agentCount = snapshotAgents.length || agentGroups.length; - const modelCount = - snapshotModels.length || - new Set(suggestions.map((s) => s.model).filter((m): m is string => !!m)).size; - return { agentCount, modelCount, ...countSeverities(suggestions) }; - }, [snapshot, agentGroups.length, suggestions]); - - const previousStats = useMemo(() => countSeverities(previousSuggestions), [previousSuggestions]); - const hasPreviousRun = previousSuggestions.length > 0; - - const hasSuggestions = suggestions.length > 0; - const showStats = stats.agentCount > 0 && (hasSuggestions || snapshot !== null); - - const renderAgentGroup = useCallback( - (group: { name: string; models: string[]; items: OptimizationSuggestion[] }) => ( - - - {group.name} - {group.models.length > 0 && ( - - {group.models.join(', ')} - - )} - - } - > - - {group.items.map((suggestion) => { - const applyState = getApplyState(suggestion); - return ( - - ); - })} - - - ), - [getApplyState, getEvalState, handleApplyClicked] - ); + isRunning, + showFilters, + setShowFilters, + agentSearch, + setAgentSearch, + pendingApply, + setPendingApply, + dataViewState, + handleApplyClicked, + handleEvalConfigChosen, + makeColumns, + filteredSuggestions, + workspaceSuggestions, + agentGroups, + visibleAgentGroups, + stats, + previousStats, + hasPreviousRun, + hasSuggestions, + showStats, + suggestions, + } = useAgentOptimizations(); return ( @@ -420,38 +88,11 @@ export const AgentOptimizationsRoute: FC = () => { /> {showStats && ( - - - - - - - - - - - Suggestions - - - - - - - - {hasPreviousRun && ( - - - - Previous run - - - - - - - - )} - + )} {isRunning && ( @@ -572,7 +213,17 @@ export const AgentOptimizationsRoute: FC = () => { No agents match your search. ) : ( - {visibleAgentGroups.map(renderAgentGroup)} + + {visibleAgentGroups.map((group) => ( + + ))} + )} )} diff --git a/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/useAgentOptimizations.tsx b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/useAgentOptimizations.tsx new file mode 100644 index 0000000000..5b772597fe --- /dev/null +++ b/web/packages/studio/src/routes/agents/AgentSuggestionsRoute/useAgentOptimizations.tsx @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Root as DataView } from '@nemo/common/src/components/DataView/internal'; +import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { useAgentsListAgents } from '@nemo/sdk/generated/agents/api'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { loadSnapshot } from '@studio/routes/agents/AgentSuggestionsRoute/api'; +import { + SCOPE_AGENT, + SCOPE_OPTIONS, + SCOPE_WORKSPACE, + SEVERITY_ORDER, + STALE_SUGGESTION_MS, + TYPE_OPTIONS, +} from '@studio/routes/agents/AgentSuggestionsRoute/constants'; +import type { + EvalConfigChoice, + OptimizationSuggestion, +} from '@studio/routes/agents/AgentSuggestionsRoute/types'; +import { useOptimizerSuggestions } from '@studio/routes/agents/AgentSuggestionsRoute/useOptimizerSuggestions'; +import { + capitalize, + countSeverities, + snapshotAgentNames, + snapshotModelNames, +} from '@studio/routes/agents/AgentSuggestionsRoute/utils'; +import { getAgentsListRoute } from '@studio/routes/utils'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'; +import { useLocation } from 'react-router-dom'; + +type MultiState = Record; +interface SuggestionFilter { + agent?: MultiState; + severity?: MultiState; + type?: MultiState; + scope?: MultiState; +} + +export const useAgentOptimizations = () => { + const workspace = useWorkspaceFromPath(); + const location = useLocation(); + const { + suggestions, + previousSuggestions, + isSuggestionsLoading, + suggestionsLoadError, + refetchSuggestions, + phase, + step, + error, + run, + apply, + getApplyState, + getEvalState, + } = useOptimizerSuggestions(workspace); + + const snapshotQuery = useQuery({ + queryKey: ['agent-optimizer', 'snapshot', workspace] as const, + queryFn: ({ signal }) => loadSnapshot(workspace, signal), + enabled: !!workspace, + retry: false, + }); + const snapshot = snapshotQuery.data ?? null; + + // Workspace agent count drives whether auto-run fires and which empty + // state we show — running the optimizer against zero agents is pointless. + const agentsListQuery = useAgentsListAgents( + workspace, + { page: 1, page_size: 1 }, + { query: { enabled: !!workspace } } + ); + const totalAgentsInWorkspace = + agentsListQuery.data?.pagination?.total_results ?? agentsListQuery.data?.data?.length ?? 0; + const hasAgentsInWorkspace = totalAgentsInWorkspace > 0; + + const breadcrumbItems = useMemo( + () => [ + { slotLabel: 'Agents', href: getAgentsListRoute(workspace) }, + { slotLabel: 'Optimizations' }, + ], + [workspace] + ); + useBreadcrumbs({ items: breadcrumbItems }); + + const isSnapshotStale = useMemo(() => { + if (!snapshot?.agents) return false; + const timestamps = Object.values(snapshot.agents) + .map((a) => Date.parse(a.updatedAt)) + .filter((t) => !Number.isNaN(t)); + if (timestamps.length === 0) return false; + return Date.now() - Math.max(...timestamps) > STALE_SUGGESTION_MS; + }, [snapshot]); + + const didAutoRun = useRef(false); + // Reset the auto-run guard when the workspace changes so the next workspace + // visited gets its own initial run. + useEffect(() => { + didAutoRun.current = false; + }, [workspace]); + useEffect(() => { + if (didAutoRun.current) return; + if (isSuggestionsLoading || snapshotQuery.isLoading || agentsListQuery.isLoading) return; + if (!hasAgentsInWorkspace) return; + const fromNav = (location.state as { autoRun?: boolean } | null)?.autoRun; + const isEmptyFirstLoad = !suggestionsLoadError && suggestions.length === 0; + if (fromNav || isEmptyFirstLoad || isSnapshotStale) { + didAutoRun.current = true; + void run(); + } + }, [ + isSuggestionsLoading, + snapshotQuery.isLoading, + agentsListQuery.isLoading, + hasAgentsInWorkspace, + suggestionsLoadError, + suggestions.length, + isSnapshotStale, + location.state, + run, + ]); + + const isRunning = phase === 'running'; + + const [showFilters, setShowFilters] = useState(false); + const [agentSearch, setAgentSearch] = useState(''); + // Suggestion the user just clicked Apply on — drives the eval-config + // chooser modal. ``null`` keeps the modal closed. + const [pendingApply, setPendingApply] = useState(null); + const dataViewState = useStudioDataViewState({}); + + const handleApplyClicked = useCallback( + (suggestion: OptimizationSuggestion) => { + // Only ``model_optimization`` suggestions actually run an eval — for + // everything else (guardrails, data_safety, new_model_scan) there's + // nothing for the user to choose, so apply immediately. + if (suggestion.type === 'model_optimization' && suggestion.agent) { + setPendingApply(suggestion); + return; + } + void apply(suggestion); + }, + [apply] + ); + + const handleEvalConfigChosen = useCallback( + (choice: EvalConfigChoice) => { + const target = pendingApply; + setPendingApply(null); + if (!target) return; + void apply( + target, + choice.filesetOverride ? { evalConfigOverride: choice.filesetOverride } : undefined + ); + }, + [apply, pendingApply] + ); + + // Single pass over ``suggestions`` builds the four filter dropdown + // option sets — replaces four independent ``useMemo``s that each iterated + // the array. + const { agentOptions, scopeOptions, severityOptions, typeOptions } = useMemo(() => { + const agents = new Set(); + const scopes = new Set(); + const severities = new Set(); + const types = new Set(); + for (const s of suggestions) { + if (s.agent) agents.add(s.agent); + scopes.add(s.agent ? SCOPE_AGENT : SCOPE_WORKSPACE); + severities.add(s.severity ?? 'low'); + types.add(s.type); + } + return { + agentOptions: Array.from(agents) + .sort() + .map((value) => ({ value, label: value })), + scopeOptions: SCOPE_OPTIONS.filter((opt) => scopes.has(opt.value)), + severityOptions: Array.from(severities) + .sort((a, b) => (SEVERITY_ORDER[a] ?? 99) - (SEVERITY_ORDER[b] ?? 99)) + .map((value) => ({ value, label: capitalize(value) })), + typeOptions: TYPE_OPTIONS.filter((opt) => types.has(opt.value)), + }; + }, [suggestions]); + + const makeColumns = useCallback< + NonNullable>['makeColumns']> + >( + ({ accessor }) => [ + accessor((s: OptimizationSuggestion) => s.type, { + id: 'type', + header: 'Type', + enableSorting: false, + meta: { + filter: { + type: 'multi-select', + label: 'Type', + options: typeOptions, + }, + }, + }), + accessor((s: OptimizationSuggestion) => s.severity ?? 'low', { + id: 'severity', + header: 'Priority', + enableSorting: false, + meta: { + filter: { + type: 'multi-select', + label: 'Priority', + options: severityOptions, + }, + }, + }), + accessor((s: OptimizationSuggestion) => (s.agent ? SCOPE_AGENT : SCOPE_WORKSPACE), { + id: 'scope', + header: 'Scope', + enableSorting: false, + meta: { + filter: { + type: 'multi-select', + label: 'Scope', + options: scopeOptions, + }, + }, + }), + accessor((s: OptimizationSuggestion) => s.agent ?? '', { + id: 'agent', + header: 'Agent', + enableSorting: false, + meta: { + filter: { + type: 'multi-select', + label: 'Agent', + options: agentOptions, + }, + }, + }), + ], + [typeOptions, severityOptions, scopeOptions, agentOptions] + ); + + const filterState = dataViewState.apiFilter.filter; + const agentFilter = filterState?.agent; + const severityFilter = filterState?.severity; + const typeFilter = filterState?.type; + const scopeFilter = filterState?.scope; + + const filteredSuggestions = useMemo(() => { + const agentKeys = agentFilter ? Object.keys(agentFilter) : []; + const severityKeys = severityFilter ? Object.keys(severityFilter) : []; + const typeKeys = typeFilter ? Object.keys(typeFilter) : []; + const scopeKeys = scopeFilter ? Object.keys(scopeFilter) : []; + return suggestions.filter((s) => { + if (agentKeys.length > 0) { + if (!s.agent || !agentFilter?.[s.agent]) return false; + } + if (scopeKeys.length > 0) { + const scope = s.agent ? SCOPE_AGENT : SCOPE_WORKSPACE; + if (!scopeFilter?.[scope]) return false; + } + if (severityKeys.length > 0 && !severityFilter?.[s.severity ?? 'low']) return false; + if (typeKeys.length > 0 && !typeFilter?.[s.type]) return false; + return true; + }); + }, [suggestions, agentFilter, severityFilter, typeFilter, scopeFilter]); + + // Workspace-wide suggestions sit above per-agent groups in the layout. + const workspaceSuggestions = useMemo( + () => filteredSuggestions.filter((s) => !s.agent), + [filteredSuggestions] + ); + + // Group agent-scoped suggestions by agent name. Track all referenced models + // per agent so multi-model agents match search against any of their models; + // also expose the first model as a primary label for the accordion trigger. + const agentGroups = useMemo(() => { + const groups = new Map }>(); + for (const s of filteredSuggestions) { + if (!s.agent) continue; + let group = groups.get(s.agent); + if (!group) { + group = { items: [], models: new Set() }; + groups.set(s.agent, group); + } + group.items.push(s); + if (s.model) group.models.add(s.model); + } + return Array.from(groups.entries()) + .map(([name, value]) => ({ + name, + items: value.items, + models: Array.from(value.models), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [filteredSuggestions]); + + const visibleAgentGroups = useMemo(() => { + const q = agentSearch.trim().toLowerCase(); + if (!q) return agentGroups; + return agentGroups.filter( + (g) => g.name.toLowerCase().includes(q) || g.models.some((m) => m.toLowerCase().includes(q)) + ); + }, [agentGroups, agentSearch]); + + const stats = useMemo(() => { + const snapshotAgents = snapshotAgentNames(snapshot); + const snapshotModels = snapshotModelNames(snapshot); + const agentCount = snapshotAgents.length || agentGroups.length; + const modelCount = + snapshotModels.length || + new Set(suggestions.map((s) => s.model).filter((m): m is string => !!m)).size; + return { agentCount, modelCount, ...countSeverities(suggestions) }; + }, [snapshot, agentGroups.length, suggestions]); + + const previousStats = useMemo(() => countSeverities(previousSuggestions), [previousSuggestions]); + const hasPreviousRun = previousSuggestions.length > 0; + + const hasSuggestions = suggestions.length > 0; + const showStats = stats.agentCount > 0 && (hasSuggestions || snapshot !== null); + + return { + workspace, + isSuggestionsLoading, + suggestionsLoadError, + refetchSuggestions, + phase, + step, + error, + run, + apply, + getApplyState, + getEvalState, + agentsListQuery, + hasAgentsInWorkspace, + isRunning, + showFilters, + setShowFilters, + agentSearch, + setAgentSearch, + pendingApply, + setPendingApply, + dataViewState, + handleApplyClicked, + handleEvalConfigChosen, + makeColumns, + filteredSuggestions, + workspaceSuggestions, + agentGroups, + visibleAgentGroups, + stats, + previousStats, + hasPreviousRun, + hasSuggestions, + showStats, + suggestions, + }; +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx index d8bbfe2f70..6d357d6c0b 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.tsx @@ -1,589 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - Anchor, - Banner, - Button, - Card, - Flex, - SegmentedControl, - Skeleton, - Stack, - Text, - Tooltip, -} from '@nvidia/foundations-react-core'; -import { Empty } from '@studio/components/Empty'; -import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; -import { - CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, - CLAUDE_CODE_SKILLS_QUERY_KEY, - listClaudeCodeHistorySessions, - listClaudeCodeSkills, -} from '@studio/routes/agents/ClaudeCodeChatRoute/api'; -import { cleanClaudeCodeArtifactText } from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; -import { CLAUDE_CODE_STUDIO_LINK_CLASS } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLink'; -import { getStudioInternalLinkTarget } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLinkTarget'; -import type { - ClaudeCodeChatArtifacts, - ClaudeCodeChatFileArtifact, - ClaudeCodeChatLinkArtifact, - ClaudeCodeChatSelectionArtifact, - ClaudeCodeHistorySession, - ClaudeCodeSkill, -} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; -import { getSkillDisplayName } from '@studio/routes/DashboardLandingRoute/skillDisplayName'; +import { Button, Flex, SegmentedControl, Tooltip } from '@nvidia/foundations-react-core'; +import { ClaudeCodeArtifactsPane } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/ClaudeCodeArtifactsPane'; +import { PANEL_TAB_ITEMS } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/constants'; +import { isClaudeCodePanelTab } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; +import { HistoryPanelContents } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents'; +import { SkillsPanelContents } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillsPanelContents'; +import type { ClaudeCodeHistoryPanelProps } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/types'; import { useLocalStorage } from '@studio/util/hooks/useLocalStorage'; import { CLAUDE_CODE_HISTORY_OPEN_KEY, CLAUDE_CODE_PANEL_TAB_KEY } from '@studio/util/localStorage'; -import { useQuery } from '@tanstack/react-query'; -import cn from 'classnames'; -import { - ArrowRight, - Bot, - BookOpen, - Boxes, - Cpu, - FileCode2, - History, - Link2, - MessageSquare, - MessageSquarePlus, - PanelRightClose, - PanelRightOpen, - RefreshCw, - Sparkles, - Wrench, -} from 'lucide-react'; -import { type FC, type ReactNode } from 'react'; -import { Link } from 'react-router-dom'; - -interface ClaudeCodeHistoryPanelProps { - activeSessionId?: string; - artifacts?: ClaudeCodeChatArtifacts; - onNewChat: () => void; - onSelectSession: (sessionId: string) => void; -} - -type ClaudeCodePanelTab = 'history' | 'skills'; - -const isClaudeCodePanelTab = (value: string): value is ClaudeCodePanelTab => - value === 'history' || value === 'skills'; - -const PANEL_TAB_ITEMS = [ - { - value: 'history', - children: ( - - - History - - ), - }, - { - value: 'skills', - children: ( - - - Skills - - ), - }, -]; - -const getCompactRelativeTime = (mtime: number): string => { - const elapsedMs = Math.max(Date.now() - mtime * 1000, 0); - const minuteMs = 60 * 1000; - const hourMs = 60 * minuteMs; - const dayMs = 24 * hourMs; - - if (elapsedMs < minuteMs) return 'now'; - if (elapsedMs < hourMs) return `${Math.floor(elapsedMs / minuteMs)}m`; - if (elapsedMs < dayMs) return `${Math.floor(elapsedMs / hourMs)}h`; - - const days = Math.floor(elapsedMs / dayMs); - if (days < 31) return `${days}d`; - - return new Date(mtime * 1000).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }); -}; - -const HistoryPanelSkeleton = () => ( - - - - - -); - -const SkillsPanelSkeleton = () => ( - - - - - -); - -const ToolCallSummary = ({ toolCalls }: { toolCalls: string[] }) => { - if (!toolCalls.length) return null; - - return ( - - - - {toolCalls.join(', ')} - - - ); -}; - -const ArtifactChip = ({ children }: { children: ReactNode }) => { - const content = typeof children === 'string' ? cleanClaudeCodeArtifactText(children) : children; - - return ( - - - {content} - - - ); -}; - -const ArtifactRow = ({ - icon, - label, - value, -}: { - icon: ReactNode; - label: string; - value?: string; -}) => { - if (!value) return null; - - return ( - - - {icon} - - - - {label}: - - {value} - - - ); -}; - -const ArtifactSection = ({ - background, - children, - icon, - title, -}: { - background?: boolean; - children: ReactNode; - icon: ReactNode; - title: string; -}) => ( - - - {icon} - - {title} - - - {children} - -); - -const getFileLabel = (file: ClaudeCodeChatFileArtifact): string => { - const parts = file.path.split('/'); - return parts[parts.length - 1] || file.path; -}; - -const FileArtifacts = ({ files }: { files: ClaudeCodeChatFileArtifact[] }) => { - if (!files.length) return null; - - return ( - } title="Files"> - - {files.slice(0, 6).map((file) => ( - - - {file.action} - - - {getFileLabel(file)} - - - ))} - - - ); -}; - -const LinkArtifacts = ({ links }: { links: ClaudeCodeChatLinkArtifact[] }) => { - const workspace = useWorkspaceFromPathIfExists(); - - if (!links.length) return null; - - return ( - } title="Studio links"> - - {links.slice(0, 6).map((link) => { - const target = getStudioInternalLinkTarget( - link.href ?? link.destination, - window.location.origin, - workspace - ); - const label = cleanClaudeCodeArtifactText(link.label); - const key = `${link.label}-${link.destination ?? link.href ?? 'link'}`; - - return target ? ( - - - {label} - - ) : ( - {label} - ); - })} - - - ); -}; - -const SelectionArtifacts = ({ selections }: { selections: ClaudeCodeChatSelectionArtifact[] }) => { - if (!selections.length) return null; - - return ( - } title="Selections"> - - {selections.slice(0, 6).map((selection) => ( - } - label={selection.label} - value={selection.value} - /> - ))} - - - ); -}; - -const ToolArtifacts = ({ tools }: { tools: string[] }) => { - if (!tools.length) return null; - - return ( - } title="Tools"> - - {tools.slice(0, 8).map((tool) => ( - {tool} - ))} - - - ); -}; - -const getSelectedArtifactModel = (artifacts: ClaudeCodeChatArtifacts): string | undefined => - artifacts.model_source === 'selection' || artifacts.model_source === 'spec' - ? artifacts.model - : undefined; - -const hasArtifacts = (artifacts?: ClaudeCodeChatArtifacts): artifacts is ClaudeCodeChatArtifacts => - !!artifacts && - !!( - artifacts.agent || - getSelectedArtifactModel(artifacts) || - artifacts.workspace || - artifacts.selections.length || - artifacts.files.length || - artifacts.links.length || - artifacts.tools.length - ); - -const ClaudeCodeArtifactsPane = ({ - artifacts, - collapseLabel, - onCollapse, -}: { - artifacts?: ClaudeCodeChatArtifacts; - collapseLabel: string; - onCollapse: () => void; -}) => { - const selectedModel = artifacts ? getSelectedArtifactModel(artifacts) : undefined; - - return ( -
    - - - - - Chat artifacts - - - - - - - {hasArtifacts(artifacts) ? ( - - - } label="Agent" value={artifacts.agent} /> - } label="Model" value={selectedModel} /> - } label="Workspace" value={artifacts.workspace} /> - - - - - - - ) : ( - - - - )} -
    - ); -}; - -const HistorySessionButton = ({ - active, - onSelect, - session, -}: { - active: boolean; - onSelect: () => void; - session: ClaudeCodeHistorySession; -}) => ( - -); - -const SkillCard = ({ skill }: { skill: ClaudeCodeSkill }) => ( - - - - - - - - - {getSkillDisplayName(skill)} - - - {skill.claude_name} - - - - - {skill.description || 'No description'} - - - -); - -const HistoryPanelContents = ({ - activeSessionId, - onNewChat, - onSelectSession, -}: ClaudeCodeHistoryPanelProps) => { - const { - data: sessions = [], - error, - isLoading, - refetch, - } = useQuery({ - queryKey: CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, - queryFn: listClaudeCodeHistorySessions, - refetchOnMount: 'always', - }); - - return ( - <> -
    - - - - - - -
    - {error && ( -
    - - Could not load Claude history. - -
    - )} - {isLoading ? ( - - ) : sessions.length ? ( -
    - {sessions.map((session) => ( - onSelectSession(session.session_id)} - /> - ))} -
    - ) : !error ? ( - - - - ) : null} - - ); -}; - -const SkillsPanelContents = () => { - const { - data: skills = [], - error, - isLoading, - refetch, - } = useQuery({ - queryKey: CLAUDE_CODE_SKILLS_QUERY_KEY, - queryFn: listClaudeCodeSkills, - }); - - return ( - <> - - - {skills.length} skills - - - - - - {error && ( -
    - - Could not load Claude skills. - -
    - )} - {isLoading ? ( - - ) : skills.length ? ( -
    - - {skills.map((skill) => ( - - ))} - -
    - ) : !error ? ( - - - - ) : null} - - ); -}; +import { PanelRightOpen } from 'lucide-react'; +import { type FC } from 'react'; export const ClaudeCodeHistoryPanel: FC = (props) => { const [historyOpen, setHistoryOpen] = useLocalStorage(CLAUDE_CODE_HISTORY_OPEN_KEY, 'true'); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx index ddbbbd1634..50370bcaad 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart.tsx @@ -2,661 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import type { ToolCallMessagePartComponent } from '@assistant-ui/react'; -import { Text } from '@nvidia/foundations-react-core'; import { JobProgressToolCall } from '@studio/routes/agents/ClaudeCodeChatRoute/JobProgressToolCall'; +import { CollapsedThinkingToolCall } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/CollapsedThinkingToolCall'; +import { TOOL_LABELS } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/constants'; +import { FileChangeToolCallCard } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard'; +import { + formatSubtleToolMessage, + getFileChangeSummary, + getStringArg, + getSubtleToolDetail, + getSubtleToolGroupActions, + getSubtleToolIcon, + getSubtleToolMessage, + getToolSummary, +} from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/helpers'; +import { SubtleToolCallRow } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow'; import { CLAUDE_CODE_COLLAPSED_THINKING_TOOL_NAME, CLAUDE_CODE_SUBTLE_TOOL_GROUP_NAME, isClaudeCodeJobProgressToolName, - isClaudeCodeSubtleToolCallName, - toClaudeCodeToolArgs, type ClaudeCodeToolArgs, } from '@studio/routes/agents/ClaudeCodeChatRoute/toolParts'; -import cn from 'classnames'; -import { - CheckSquare, - ChevronRight, - CircleHelp, - ClipboardList, - Command, - FilePenLine, - FilePlus2, - FileText, - Globe, - ListTree, - Search, - Terminal, - type LucideIcon, -} from 'lucide-react'; - -const TOOL_LABELS: Record = { - Bash: 'Run command', - Edit: 'Edit file', - Glob: 'Find files', - Grep: 'Search text', - LS: 'List directory', - MultiEdit: 'Edit file', - Read: 'Read file', - TodoWrite: 'Update todos', - WebFetch: 'Fetch URL', - WebSearch: 'Search web', - Write: 'Write file', -}; - -const TOOL_ICONS: Record = { - Bash: Terminal, - Edit: FilePenLine, - Glob: Search, - Grep: Search, - LS: ListTree, - MultiEdit: FilePenLine, - Read: FileText, - TodoWrite: CheckSquare, - WebFetch: Globe, - WebSearch: Search, - Write: FilePenLine, -}; - -const CODE_BLOCK_SURFACE_CLASS = 'bg-gray-050 dark:bg-gray-900'; -const FILE_CHANGE_ADDITION_CLASS = 'text-feedback-success'; -const FILE_CHANGE_DELETION_CLASS = 'text-feedback-danger'; -const SUBTLE_MESSAGE_MAX_LENGTH = 160; -const RUNNING_TOOL_CALL_CLASS = 'claude-code-tool-call-running'; - -const SUBTLE_TOOL_ICONS: Record = { - AskUserQuestion: CircleHelp, - Bash: Command, - FindFiles: Search, - Grep: Search, - Read: FileText, - TaskCreate: ClipboardList, - TaskUpdate: ClipboardList, - ToolSearch: Search, -}; - -interface SubtleToolAction { - readonly detail: string; - readonly details?: readonly string[]; - readonly Icon: LucideIcon; - readonly message: string; - readonly title?: string; - readonly toolCallId: string; - readonly toolName: string; -} - -const getStringArg = (args: ClaudeCodeToolArgs, keys: string[]): string | undefined => { - for (const key of keys) { - const value = args[key]; - if (typeof value === 'string' && value.trim()) return value.trim(); - } - return undefined; -}; - -const getRawStringArg = (args: Record, keys: string[]): string | undefined => { - for (const key of keys) { - const value = args[key]; - if (typeof value === 'string') return value; - } - return undefined; -}; - -const getToolSummary = (toolName: string, args: ClaudeCodeToolArgs): string | undefined => { - switch (toolName) { - case 'Bash': - return getStringArg(args, ['command']); - case 'Edit': - case 'MultiEdit': - case 'Read': - case 'Write': - return getStringArg(args, ['file_path', 'path']); - case 'Glob': - return getStringArg(args, ['pattern']); - case 'Grep': { - const pattern = getStringArg(args, ['pattern']); - const path = getStringArg(args, ['path']); - return [pattern, path].filter(Boolean).join(' in ') || undefined; - } - case 'LS': - return getStringArg(args, ['path']); - case 'TodoWrite': { - const todos = args.todos; - return Array.isArray(todos) ? `${todos.length} todos` : undefined; - } - case 'WebFetch': - return getStringArg(args, ['url']); - case 'WebSearch': - return getStringArg(args, ['query']); - default: - return getStringArg(args, ['command', 'file_path', 'path', 'pattern', 'query', 'url']); - } -}; - -const compactSubtleDetail = (detail: string | undefined): string | undefined => { - const compacted = detail?.replace(/\s+/g, ' ').trim(); - if (!compacted) return undefined; - if (compacted.length <= SUBTLE_MESSAGE_MAX_LENGTH) return compacted; - return `${compacted.slice(0, SUBTLE_MESSAGE_MAX_LENGTH - 3).trimEnd()}...`; -}; - -const formatSubtleToolMessage = ( - action: string, - detail: string | undefined, - fallback: string -): string => { - const compactedDetail = compactSubtleDetail(detail); - return compactedDetail ? `${action} ${compactedDetail}` : fallback; -}; - -const getSubtleToolIcon = (toolName: string): LucideIcon => - SUBTLE_TOOL_ICONS[toolName] ?? TOOL_ICONS[toolName] ?? Terminal; - -const getRepeatedSubtleToolMessage = (toolName: string, count: number): string => { - switch (toolName) { - case 'AskUserQuestion': - return `Asked ${count} questions`; - case 'Bash': - return `Ran ${count} commands`; - case 'FindFiles': - return `Searched files ${count} times`; - case 'Glob': - return `Found files ${count} times`; - case 'Grep': - return `Searched text ${count} times`; - case 'LS': - return `Listed ${count} directories`; - case 'Read': - return `Read ${count} files`; - case 'TaskCreate': - return `Created ${count} tasks`; - case 'TaskUpdate': - return `Updated ${count} tasks`; - case 'TodoWrite': - return `Updated todos ${count} times`; - case 'ToolSearch': - return `Searched tools ${count} times`; - case 'WebFetch': - return `Fetched ${count} URLs`; - case 'WebSearch': - return `Searched web ${count} times`; - default: { - const label = TOOL_LABELS[toolName] ?? toolName; - return `Used ${label} ${count} times`; - } - } -}; - -const getFileName = (path: string): string => { - const segments = path.split(/[\\/]/).filter(Boolean); - return segments.at(-1) ?? path; -}; - -const getLineCount = (content: string): number => { - if (!content) return 0; - - const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const withoutTrailingNewline = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized; - - return withoutTrailingNewline.split('\n').length; -}; - -const getEditStats = (args: Record): { additions: number; deletions: number } => ({ - additions: getLineCount(getRawStringArg(args, ['new_string']) ?? ''), - deletions: getLineCount(getRawStringArg(args, ['old_string']) ?? ''), -}); - -const isToolArgsRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const splitCollapsedThinkingParagraphs = (text: string): readonly string[] => - text - .trim() - .split(/\n\s*\n/) - .map((paragraph) => paragraph.trim()) - .filter(Boolean); - -const getAskUserQuestionSummary = (args: ClaudeCodeToolArgs): string | undefined => { - const questions = args.questions; - const firstQuestion = Array.isArray(questions) ? questions.find(isToolArgsRecord) : undefined; - return ( - getStringArg(args, ['question', 'prompt']) ?? - (firstQuestion - ? getRawStringArg(firstQuestion, ['question', 'prompt', 'header'])?.trim() - : undefined) - ); -}; - -interface FileChangeSummary { - readonly action: 'Edited' | 'Wrote'; - readonly additions: number; - readonly deletions: number; - readonly path: string; - readonly reviewContent: string; -} - -const formatArgs = (args: ClaudeCodeToolArgs, argsText: string): string => { - const trimmedArgsText = argsText.trim(); - if (trimmedArgsText && trimmedArgsText !== '{}') return trimmedArgsText; - return JSON.stringify(args, null, 2); -}; - -const getFileChangeSummary = ( - toolName: string, - args: ClaudeCodeToolArgs, - argsText: string -): FileChangeSummary | undefined => { - const path = getStringArg(args, ['file_path', 'path']); - if (!path) return undefined; - - if (toolName === 'Write') { - const content = getRawStringArg(args, ['content']); - if (content === undefined) return undefined; - - return { - action: 'Wrote', - additions: getLineCount(content), - deletions: 0, - path, - reviewContent: content, - }; - } - - if (toolName === 'Edit') { - return { - action: 'Edited', - ...getEditStats(args), - path, - reviewContent: formatArgs(args, argsText), - }; - } - - if (toolName === 'MultiEdit') { - const edits = args.edits; - if (!Array.isArray(edits)) return undefined; - - const stats = edits.filter(isToolArgsRecord).reduce<{ additions: number; deletions: number }>( - (total, edit) => { - const editStats = getEditStats(edit); - return { - additions: total.additions + editStats.additions, - deletions: total.deletions + editStats.deletions, - }; - }, - { additions: 0, deletions: 0 } - ); - - return { - action: 'Edited', - ...stats, - path, - reviewContent: formatArgs(args, argsText), - }; - } - - return undefined; -}; - -const getSubtleToolMessage = (toolName: string, args: ClaudeCodeToolArgs): string | undefined => { - if (!isClaudeCodeSubtleToolCallName(toolName)) return undefined; - - if (toolName === 'AskUserQuestion') { - return formatSubtleToolMessage('Asked', getAskUserQuestionSummary(args), 'Asked user question'); - } - - if (toolName === 'Bash') { - return formatSubtleToolMessage( - 'Ran', - getStringArg(args, ['description', 'command']), - 'Ran command' - ); - } - - if (toolName === 'FindFiles') { - return formatSubtleToolMessage( - 'Searched files', - getStringArg(args, ['query', 'pattern', 'path']), - 'Searched files' - ); - } - - if (toolName === 'Grep') { - return formatSubtleToolMessage( - 'Searched text', - getToolSummary(toolName, args), - 'Searched text' - ); - } - - if (toolName === 'Glob') { - return formatSubtleToolMessage('Found files', getToolSummary(toolName, args), 'Found files'); - } - - if (toolName === 'LS') { - return formatSubtleToolMessage( - 'Listed directory', - getToolSummary(toolName, args), - 'Listed directory' - ); - } - - if (toolName === 'Read') { - const path = getStringArg(args, ['file_path', 'path']); - return path ? `Read ${getFileName(path)}` : 'Read file'; - } - - if (toolName === 'TaskCreate') { - return formatSubtleToolMessage( - 'Created task', - getStringArg(args, ['description', 'task', 'prompt', 'query']), - 'Created task' - ); - } - - if (toolName === 'TaskUpdate') { - return formatSubtleToolMessage( - 'Updated task', - getStringArg(args, ['description', 'task', 'status']), - 'Updated task' - ); - } - - if (toolName === 'TodoWrite') { - return formatSubtleToolMessage( - 'Updated todos', - getToolSummary(toolName, args), - 'Updated todos' - ); - } - - if (toolName === 'ToolSearch') { - return formatSubtleToolMessage( - 'Searched tools', - getStringArg(args, ['query', 'pattern', 'name']), - 'Searched tools' - ); - } - - if (toolName === 'WebFetch') { - return formatSubtleToolMessage('Fetched URL', getToolSummary(toolName, args), 'Fetched URL'); - } - - if (toolName === 'WebSearch') { - return formatSubtleToolMessage('Searched web', getToolSummary(toolName, args), 'Searched web'); - } - - const label = TOOL_LABELS[toolName] ?? toolName; - return formatSubtleToolMessage(`Used ${label}`, getToolSummary(toolName, args), `Used ${label}`); -}; - -const getSubtleToolDetail = ( - toolName: string, - args: ClaudeCodeToolArgs, - message: string -): string => { - if (toolName === 'AskUserQuestion') { - return compactSubtleDetail(getAskUserQuestionSummary(args)) ?? message; - } - - if (toolName === 'Bash') { - return compactSubtleDetail(getStringArg(args, ['description', 'command'])) ?? message; - } - - if (toolName === 'FindFiles') { - return compactSubtleDetail(getStringArg(args, ['query', 'pattern', 'path'])) ?? message; - } - - if (toolName === 'Read') { - const path = getStringArg(args, ['file_path', 'path']); - return path ? getFileName(path) : message; - } - - if (toolName === 'TaskCreate') { - return ( - compactSubtleDetail(getStringArg(args, ['description', 'task', 'prompt', 'query'])) ?? message - ); - } - - if (toolName === 'TaskUpdate') { - return compactSubtleDetail(getStringArg(args, ['description', 'task', 'status'])) ?? message; - } - - if (toolName === 'ToolSearch') { - return compactSubtleDetail(getStringArg(args, ['query', 'pattern', 'name'])) ?? message; - } - - return compactSubtleDetail(getToolSummary(toolName, args)) ?? message; -}; - -const getSubtleToolGroupActions = (args: ClaudeCodeToolArgs): readonly SubtleToolAction[] => { - const actions = args.actions; - if (!Array.isArray(actions)) return []; - - return actions - .filter(isToolArgsRecord) - .map((action, index): SubtleToolAction | undefined => { - const toolName = getRawStringArg(action, ['toolName'])?.trim(); - if (!toolName) return undefined; - - const actionArgs = isToolArgsRecord(action.args) ? toClaudeCodeToolArgs(action.args) : {}; - const message = getSubtleToolMessage(toolName, actionArgs); - if (!message) return undefined; - - return { - detail: getSubtleToolDetail(toolName, actionArgs, message), - Icon: getSubtleToolIcon(toolName), - message, - toolCallId: getRawStringArg(action, ['toolCallId'])?.trim() ?? `${toolName}-${index}`, - toolName, - }; - }) - .filter((action): action is SubtleToolAction => action !== undefined); -}; - -const summarizeRepeatedSubtleToolActions = ( - actions: readonly SubtleToolAction[] -): readonly SubtleToolAction[] => { - const groupedActions = new Map(); - - for (const action of actions) { - const existingActions = groupedActions.get(action.toolName); - if (existingActions) { - existingActions.push(action); - } else { - groupedActions.set(action.toolName, [action]); - } - } - - return Array.from(groupedActions.values()).map((group) => { - if (group.length === 1) return group[0]!; - - const firstAction = group[0]!; - return { - ...firstAction, - details: group.map((action) => action.detail), - message: getRepeatedSubtleToolMessage(firstAction.toolName, group.length), - title: group.map((action) => action.message).join(' | '), - toolCallId: `${firstAction.toolCallId}-${group.length}`, - }; - }); -}; - -interface SubtleToolCallRowProps { - readonly actions: readonly SubtleToolAction[]; - readonly isRunning?: boolean; -} - -const SubtleToolCallRow = ({ actions, isRunning = false }: SubtleToolCallRowProps) => ( - -
    action.title ?? action.message).join(' | ')} - > - {summarizeRepeatedSubtleToolActions(actions).map((action, index) => { - const Icon = action.Icon; - const key = `${action.toolCallId}-${index}`; - - if (action.details?.length) { - return ( -
    - - - - {action.message} - -
      - {action.details.map((detail, detailIndex) => ( -
    • - {detail} -
    • - ))} -
    -
    - ); - } - - return ( - - - {action.message} - - ); - })} -
    -
    -); - -const CollapsedThinkingToolCall = ({ text }: { readonly text: string }) => { - const paragraphs = splitCollapsedThinkingParagraphs(text); - if (!paragraphs.length) return null; - - return ( - -
    - - - - Earlier thinking - -
    - {paragraphs.map((paragraph, index) => ( -

    - {paragraph} -

    - ))} -
    -
    -
    - ); -}; - -interface FileChangeToolCallCardProps { - readonly isRunning?: boolean; - readonly summary: { - readonly action: 'Edited' | 'Wrote'; - readonly additions: number; - readonly deletions: number; - readonly path: string; - readonly reviewContent: string; - }; -} - -const FileChangeToolCallCard = ({ isRunning = false, summary }: FileChangeToolCallCardProps) => { - const Icon = summary.action === 'Wrote' ? FilePlus2 : FilePenLine; - - return ( -
    -
    - -
    - -
    -
    - - {summary.action} 1 file - - - +{summary.additions}{' '} - -{summary.deletions} - -
    - - Review - - -
    -
    -
    -            
    -              {summary.reviewContent}
    -            
    -          
    -
    -
    -
    -
    - - {summary.path} - - - +{summary.additions}{' '} - -{summary.deletions} - -
    -
    -
    - ); -}; interface ClaudeCodeToolCallPartContentProps { readonly args: ClaudeCodeToolArgs; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections.tsx new file mode 100644 index 0000000000..b661aec8e3 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections.tsx @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Anchor, Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; +import { cleanClaudeCodeArtifactText } from '@studio/routes/agents/ClaudeCodeChatRoute/artifacts'; +import { CLAUDE_CODE_STUDIO_LINK_CLASS } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLink'; +import { getStudioInternalLinkTarget } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLinkTarget'; +import { getFileLabel } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; +import type { + ClaudeCodeChatFileArtifact, + ClaudeCodeChatLinkArtifact, + ClaudeCodeChatSelectionArtifact, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import cn from 'classnames'; +import { ArrowRight, Boxes, FileCode2, Link2, Sparkles, Wrench } from 'lucide-react'; +import { type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +export const ToolCallSummary = ({ toolCalls }: { toolCalls: string[] }) => { + if (!toolCalls.length) return null; + + return ( + + + + {toolCalls.join(', ')} + + + ); +}; + +export const ArtifactChip = ({ children }: { children: ReactNode }) => { + const content = typeof children === 'string' ? cleanClaudeCodeArtifactText(children) : children; + + return ( + + + {content} + + + ); +}; + +export const ArtifactRow = ({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value?: string; +}) => { + if (!value) return null; + + return ( + + + {icon} + + + + {label}: + + {value} + + + ); +}; + +export const ArtifactSection = ({ + background, + children, + icon, + title, +}: { + background?: boolean; + children: ReactNode; + icon: ReactNode; + title: string; +}) => ( + + + {icon} + + {title} + + + {children} + +); + +export const FileArtifacts = ({ files }: { files: ClaudeCodeChatFileArtifact[] }) => { + if (!files.length) return null; + + return ( + } title="Files"> + + {files.slice(0, 6).map((file) => ( + + + {file.action} + + + {getFileLabel(file)} + + + ))} + + + ); +}; + +export const LinkArtifacts = ({ links }: { links: ClaudeCodeChatLinkArtifact[] }) => { + const workspace = useWorkspaceFromPathIfExists(); + + if (!links.length) return null; + + return ( + } title="Studio links"> + + {links.slice(0, 6).map((link) => { + const target = getStudioInternalLinkTarget( + link.href ?? link.destination, + window.location.origin, + workspace + ); + const label = cleanClaudeCodeArtifactText(link.label); + const key = `${link.label}-${link.destination ?? link.href ?? 'link'}`; + + return target ? ( + + + {label} + + ) : ( + {label} + ); + })} + + + ); +}; + +export const SelectionArtifacts = ({ + selections, +}: { + selections: ClaudeCodeChatSelectionArtifact[]; +}) => { + if (!selections.length) return null; + + return ( + } title="Selections"> + + {selections.slice(0, 6).map((selection) => ( + } + label={selection.label} + value={selection.value} + /> + ))} + + + ); +}; + +export const ToolArtifacts = ({ tools }: { tools: string[] }) => { + if (!tools.length) return null; + + return ( + } title="Tools"> + + {tools.slice(0, 8).map((tool) => ( + {tool} + ))} + + + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/ClaudeCodeArtifactsPane.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/ClaudeCodeArtifactsPane.tsx new file mode 100644 index 0000000000..d4317e3a9c --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/ClaudeCodeArtifactsPane.tsx @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Flex, Stack, Text, Tooltip } from '@nvidia/foundations-react-core'; +import { Empty } from '@studio/components/Empty'; +import { + ArtifactRow, + FileArtifacts, + LinkArtifacts, + SelectionArtifacts, + ToolArtifacts, +} from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections'; +import { + getSelectedArtifactModel, + hasArtifacts, +} from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; +import type { ClaudeCodeChatArtifacts } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { Bot, Boxes, Cpu, PanelRightClose, Sparkles } from 'lucide-react'; + +export const ClaudeCodeArtifactsPane = ({ + artifacts, + collapseLabel, + onCollapse, +}: { + artifacts?: ClaudeCodeChatArtifacts; + collapseLabel: string; + onCollapse: () => void; +}) => { + const selectedModel = artifacts ? getSelectedArtifactModel(artifacts) : undefined; + + return ( +
    + + + + + Chat artifacts + + + + + + + {hasArtifacts(artifacts) ? ( + + + } label="Agent" value={artifacts.agent} /> + } label="Model" value={selectedModel} /> + } label="Workspace" value={artifacts.workspace} /> + + + + + + + ) : ( + + + + )} +
    + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx new file mode 100644 index 0000000000..674f0517d3 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelContents.tsx @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Banner, Button, Flex, Text, Tooltip } from '@nvidia/foundations-react-core'; +import { Empty } from '@studio/components/Empty'; +import { + CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, + listClaudeCodeHistorySessions, +} from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import { HistoryPanelSkeleton } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelSkeletons'; +import { HistorySessionButton } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton'; +import type { ClaudeCodeHistoryPanelProps } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/types'; +import { useQuery } from '@tanstack/react-query'; +import { MessageSquarePlus, RefreshCw } from 'lucide-react'; + +export const HistoryPanelContents = ({ + activeSessionId, + onNewChat, + onSelectSession, +}: ClaudeCodeHistoryPanelProps) => { + const { + data: sessions = [], + error, + isLoading, + refetch, + } = useQuery({ + queryKey: CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY, + queryFn: listClaudeCodeHistorySessions, + refetchOnMount: 'always', + }); + + return ( + <> +
    + + + + + + +
    + {error && ( +
    + + Could not load Claude history. + +
    + )} + {isLoading ? ( + + ) : sessions.length ? ( +
    + {sessions.map((session) => ( + onSelectSession(session.session_id)} + /> + ))} +
    + ) : !error ? ( + + + + ) : null} + + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelSkeletons.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelSkeletons.tsx new file mode 100644 index 0000000000..b3030862e5 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelSkeletons.tsx @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Skeleton, Stack } from '@nvidia/foundations-react-core'; + +export const HistoryPanelSkeleton = () => ( + + + + + +); + +export const SkillsPanelSkeleton = () => ( + + + + + +); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx new file mode 100644 index 0000000000..4f4f0dafa4 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import { ToolCallSummary } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections'; +import { getCompactRelativeTime } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; +import type { ClaudeCodeHistorySession } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import cn from 'classnames'; +import { MessageSquare } from 'lucide-react'; + +export const HistorySessionButton = ({ + active, + onSelect, + session, +}: { + active: boolean; + onSelect: () => void; + session: ClaudeCodeHistorySession; +}) => ( + +); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillCard.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillCard.tsx new file mode 100644 index 0000000000..a6d0c3833e --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillCard.tsx @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Card, Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { getSkillDisplayName } from '@studio/routes/DashboardLandingRoute/skillDisplayName'; +import { BookOpen } from 'lucide-react'; + +export const SkillCard = ({ skill }: { skill: ClaudeCodeSkill }) => ( + + + + + + + + + {getSkillDisplayName(skill)} + + + {skill.claude_name} + + + + + {skill.description || 'No description'} + + + +); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillsPanelContents.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillsPanelContents.tsx new file mode 100644 index 0000000000..169ec70f5a --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillsPanelContents.tsx @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Banner, Button, Flex, Stack, Text, Tooltip } from '@nvidia/foundations-react-core'; +import { Empty } from '@studio/components/Empty'; +import { + CLAUDE_CODE_SKILLS_QUERY_KEY, + listClaudeCodeSkills, +} from '@studio/routes/agents/ClaudeCodeChatRoute/api'; +import { SkillsPanelSkeleton } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/HistoryPanelSkeletons'; +import { SkillCard } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/SkillCard'; +import { useQuery } from '@tanstack/react-query'; +import { RefreshCw } from 'lucide-react'; + +export const SkillsPanelContents = () => { + const { + data: skills = [], + error, + isLoading, + refetch, + } = useQuery({ + queryKey: CLAUDE_CODE_SKILLS_QUERY_KEY, + queryFn: listClaudeCodeSkills, + }); + + return ( + <> + + + {skills.length} skills + + + + + + {error && ( +
    + + Could not load Claude skills. + +
    + )} + {isLoading ? ( + + ) : skills.length ? ( +
    + + {skills.map((skill) => ( + + ))} + +
    + ) : !error ? ( + + + + ) : null} + + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/constants.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/constants.tsx new file mode 100644 index 0000000000..8859c67a15 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/constants.tsx @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flex } from '@nvidia/foundations-react-core'; +import { BookOpen, History } from 'lucide-react'; + +export const PANEL_TAB_ITEMS = [ + { + value: 'history', + children: ( + + + History + + ), + }, + { + value: 'skills', + children: ( + + + Skills + + ), + }, +]; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts new file mode 100644 index 0000000000..a7c089b174 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ClaudeCodePanelTab } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/types'; +import type { + ClaudeCodeChatArtifacts, + ClaudeCodeChatFileArtifact, +} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; + +export const isClaudeCodePanelTab = (value: string): value is ClaudeCodePanelTab => + value === 'history' || value === 'skills'; + +export const getCompactRelativeTime = (mtime: number): string => { + const elapsedMs = Math.max(Date.now() - mtime * 1000, 0); + const minuteMs = 60 * 1000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + + if (elapsedMs < minuteMs) return 'now'; + if (elapsedMs < hourMs) return `${Math.floor(elapsedMs / minuteMs)}m`; + if (elapsedMs < dayMs) return `${Math.floor(elapsedMs / hourMs)}h`; + + const days = Math.floor(elapsedMs / dayMs); + if (days < 31) return `${days}d`; + + return new Date(mtime * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); +}; + +export const getFileLabel = (file: ClaudeCodeChatFileArtifact): string => { + const parts = file.path.split('/'); + return parts[parts.length - 1] || file.path; +}; + +export const getSelectedArtifactModel = (artifacts: ClaudeCodeChatArtifacts): string | undefined => + artifacts.model_source === 'selection' || artifacts.model_source === 'spec' + ? artifacts.model + : undefined; + +export const hasArtifacts = ( + artifacts?: ClaudeCodeChatArtifacts +): artifacts is ClaudeCodeChatArtifacts => + !!artifacts && + !!( + artifacts.agent || + getSelectedArtifactModel(artifacts) || + artifacts.workspace || + artifacts.selections.length || + artifacts.files.length || + artifacts.links.length || + artifacts.tools.length + ); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts new file mode 100644 index 0000000000..4e25d621a8 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/types.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ClaudeCodeChatArtifacts } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; + +export interface ClaudeCodeHistoryPanelProps { + activeSessionId?: string; + artifacts?: ClaudeCodeChatArtifacts; + onNewChat: () => void; + onSelectSession: (sessionId: string) => void; +} + +export type ClaudeCodePanelTab = 'history' | 'skills'; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/CollapsedThinkingToolCall.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/CollapsedThinkingToolCall.tsx new file mode 100644 index 0000000000..457034a305 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/CollapsedThinkingToolCall.tsx @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '@nvidia/foundations-react-core'; +import { splitCollapsedThinkingParagraphs } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/helpers'; +import { ChevronRight, ClipboardList } from 'lucide-react'; + +export const CollapsedThinkingToolCall = ({ text }: { readonly text: string }) => { + const paragraphs = splitCollapsedThinkingParagraphs(text); + if (!paragraphs.length) return null; + + return ( + +
    + + + + Earlier thinking + +
    + {paragraphs.map((paragraph, index) => ( +

    + {paragraph} +

    + ))} +
    +
    +
    + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx new file mode 100644 index 0000000000..56ed106cb0 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/FileChangeToolCallCard.tsx @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '@nvidia/foundations-react-core'; +import { + CODE_BLOCK_SURFACE_CLASS, + FILE_CHANGE_ADDITION_CLASS, + FILE_CHANGE_DELETION_CLASS, + RUNNING_TOOL_CALL_CLASS, +} from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/constants'; +import cn from 'classnames'; +import { ChevronRight, FilePenLine, FilePlus2 } from 'lucide-react'; + +interface FileChangeToolCallCardProps { + readonly isRunning?: boolean; + readonly summary: { + readonly action: 'Edited' | 'Wrote'; + readonly additions: number; + readonly deletions: number; + readonly path: string; + readonly reviewContent: string; + }; +} + +export const FileChangeToolCallCard = ({ + isRunning = false, + summary, +}: FileChangeToolCallCardProps) => { + const Icon = summary.action === 'Wrote' ? FilePlus2 : FilePenLine; + + return ( +
    +
    + +
    + +
    +
    + + {summary.action} 1 file + + + +{summary.additions}{' '} + -{summary.deletions} + +
    + + Review + + +
    +
    +
    +            
    +              {summary.reviewContent}
    +            
    +          
    +
    +
    +
    +
    + + {summary.path} + + + +{summary.additions}{' '} + -{summary.deletions} + +
    +
    +
    + ); +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx new file mode 100644 index 0000000000..7981954937 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/SubtleToolCallRow.tsx @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from '@nvidia/foundations-react-core'; +import { RUNNING_TOOL_CALL_CLASS } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/constants'; +import { summarizeRepeatedSubtleToolActions } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/helpers'; +import type { SubtleToolAction } from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/types'; +import cn from 'classnames'; +import { ChevronRight } from 'lucide-react'; + +interface SubtleToolCallRowProps { + readonly actions: readonly SubtleToolAction[]; + readonly isRunning?: boolean; +} + +export const SubtleToolCallRow = ({ actions, isRunning = false }: SubtleToolCallRowProps) => ( + +
    action.title ?? action.message).join(' | ')} + > + {summarizeRepeatedSubtleToolActions(actions).map((action, index) => { + const Icon = action.Icon; + const key = `${action.toolCallId}-${index}`; + + if (action.details?.length) { + return ( +
    + + + + {action.message} + +
      + {action.details.map((detail, detailIndex) => ( +
    • + {detail} +
    • + ))} +
    +
    + ); + } + + return ( + + + {action.message} + + ); + })} +
    +
    +); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts new file mode 100644 index 0000000000..31831e8ce7 --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/constants.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + CheckSquare, + CircleHelp, + ClipboardList, + Command, + FilePenLine, + FileText, + Globe, + ListTree, + Search, + Terminal, + type LucideIcon, +} from 'lucide-react'; + +export const TOOL_LABELS: Record = { + Bash: 'Run command', + Edit: 'Edit file', + Glob: 'Find files', + Grep: 'Search text', + LS: 'List directory', + MultiEdit: 'Edit file', + Read: 'Read file', + TodoWrite: 'Update todos', + WebFetch: 'Fetch URL', + WebSearch: 'Search web', + Write: 'Write file', +}; + +export const TOOL_ICONS: Record = { + Bash: Terminal, + Edit: FilePenLine, + Glob: Search, + Grep: Search, + LS: ListTree, + MultiEdit: FilePenLine, + Read: FileText, + TodoWrite: CheckSquare, + WebFetch: Globe, + WebSearch: Search, + Write: FilePenLine, +}; + +export const CODE_BLOCK_SURFACE_CLASS = 'bg-gray-050 dark:bg-gray-900'; +export const FILE_CHANGE_ADDITION_CLASS = 'text-feedback-success'; +export const FILE_CHANGE_DELETION_CLASS = 'text-feedback-danger'; +export const SUBTLE_MESSAGE_MAX_LENGTH = 160; +export const RUNNING_TOOL_CALL_CLASS = 'claude-code-tool-call-running'; + +export const SUBTLE_TOOL_ICONS: Record = { + AskUserQuestion: CircleHelp, + Bash: Command, + FindFiles: Search, + Grep: Search, + Read: FileText, + TaskCreate: ClipboardList, + TaskUpdate: ClipboardList, + ToolSearch: Search, +}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/helpers.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/helpers.ts new file mode 100644 index 0000000000..286c56b32f --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/helpers.ts @@ -0,0 +1,413 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + SUBTLE_MESSAGE_MAX_LENGTH, + SUBTLE_TOOL_ICONS, + TOOL_ICONS, + TOOL_LABELS, +} from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/constants'; +import type { + FileChangeSummary, + SubtleToolAction, +} from '@studio/routes/agents/ClaudeCodeChatRoute/toolCall/types'; +import { + isClaudeCodeSubtleToolCallName, + toClaudeCodeToolArgs, + type ClaudeCodeToolArgs, +} from '@studio/routes/agents/ClaudeCodeChatRoute/toolParts'; +import { Terminal, type LucideIcon } from 'lucide-react'; + +const getStringArg = (args: ClaudeCodeToolArgs, keys: string[]): string | undefined => { + for (const key of keys) { + const value = args[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +}; + +const getRawStringArg = (args: Record, keys: string[]): string | undefined => { + for (const key of keys) { + const value = args[key]; + if (typeof value === 'string') return value; + } + return undefined; +}; + +export const getToolSummary = (toolName: string, args: ClaudeCodeToolArgs): string | undefined => { + switch (toolName) { + case 'Bash': + return getStringArg(args, ['command']); + case 'Edit': + case 'MultiEdit': + case 'Read': + case 'Write': + return getStringArg(args, ['file_path', 'path']); + case 'Glob': + return getStringArg(args, ['pattern']); + case 'Grep': { + const pattern = getStringArg(args, ['pattern']); + const path = getStringArg(args, ['path']); + return [pattern, path].filter(Boolean).join(' in ') || undefined; + } + case 'LS': + return getStringArg(args, ['path']); + case 'TodoWrite': { + const todos = args.todos; + return Array.isArray(todos) ? `${todos.length} todos` : undefined; + } + case 'WebFetch': + return getStringArg(args, ['url']); + case 'WebSearch': + return getStringArg(args, ['query']); + default: + return getStringArg(args, ['command', 'file_path', 'path', 'pattern', 'query', 'url']); + } +}; + +export const compactSubtleDetail = (detail: string | undefined): string | undefined => { + const compacted = detail?.replace(/\s+/g, ' ').trim(); + if (!compacted) return undefined; + if (compacted.length <= SUBTLE_MESSAGE_MAX_LENGTH) return compacted; + return `${compacted.slice(0, SUBTLE_MESSAGE_MAX_LENGTH - 3).trimEnd()}...`; +}; + +export const formatSubtleToolMessage = ( + action: string, + detail: string | undefined, + fallback: string +): string => { + const compactedDetail = compactSubtleDetail(detail); + return compactedDetail ? `${action} ${compactedDetail}` : fallback; +}; + +export const getSubtleToolIcon = (toolName: string): LucideIcon => + SUBTLE_TOOL_ICONS[toolName] ?? TOOL_ICONS[toolName] ?? Terminal; + +const getRepeatedSubtleToolMessage = (toolName: string, count: number): string => { + switch (toolName) { + case 'AskUserQuestion': + return `Asked ${count} questions`; + case 'Bash': + return `Ran ${count} commands`; + case 'FindFiles': + return `Searched files ${count} times`; + case 'Glob': + return `Found files ${count} times`; + case 'Grep': + return `Searched text ${count} times`; + case 'LS': + return `Listed ${count} directories`; + case 'Read': + return `Read ${count} files`; + case 'TaskCreate': + return `Created ${count} tasks`; + case 'TaskUpdate': + return `Updated ${count} tasks`; + case 'TodoWrite': + return `Updated todos ${count} times`; + case 'ToolSearch': + return `Searched tools ${count} times`; + case 'WebFetch': + return `Fetched ${count} URLs`; + case 'WebSearch': + return `Searched web ${count} times`; + default: { + const label = TOOL_LABELS[toolName] ?? toolName; + return `Used ${label} ${count} times`; + } + } +}; + +const getFileName = (path: string): string => { + const segments = path.split(/[\\/]/).filter(Boolean); + return segments.at(-1) ?? path; +}; + +const getLineCount = (content: string): number => { + if (!content) return 0; + + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const withoutTrailingNewline = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized; + + return withoutTrailingNewline.split('\n').length; +}; + +const getEditStats = (args: Record): { additions: number; deletions: number } => ({ + additions: getLineCount(getRawStringArg(args, ['new_string']) ?? ''), + deletions: getLineCount(getRawStringArg(args, ['old_string']) ?? ''), +}); + +const isToolArgsRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const splitCollapsedThinkingParagraphs = (text: string): readonly string[] => + text + .trim() + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + +const getAskUserQuestionSummary = (args: ClaudeCodeToolArgs): string | undefined => { + const questions = args.questions; + const firstQuestion = Array.isArray(questions) ? questions.find(isToolArgsRecord) : undefined; + return ( + getStringArg(args, ['question', 'prompt']) ?? + (firstQuestion + ? getRawStringArg(firstQuestion, ['question', 'prompt', 'header'])?.trim() + : undefined) + ); +}; + +const formatArgs = (args: ClaudeCodeToolArgs, argsText: string): string => { + const trimmedArgsText = argsText.trim(); + if (trimmedArgsText && trimmedArgsText !== '{}') return trimmedArgsText; + return JSON.stringify(args, null, 2); +}; + +export const getFileChangeSummary = ( + toolName: string, + args: ClaudeCodeToolArgs, + argsText: string +): FileChangeSummary | undefined => { + const path = getStringArg(args, ['file_path', 'path']); + if (!path) return undefined; + + if (toolName === 'Write') { + const content = getRawStringArg(args, ['content']); + if (content === undefined) return undefined; + + return { + action: 'Wrote', + additions: getLineCount(content), + deletions: 0, + path, + reviewContent: content, + }; + } + + if (toolName === 'Edit') { + return { + action: 'Edited', + ...getEditStats(args), + path, + reviewContent: formatArgs(args, argsText), + }; + } + + if (toolName === 'MultiEdit') { + const edits = args.edits; + if (!Array.isArray(edits)) return undefined; + + const stats = edits.filter(isToolArgsRecord).reduce<{ additions: number; deletions: number }>( + (total, edit) => { + const editStats = getEditStats(edit); + return { + additions: total.additions + editStats.additions, + deletions: total.deletions + editStats.deletions, + }; + }, + { additions: 0, deletions: 0 } + ); + + return { + action: 'Edited', + ...stats, + path, + reviewContent: formatArgs(args, argsText), + }; + } + + return undefined; +}; + +export const getSubtleToolMessage = ( + toolName: string, + args: ClaudeCodeToolArgs +): string | undefined => { + if (!isClaudeCodeSubtleToolCallName(toolName)) return undefined; + + if (toolName === 'AskUserQuestion') { + return formatSubtleToolMessage('Asked', getAskUserQuestionSummary(args), 'Asked user question'); + } + + if (toolName === 'Bash') { + return formatSubtleToolMessage( + 'Ran', + getStringArg(args, ['description', 'command']), + 'Ran command' + ); + } + + if (toolName === 'FindFiles') { + return formatSubtleToolMessage( + 'Searched files', + getStringArg(args, ['query', 'pattern', 'path']), + 'Searched files' + ); + } + + if (toolName === 'Grep') { + return formatSubtleToolMessage( + 'Searched text', + getToolSummary(toolName, args), + 'Searched text' + ); + } + + if (toolName === 'Glob') { + return formatSubtleToolMessage('Found files', getToolSummary(toolName, args), 'Found files'); + } + + if (toolName === 'LS') { + return formatSubtleToolMessage( + 'Listed directory', + getToolSummary(toolName, args), + 'Listed directory' + ); + } + + if (toolName === 'Read') { + const path = getStringArg(args, ['file_path', 'path']); + return path ? `Read ${getFileName(path)}` : 'Read file'; + } + + if (toolName === 'TaskCreate') { + return formatSubtleToolMessage( + 'Created task', + getStringArg(args, ['description', 'task', 'prompt', 'query']), + 'Created task' + ); + } + + if (toolName === 'TaskUpdate') { + return formatSubtleToolMessage( + 'Updated task', + getStringArg(args, ['description', 'task', 'status']), + 'Updated task' + ); + } + + if (toolName === 'TodoWrite') { + return formatSubtleToolMessage( + 'Updated todos', + getToolSummary(toolName, args), + 'Updated todos' + ); + } + + if (toolName === 'ToolSearch') { + return formatSubtleToolMessage( + 'Searched tools', + getStringArg(args, ['query', 'pattern', 'name']), + 'Searched tools' + ); + } + + if (toolName === 'WebFetch') { + return formatSubtleToolMessage('Fetched URL', getToolSummary(toolName, args), 'Fetched URL'); + } + + if (toolName === 'WebSearch') { + return formatSubtleToolMessage('Searched web', getToolSummary(toolName, args), 'Searched web'); + } + + const label = TOOL_LABELS[toolName] ?? toolName; + return formatSubtleToolMessage(`Used ${label}`, getToolSummary(toolName, args), `Used ${label}`); +}; + +export const getSubtleToolDetail = ( + toolName: string, + args: ClaudeCodeToolArgs, + message: string +): string => { + if (toolName === 'AskUserQuestion') { + return compactSubtleDetail(getAskUserQuestionSummary(args)) ?? message; + } + + if (toolName === 'Bash') { + return compactSubtleDetail(getStringArg(args, ['description', 'command'])) ?? message; + } + + if (toolName === 'FindFiles') { + return compactSubtleDetail(getStringArg(args, ['query', 'pattern', 'path'])) ?? message; + } + + if (toolName === 'Read') { + const path = getStringArg(args, ['file_path', 'path']); + return path ? getFileName(path) : message; + } + + if (toolName === 'TaskCreate') { + return ( + compactSubtleDetail(getStringArg(args, ['description', 'task', 'prompt', 'query'])) ?? message + ); + } + + if (toolName === 'TaskUpdate') { + return compactSubtleDetail(getStringArg(args, ['description', 'task', 'status'])) ?? message; + } + + if (toolName === 'ToolSearch') { + return compactSubtleDetail(getStringArg(args, ['query', 'pattern', 'name'])) ?? message; + } + + return compactSubtleDetail(getToolSummary(toolName, args)) ?? message; +}; + +export const getSubtleToolGroupActions = ( + args: ClaudeCodeToolArgs +): readonly SubtleToolAction[] => { + const actions = args.actions; + if (!Array.isArray(actions)) return []; + + return actions + .filter(isToolArgsRecord) + .map((action, index): SubtleToolAction | undefined => { + const toolName = getRawStringArg(action, ['toolName'])?.trim(); + if (!toolName) return undefined; + + const actionArgs = isToolArgsRecord(action.args) ? toClaudeCodeToolArgs(action.args) : {}; + const message = getSubtleToolMessage(toolName, actionArgs); + if (!message) return undefined; + + return { + detail: getSubtleToolDetail(toolName, actionArgs, message), + Icon: getSubtleToolIcon(toolName), + message, + toolCallId: getRawStringArg(action, ['toolCallId'])?.trim() ?? `${toolName}-${index}`, + toolName, + }; + }) + .filter((action): action is SubtleToolAction => action !== undefined); +}; + +export const summarizeRepeatedSubtleToolActions = ( + actions: readonly SubtleToolAction[] +): readonly SubtleToolAction[] => { + const groupedActions = new Map(); + + for (const action of actions) { + const existingActions = groupedActions.get(action.toolName); + if (existingActions) { + existingActions.push(action); + } else { + groupedActions.set(action.toolName, [action]); + } + } + + return Array.from(groupedActions.values()).map((group) => { + if (group.length === 1) return group[0]!; + + const firstAction = group[0]!; + return { + ...firstAction, + details: group.map((action) => action.detail), + message: getRepeatedSubtleToolMessage(firstAction.toolName, group.length), + title: group.map((action) => action.message).join(' | '), + toolCallId: `${firstAction.toolCallId}-${group.length}`, + }; + }); +}; + +export { getStringArg }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/types.ts new file mode 100644 index 0000000000..dc61fabedd --- /dev/null +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/toolCall/types.ts @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type LucideIcon } from 'lucide-react'; + +export interface SubtleToolAction { + readonly detail: string; + readonly details?: readonly string[]; + readonly Icon: LucideIcon; + readonly message: string; + readonly title?: string; + readonly toolCallId: string; + readonly toolName: string; +} + +export interface FileChangeSummary { + readonly action: 'Edited' | 'Wrote'; + readonly additions: number; + readonly deletions: number; + readonly path: string; + readonly reviewContent: string; +}