diff --git a/services/studio/src/nmp/studio/env_mappings.py b/services/studio/src/nmp/studio/env_mappings.py index 5ff3fe3f17..93d5604af7 100644 --- a/services/studio/src/nmp/studio/env_mappings.py +++ b/services/studio/src/nmp/studio/env_mappings.py @@ -138,6 +138,11 @@ class EnvMapping: config_path="studio.feature_flags.model_compare_enabled", default="false", ), + EnvMapping( + marker="STUDIO_UI_VITE_FF_PROMPTS_ENABLED", + config_path="studio.feature_flags.prompts_enabled", + default="false", + ), EnvMapping( marker="STUDIO_UI_VITE_FF_SAFE_SYNTHESIZER_ENABLED", config_path="studio.feature_flags.safe_synthesizer_enabled", diff --git a/web/packages/common/src/components/StatusBadge/StatusBadge.test.tsx b/web/packages/common/src/components/StatusBadge/StatusBadge.test.tsx index 343abc1016..e5d4eed2f9 100644 --- a/web/packages/common/src/components/StatusBadge/StatusBadge.test.tsx +++ b/web/packages/common/src/components/StatusBadge/StatusBadge.test.tsx @@ -4,6 +4,7 @@ import { BadgeStatus, badgeStatus } from '@nemo/common/src/components/StatusBadge/badgeStatus'; import { StatusBadge } from '@nemo/common/src/components/StatusBadge/index'; import * as customQueries from '@nemo/common/src/tests/customQueries'; +import { getLucideIcon } from '@nemo/common/src/tests/lucideIconQueries'; import { queries, render, screen, within } from '@testing-library/react'; import { CircleCheck } from 'lucide-react'; @@ -47,9 +48,7 @@ describe('StatusBadge component', () => { const status: BadgeStatus = 'in_progress'; render(, { queries: allQueries }); - // Use querySelector with document to find SVG (escape hatch for SVG testing) - // eslint-disable-next-line testing-library/no-node-access - const icon = document.querySelector('.lucide-refresh-cw'); + const icon = getLucideIcon('refresh-cw'); expect(icon).toBeInTheDocument(); expect(icon).toHaveAttribute('width', '12px'); expect(icon).toHaveAttribute('height', '12px'); @@ -59,8 +58,7 @@ describe('StatusBadge component', () => { // Since all current statuses have icons, we'll verify the icon exists render(); - // eslint-disable-next-line testing-library/no-node-access - const icon = document.querySelector('.lucide-circle-check'); + const icon = getLucideIcon('circle-check'); expect(icon).toBeInTheDocument(); }); diff --git a/web/packages/common/src/tests/customQueries.ts b/web/packages/common/src/tests/customQueries.ts index 22acd4e7b6..254e0bce61 100644 --- a/web/packages/common/src/tests/customQueries.ts +++ b/web/packages/common/src/tests/customQueries.ts @@ -3,6 +3,14 @@ import { queryHelpers, buildQueries, Matcher, MatcherOptions } from '@testing-library/react'; +export { + findAllByLucideIcon, + findByLucideIcon, + getAllByLucideIcon, + getByLucideIcon, + queryByLucideIcon, +} from '@nemo/common/src/tests/lucideIconQueries'; + // The queryAllByAttribute is a shortcut for attribute-based matchers // You can also use document.querySelector or a combination of existing // testing library utilities to find matching nodes for your query diff --git a/web/packages/common/src/tests/lucideIconQueries.ts b/web/packages/common/src/tests/lucideIconQueries.ts new file mode 100644 index 0000000000..395c2a8a37 --- /dev/null +++ b/web/packages/common/src/tests/lucideIconQueries.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { buildQueries, Matcher } from '@testing-library/react'; + +/** Lucide icons render as ``. Use the kebab-case suffix (e.g. `cog`, `refresh-cw`). */ +const lucideIconSelector = (iconName: string) => `svg.lucide-${iconName}`; + +/** Returns all matching Lucide SVGs under `container` (defaults to `document.body`). */ +export const queryAllLucideIcons = ( + iconName: string, + container: ParentNode = document.body +): SVGElement[] => { + return [...container.querySelectorAll(lucideIconSelector(iconName))] as SVGElement[]; +}; + +const getMultipleError = (_container: Element | null, iconName: string) => + `Found multiple lucide icons matching: ${iconName}`; +const getMissingError = (_container: Element | null, iconName: string) => + `Unable to find lucide icon: ${iconName}`; + +/** Finds a single Lucide icon by kebab-case name, optionally scoped to a container. */ +export const getLucideIcon = ( + iconName: string, + container: ParentNode = document.body +): SVGElement => { + const icons = queryAllLucideIcons(iconName, container); + if (icons.length === 0) { + throw new Error(getMissingError(null, iconName)); + } + if (icons.length > 1) { + throw new Error(getMultipleError(null, iconName)); + } + return icons[0]; +}; + +/** Returns the first matching Lucide icon, or null if none. */ +export const queryLucideIcon = ( + iconName: string, + container: ParentNode = document.body +): SVGElement | null => queryAllLucideIcons(iconName, container)[0] ?? null; + +const queryAllByLucideIcon = (container: HTMLElement, iconName: Matcher) => { + if (typeof iconName !== 'string') { + throw new TypeError(`Lucide icon name must be a string, received ${typeof iconName}`); + } + return queryAllLucideIcons(iconName, container) as unknown as HTMLElement[]; +}; + +export const [ + queryByLucideIcon, + getAllByLucideIcon, + getByLucideIcon, + findAllByLucideIcon, + findByLucideIcon, +] = buildQueries(queryAllByLucideIcon, getMultipleError, getMissingError); diff --git a/web/packages/studio/env/.env.dev.local.sample b/web/packages/studio/env/.env.dev.local.sample index 10d1819ee3..a625dfe760 100644 --- a/web/packages/studio/env/.env.dev.local.sample +++ b/web/packages/studio/env/.env.dev.local.sample @@ -31,6 +31,7 @@ VITE_FF_INTAKE_ENABLED='true' VITE_FF_JOBS_ENABLED='true' VITE_FF_MEMBERS_ENABLED='preview' VITE_FF_MODEL_COMPARE_ENABLED='true' +VITE_FF_PROMPTS_ENABLED='false' VITE_FF_SAFE_SYNTHESIZER_ENABLED='false' VITE_FF_SECRETS_ENABLED='true' VITE_FF_SETTINGS_ENABLED='true' diff --git a/web/packages/studio/env/.env.fastapi b/web/packages/studio/env/.env.fastapi index 886cdd4565..51713f72bf 100644 --- a/web/packages/studio/env/.env.fastapi +++ b/web/packages/studio/env/.env.fastapi @@ -32,5 +32,6 @@ VITE_FF_INFERENCE_PROVIDER_ENABLED=STUDIO_UI_VITE_FF_INFERENCE_PROVIDER_ENABLED VITE_FF_INTAKE_ENABLED=STUDIO_UI_VITE_FF_INTAKE_ENABLED VITE_FF_MEMBERS_ENABLED=STUDIO_UI_VITE_FF_MEMBERS_ENABLED VITE_FF_MODEL_COMPARE_ENABLED=STUDIO_UI_VITE_FF_MODEL_COMPARE_ENABLED +VITE_FF_PROMPTS_ENABLED=STUDIO_UI_VITE_FF_PROMPTS_ENABLED VITE_FF_SAFE_SYNTHESIZER_ENABLED=STUDIO_UI_VITE_FF_SAFE_SYNTHESIZER_ENABLED VITE_FF_SECRETS_ENABLED=STUDIO_UI_VITE_FF_SECRETS_ENABLED diff --git a/web/packages/studio/src/components/Layouts/NavigationDrawer/index.test.tsx b/web/packages/studio/src/components/Layouts/NavigationDrawer/index.test.tsx index 33db0df307..9704dbf038 100644 --- a/web/packages/studio/src/components/Layouts/NavigationDrawer/index.test.tsx +++ b/web/packages/studio/src/components/Layouts/NavigationDrawer/index.test.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { getLucideIcon } from '@nemo/common/src/tests/lucideIconQueries'; import { ROUTES } from '@studio/constants/routes'; import { workspace1 } from '@studio/mocks/entity-store/projects'; import { PageLayout } from '@studio/routes/PageLayout'; @@ -130,14 +131,12 @@ describe('NavigationDrawer', () => { // Starts expanded expect(accordionTrigger).toHaveAttribute('data-state', 'open'); - // eslint-disable-next-line testing-library/no-node-access - expect(document.querySelector('.lucide-chevron-up')).toBeInTheDocument(); + expect(getLucideIcon('chevron-up')).toBeInTheDocument(); // Clicking closes it (chevron-down icon visible) await user.click(accordionTrigger as HTMLElement); expect(accordionTrigger).toHaveAttribute('data-state', 'closed'); - // eslint-disable-next-line testing-library/no-node-access - expect(document.querySelector('.lucide-chevron-down')).toBeInTheDocument(); + expect(getLucideIcon('chevron-down')).toBeInTheDocument(); }); }); }); diff --git a/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx b/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx index 5bd60223ce..4f9d1381f0 100644 --- a/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/DataDesignerJobsDataView/index.tsx @@ -27,6 +27,7 @@ import type { import { Banner, Button, Text } from '@nvidia/foundations-react-core'; import { DeleteJobModal } from '@studio/components/dataViews/DataDesignerJobsDataView/DeleteJobModal'; import { QuickActionsMenuRoot } from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot'; +import { DataDesignerIconFc } from '@studio/constants/constants'; import { STATUS_FILTER_OPTIONS } from '@studio/constants/platformJobs'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getDataDesignerJobDetailsRoute, getNewDataDesignerJobRoute } from '@studio/routes/utils'; @@ -248,6 +249,7 @@ export const DataDesignerJobsDataView: FC = () => { /> ) : ( } header="Data Designer Jobs" emptyMessage="Create and manage data designer jobs to generate or transform datasets." actions={ diff --git a/web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx b/web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx new file mode 100644 index 0000000000..6c4a449b7d --- /dev/null +++ b/web/packages/studio/src/components/dataViews/PromptsDataView/index.tsx @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + ROW_ACTIONS_COLUMN_SIZE, + StudioDataView, +} from '@nemo/common/src/components/DataView/StudioDataView'; +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 { useToast } from '@nemo/common/src/providers/toast/useToast'; +import { useModelsDeletePrompt, useModelsListPrompts } from '@nemo/sdk/generated/platform/api'; +import type { Prompt } from '@nemo/sdk/generated/platform/schema'; +import { Button, Stack, Text } from '@nvidia/foundations-react-core'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { DeleteConfirmationModal } from '@studio/components/DeleteConfirmationModal'; +import { ErrorPanel } from '@studio/components/ErrorPanel'; +import { PromptIconFc } from '@studio/constants/constants'; +import { keepPreviousData } from '@tanstack/react-query'; +import { Trash } from 'lucide-react'; +import { ComponentProps, FC, useCallback, useMemo, useState } from 'react'; + +export interface PromptsDataViewProps { + workspace: string; + emptyStateActions?: React.ReactNode; + attributes?: { + Stack?: React.ComponentProps; + }; +} + +type PromptWithId = Prompt & { id: string }; + +export const PromptsDataView: FC = ({ + workspace, + emptyStateActions, + attributes, +}) => { + const toast = useToast(); + + const dataViewState = useStudioDataViewState({ + defaultSort: { id: 'created_at', desc: true }, + }); + + const [promptToDelete, setPromptToDelete] = useState(); + + const { data, refetch, isFetching, error } = useModelsListPrompts( + workspace, + { + page: dataViewState.pagination.state.pageIndex + 1, + page_size: dataViewState.pagination.state.pageSize, + }, + { + query: { + placeholderData: keepPreviousData, + }, + } + ); + + const deletePromptMutation = useModelsDeletePrompt(); + + const prompts = useMemo(() => data?.data ?? [], [data?.data]); + const pagination = data?.pagination; + + const searchBar = dataViewState.searchBar.state; + const filteredPrompts = useMemo(() => { + if (!searchBar) return prompts; + return prompts.filter((prompt: Prompt) => + prompt.name?.toLowerCase().includes(searchBar.toLowerCase()) + ); + }, [prompts, searchBar]); + + const promptsWithId = useMemo( + () => + filteredPrompts.map((prompt: Prompt) => ({ + ...prompt, + id: `${prompt.workspace}/${prompt.name}`, + })), + [filteredPrompts] + ); + + const handleDeletePrompt = async () => { + if (!promptToDelete) return false; + + try { + await deletePromptMutation.mutateAsync({ + workspace, + name: promptToDelete.name, + }); + refetch(); + return true; + } catch { + toast.error('Failed to delete prompt'); + return false; + } + }; + + const makeColumns: ComponentProps>['makeColumns'] = + useCallback( + ({ accessor }, { rowActionsColumn }) => [ + accessor('name', { + header: 'Name', + enableSorting: false, + size: 200, + }), + accessor('description', { + header: 'Description', + cell({ row }) { + return ( + + {row.original.description || '-'} + + ); + }, + }), + accessor('tags', { + header: 'Tags', + enableSorting: false, + size: 180, + cell({ row }) { + const tags = row.original.tags; + if (!tags?.length) return -; + return ( + + {tags.join(', ')} + + ); + }, + }), + accessor('created_at', { + header: 'Created', + enableSorting: true, + size: 150, + cell({ row }) { + return row.original.created_at ? ( + + ) : ( + - + ); + }, + }), + accessor('updated_at', { + header: 'Updated', + enableSorting: true, + size: 150, + cell({ row }) { + return row.original.updated_at ? ( + + ) : ( + - + ); + }, + }), + rowActionsColumn({ + size: ROW_ACTIONS_COLUMN_SIZE, + enableResizing: false, + rowActions: (prompt: PromptWithId) => [ + { + slotLeft: , + children: 'Delete', + danger: true, + onSelect: () => setPromptToDelete(prompt), + }, + ], + }), + ], + [] + ); + + const hasActiveFilters = !!searchBar; + + return ( + + + hasActiveFilters ? ( + + Clear Search + + } + /> + ) : ( + } + header="No Prompts Yet" + emptyMessage="Reusable prompt templates will appear here once created." + actions={emptyStateActions} + /> + ), + renderErrorState: () => ( + + ), + }, + }} + /> + + {promptToDelete && ( + setPromptToDelete(undefined)} + /> + )} + + ); +}; diff --git a/web/packages/studio/src/constants/constants.ts b/web/packages/studio/src/constants/constants.tsx similarity index 88% rename from web/packages/studio/src/constants/constants.ts rename to web/packages/studio/src/constants/constants.tsx index fade534278..68b3e71fdd 100644 --- a/web/packages/studio/src/constants/constants.ts +++ b/web/packages/studio/src/constants/constants.tsx @@ -10,6 +10,8 @@ * its affiliates is strictly prohibited. */ +import { MessagesSquare, Palette } from 'lucide-react'; + export const CHAT_DEFAULT_MAX_TOKENS = 4096; export const DEFAULT_LARGE_PAGE_SIZE = 1000; export const DATASET_NAME_REGEX = /^[a-zA-Z0-9._-]+$/; @@ -20,3 +22,6 @@ export const DEFAULT_TOOLS_FILE_NAME = 'tools.json'; export const EMPTY_FIELD_VALUE = '-'; export const EMPTY_FIELD_EMDASH_VALUE = '—'; export const DEFAULT_BUILD_MODEL_NAME = 'nvidia-nvidia-llama-3-3-nemotron-super-49b-v1-5'; + +export const DataDesignerIconFc = Palette; +export const PromptIconFc = MessagesSquare; diff --git a/web/packages/studio/src/constants/environment.ts b/web/packages/studio/src/constants/environment.ts index 4c48e76a05..89562f7b8f 100644 --- a/web/packages/studio/src/constants/environment.ts +++ b/web/packages/studio/src/constants/environment.ts @@ -47,6 +47,7 @@ export const INTAKE_ENABLED = featureFlags.intakeEnabled !== false; export const JOBS_ENABLED = featureFlags.jobsEnabled !== false; export const MEMBERS_ENABLED = featureFlags.membersEnabled !== false; export const MODEL_COMPARE_ENABLED = featureFlags.modelCompareEnabled !== false; +export const PROMPTS_ENABLED = featureFlags.promptsEnabled !== false; export const SAFE_SYNTHESIZER_ENABLED = featureFlags.safeSynthesizerEnabled !== false; export const SECRETS_ENABLED = featureFlags.secretsEnabled !== false; export const SETTINGS_ENABLED = featureFlags.settingsEnabled !== false; diff --git a/web/packages/studio/src/constants/featureFlags/featureFlags.ts b/web/packages/studio/src/constants/featureFlags/featureFlags.ts index b02d4c8b1c..4afb1e03a3 100644 --- a/web/packages/studio/src/constants/featureFlags/featureFlags.ts +++ b/web/packages/studio/src/constants/featureFlags/featureFlags.ts @@ -72,6 +72,7 @@ export const flagDefinitions = { jobsEnabled: previewFlag('VITE_FF_JOBS_ENABLED', true), membersEnabled: previewFlag('VITE_FF_MEMBERS_ENABLED'), modelCompareEnabled: previewFlag('VITE_FF_MODEL_COMPARE_ENABLED'), + promptsEnabled: previewFlag('VITE_FF_PROMPTS_ENABLED', false), safeSynthesizerEnabled: previewFlag('VITE_FF_SAFE_SYNTHESIZER_ENABLED', false), secretsEnabled: previewFlag('VITE_FF_SECRETS_ENABLED', true), settingsEnabled: previewFlag('VITE_FF_SETTINGS_ENABLED', true), diff --git a/web/packages/studio/src/constants/routes.ts b/web/packages/studio/src/constants/routes.ts index 7e46ef3353..627dfeb87f 100644 --- a/web/packages/studio/src/constants/routes.ts +++ b/web/packages/studio/src/constants/routes.ts @@ -122,6 +122,7 @@ export const ROUTES = { modelCompare: `/workspaces/:${P.workspace}/playground`, agentOptimizations: `/workspaces/:${P.workspace}/agents/suggestions`, agentMonitor: `/workspaces/:${P.workspace}/agents/monitor`, + prompts: `/workspaces/:${P.workspace}/prompts`, }, models: { index: '/models', diff --git a/web/packages/studio/src/routes/PromptsListRoute/index.tsx b/web/packages/studio/src/routes/PromptsListRoute/index.tsx new file mode 100644 index 0000000000..0651fdfae9 --- /dev/null +++ b/web/packages/studio/src/routes/PromptsListRoute/index.tsx @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { PromptsDataView } from '@studio/components/dataViews/PromptsDataView'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; +import { getWorkspacePromptsRoute } from '@studio/routes/utils'; +import type { FC } from 'react'; + +export const PromptsListRoute: FC = () => { + const workspace = useWorkspaceFromPath(); + + useBreadcrumbs({ + items: [{ href: getWorkspacePromptsRoute(workspace), slotLabel: 'Prompts' }], + }); + + return ( + + + + + + + ); +}; diff --git a/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx b/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx index 519cb82db3..c0062d5a59 100644 --- a/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx +++ b/web/packages/studio/src/routes/SafeSynthesizerJobDetailsRoute/components/JobDetailsPanel.test.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { getLucideIcon } from '@nemo/common/src/tests/lucideIconQueries'; import { formatTimeInSeconds, getDifferenceInMilliseconds } from '@nemo/common/src/utils/date'; import type { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema'; import * as safeSynthesizerApi from '@nemo/sdk/generated/safe-synthesizer/api'; @@ -122,14 +123,6 @@ vi.mock('@studio/providers/toast/useToast', () => ({ })), })); -// Mock brand assets icons -vi.mock('lucide-react', () => ({ - Play: () => , - File: () => , - Cog: () => , - Copy: () => , -})); - // Test wrapper const createWrapper = () => { const queryClient = new QueryClient({ @@ -215,7 +208,7 @@ describe('JobDetailsPanel', () => { render(, { wrapper: createWrapper() }); expect(screen.getByText('Job Details')).toBeInTheDocument(); - expect(screen.getByTestId('running-icon')).toBeInTheDocument(); + expect(getLucideIcon('play')).toBeInTheDocument(); }); it('should display job status with badge', () => { @@ -453,8 +446,9 @@ describe('JobDetailsPanel', () => { const job = createMockJob(); render(, { wrapper: createWrapper() }); - expect(screen.getByText('View Job Config')).toBeInTheDocument(); - expect(screen.getByTestId('cog-icon')).toBeInTheDocument(); + const configButton = screen.getByRole('button', { name: 'View Job Config' }); + expect(configButton).toBeInTheDocument(); + expect(getLucideIcon('cog', configButton)).toBeInTheDocument(); }); it('should open job config drawer when button is clicked', async () => { @@ -462,7 +456,7 @@ describe('JobDetailsPanel', () => { const user = userEvent.setup(); render(, { wrapper: createWrapper() }); - const configButton = screen.getByText('View Job Config'); + const configButton = screen.getByRole('button', { name: 'View Job Config' }); await user.click(configButton); await waitFor(() => { @@ -476,7 +470,7 @@ describe('JobDetailsPanel', () => { render(, { wrapper: createWrapper() }); // Open drawer - const configButton = screen.getByText('View Job Config'); + const configButton = screen.getByRole('button', { name: 'View Job Config' }); await user.click(configButton); await waitFor(() => { diff --git a/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx b/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx index ab2574742d..0b6c7d143f 100644 --- a/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx +++ b/web/packages/studio/src/routes/WorkspaceLayout/WorkspaceSideNav.tsx @@ -3,6 +3,7 @@ import SafeSynthesizerLogo from '@nemo/common/src/svgs/safe_synthesizer_logo.svg?react'; import { NavigationDrawer } from '@studio/components/Layouts/NavigationDrawer'; +import { DataDesignerIconFc, PromptIconFc } from '@studio/constants/constants'; import { AGENTS_ENABLED, BASE_MODELS_ENABLED, @@ -18,6 +19,7 @@ import { INTAKE_ENABLED, JOBS_ENABLED, MODEL_COMPARE_ENABLED, + PROMPTS_ENABLED, SAFE_SYNTHESIZER_ENABLED, SETTINGS_ENABLED, } from '@studio/constants/environment'; @@ -40,6 +42,7 @@ import { getWorkspaceFilesetsRoute, getWorkspaceDeploymentsRoute, getWorkspaceJobsRoute, + getWorkspacePromptsRoute, getWorkspaceSafeSynthesizerRoute, getWorkspaceSettingsRoute, } from '@studio/routes/utils'; @@ -49,7 +52,6 @@ import { ChartBar, Database, HatGlasses, - LayoutList, ListChecks, Home, ShieldCheck, @@ -148,7 +150,7 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { ? [ { id: 'data-designer', - slotIcon: , + slotIcon: , slotLabel: 'Data Designer', href: getDataDesignerJobListRoute(workspace), }, @@ -242,7 +244,7 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { }, ] : []), - ...(BASE_MODELS_ENABLED || customizerNav.length > 0 || DEPLOYMENTS_ENABLED + ...(BASE_MODELS_ENABLED || customizerNav.length > 0 || DEPLOYMENTS_ENABLED || PROMPTS_ENABLED ? [ { group: 'Models', @@ -268,6 +270,16 @@ export const WorkspaceSideNav = ({ collapsed }: { collapsed?: boolean }) => { }, ] : []), + ...(PROMPTS_ENABLED + ? [ + { + id: 'prompts', + slotIcon: , + slotLabel: 'Prompts', + href: getWorkspacePromptsRoute(workspace), + }, + ] + : []), ], }, ] diff --git a/web/packages/studio/src/routes/groups/index.ts b/web/packages/studio/src/routes/groups/index.ts index eef63bfe94..bb7acd70da 100644 --- a/web/packages/studio/src/routes/groups/index.ts +++ b/web/packages/studio/src/routes/groups/index.ts @@ -19,3 +19,4 @@ export { agentRoutes } from '@studio/routes/groups/agentRoutes'; export { settingsRoutes } from '@studio/routes/groups/settingsRoutes'; export { modelCompareRoutes } from '@studio/routes/groups/modelCompareRoutes'; export { memberRoutes } from '@studio/routes/groups/memberRoutes'; +export { promptsRoutes } from '@studio/routes/groups/promptsRoutes'; diff --git a/web/packages/studio/src/routes/groups/promptsRoutes.tsx b/web/packages/studio/src/routes/groups/promptsRoutes.tsx new file mode 100644 index 0000000000..8da3412c2c --- /dev/null +++ b/web/packages/studio/src/routes/groups/promptsRoutes.tsx @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ErrorPanel } from '@studio/components/ErrorPanel'; +import { PROMPTS_ENABLED } from '@studio/constants/environment'; +import { ROUTES } from '@studio/constants/routes'; +import { gatePromptsRoutes } from '@studio/routes/utils'; +import { lazy } from 'react'; +import type { RouteObject } from 'react-router-dom'; + +const PromptsListRoute = + PROMPTS_ENABLED && + lazy(() => + import('@studio/routes/PromptsListRoute').then((module) => ({ + default: module.PromptsListRoute, + })) + ); + +export const promptsRoutes: RouteObject[] = gatePromptsRoutes([ + { + path: ROUTES.workspace.prompts, + element: PromptsListRoute ? : null, + errorElement: , + }, +]); diff --git a/web/packages/studio/src/routes/index.tsx b/web/packages/studio/src/routes/index.tsx index 55a2e6d011..8c4a6fe8af 100644 --- a/web/packages/studio/src/routes/index.tsx +++ b/web/packages/studio/src/routes/index.tsx @@ -21,6 +21,7 @@ import { jobRoutes, memberRoutes, modelCompareRoutes, + promptsRoutes, safeSynthesizerRoutes, secretsRoutes, settingsRoutes, @@ -115,6 +116,7 @@ export const routes: RouteObject[] = [ ...agentRoutes, ...settingsRoutes, ...modelCompareRoutes, + ...promptsRoutes, ...memberRoutes, ], }, diff --git a/web/packages/studio/src/routes/utils.ts b/web/packages/studio/src/routes/utils.ts index 01ac28159c..557069d645 100644 --- a/web/packages/studio/src/routes/utils.ts +++ b/web/packages/studio/src/routes/utils.ts @@ -21,6 +21,7 @@ import { JOBS_ENABLED, MEMBERS_ENABLED, MODEL_COMPARE_ENABLED, + PROMPTS_ENABLED, SAFE_SYNTHESIZER_ENABLED, SECRETS_ENABLED, SETTINGS_ENABLED, @@ -98,6 +99,9 @@ export const gateDeploymentsRoutes = (routes: RouteObject | RouteObject[]) => export const gateModelCompareRoutes = (routes: RouteObject | RouteObject[]) => gateRoutes(MODEL_COMPARE_ENABLED, routes); +export const gatePromptsRoutes = (routes: RouteObject | RouteObject[]) => + gateRoutes(PROMPTS_ENABLED, routes); + type WorkspacePathParams = { workspace: string; }; @@ -379,6 +383,10 @@ export const getModelCompareRoute = (workspace: string) => { return generatePath(ROUTES.workspace.modelCompare, { workspace }); }; +export const getWorkspacePromptsRoute = (workspace: string) => { + return generatePath(ROUTES.workspace.prompts, { workspace }); +}; + export const getFilesetDetailsRoute = ( workspace: string, filesetId: string,