diff --git a/web/.agents/skills/ui-design/SKILL.md b/web/.agents/skills/ui-design/SKILL.md new file mode 100644 index 0000000000..499d6b4f19 --- /dev/null +++ b/web/.agents/skills/ui-design/SKILL.md @@ -0,0 +1,14 @@ +--- +name: ui-design +description: Reference hub for implementing UI changes in Studio. Use whenever building or modifying Studio frontend UI — pages, components, tables, panels, empty states, and other shared patterns. Routes to the standardized references below. +--- + +# UI Design + +When implementing any Studio UI change, consult the matching reference before +writing code and follow it. These references are authoritative over ad-hoc +markup — reuse the shared pattern rather than hand-rolling a new one. + +| If you are building / changing... | Read | +| ----------------------------------------------------- | ---------------------------- | +| An empty state (no items, no results, failed to load) | `references/empty-states.md` | diff --git a/web/.agents/skills/ui-design/references/empty-states.md b/web/.agents/skills/ui-design/references/empty-states.md new file mode 100644 index 0000000000..e4e88c809e --- /dev/null +++ b/web/.agents/skills/ui-design/references/empty-states.md @@ -0,0 +1,153 @@ +# Studio Empty States + +Studio renders every _empty_ state through **one** primitive: `EntityEmptyState` +in `@nemo/common`, driven by a central **entity registry**. Do not hand-roll +`StatusMessage`, copy `TableEmptyState`, or invent per-callsite empty markup. +Adding a new empty state means adding a registry entry and pointing a +callsite at it — nothing more. **Error states are separate**: keep routing the +error branch through the existing `ErrorPanel` (with `getErrorMessage(error)`), +which surfaces the actual failure — `EntityEmptyState` does not handle errors. + +> Governing design rules: `kaizen-ui` skill → +> `references/patterns/empty-states.md`, `references/patterns/error-states.md`, +> `references/components/StatusMessage.md`. Read those before deviating. + +## Prerequisite + +This reference assumes the shared `EntityEmptyState` component and +`ENTITY_EMPTY_STATES` registry already exist in `@nemo/common` (delivered by +ASTD-394). If they do not yet exist, you are doing the initial build — follow +the Action Plan on the ticket, not this reference. This reference is the +go-forward guide for **every empty state after** that primitive lands. + +Canonical locations: + +- Component: `packages/common/src/components/EntityEmptyState/index.tsx` +- Registry: `packages/common/src/components/EntityEmptyState/registry.ts` + (`ENTITY_EMPTY_STATES: Record`) + +## The two variants + +Every empty state is exactly one of two governed variants. Never invent a +third idiom. + +| Variant | When | Required affordances | +| ------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `first-use` | Data source is genuinely empty; user hasn't created anything | icon, heading, subheading, primary create CTA, and the "Ask an agent · CLI" self-service snippet | +| `no-results` | Items exist but current filters/search match zero | heading naming the mismatch, **"Clear filters"** action; **no** create CTA | + +**Compute the variant from signals — do not pick it manually.** Inside a +DataView the signals already exist: `hasFiltersApplied` / `hasSearchApplied` +(→ `no-results`); otherwise `first-use`. The error branch is handled separately +by `ErrorPanel`, not by a variant. + +## Adding a new empty state + +### 1. Add a registry entry + +One entry per entity in `ENTITY_EMPTY_STATES`. Copy/CTA/CLI/prompt live here +only — never at the callsite. + +```ts +filesets: { + icon: FolderOpen, // single size token, set by the component + heading: 'No filesets yet', // sentence case, specific + subheading: 'Filesets group the files your agents and jobs read from.', + createAction: { label: 'Create fileset', to: ROUTES.FILESETS_NEW }, + cliCommand: 'nemo files filesets create --name ', + skillPrompt: 'Help me create my first fileset with nemo-files', +}, +``` + +Field rules: + +- `icon` — a `lucide-react` icon. The component applies the one standard size + token; do not set `size-*`, `h-[64px]`, etc. yourself. +- `heading` / `subheading` — sentence case ("No filesets yet", not + "No Filesets Found" / "Manage Filesets"). Subheading answers "why would I add + one?" in 1–2 sentences. +- `createAction?` — **omit** for entities with no in-app create flow (e.g. + Agents, Members). Use `to` for route navigation; for imperative or + modal-driven creation, omit `to` and pass `onCreate` at the callsite instead + (`EmptyStateCreateAction` has no `onClick` field). Renders as + ` + ) : null + } + /> + + ); + } + + const { icon: Icon, heading, subheading, createAction, cliCommand, skillPrompt } = descriptor; + const handleCreate = + onCreate ?? (createAction?.to ? () => navigate(createAction.to as string) : undefined); + + return ( + + } + slotHeading={heading} + slotSubheading={subheading} + slotFooter={ + createAction && handleCreate ? ( + + ) : null + } + /> + {(cliCommand || skillPrompt) && ( +
+ +
+ )} +
+ ); +}; + +const Centered: FC<{ children: React.ReactNode; className?: string; testId: string }> = ({ + children, + className, + testId, +}) => ( + + {children} + +); + +/** Self-service help kind. */ +type HelpKind = 'cli' | 'agent'; + +/** + * A compact "Ask an agent · CLI" disclosure: a KUI CodeSnippet (with its + * built-in copy affordance) whose action row hosts a tiny SegmentedControl to + * switch between the agent prompt and the CLI command. Kept out of the + * StatusMessage footer so the ≤2-action rule for empty states holds. + */ +const SelfServiceHelp: FC<{ cliCommand?: string; skillPrompt?: string }> = ({ + cliCommand, + skillPrompt, +}) => { + const toast = useToast(); + const [kind, setKind] = useState(cliCommand ? 'cli' : 'agent'); + + const items: { value: HelpKind; children: React.ReactNode }[] = []; + if (skillPrompt) + items.push({ + value: 'agent', + children: 'Ask an agent', + }); + if (cliCommand) items.push({ value: 'cli', children: 'CLI' }); + + const showCli = kind === 'cli' && !!cliCommand; + const value = showCli ? (cliCommand as string) : (skillPrompt ?? cliCommand ?? ''); + const language: CodeSnippetLanguage = showCli ? 'bash' : 'markdown'; + + return ( +
+ toast.success('Copied to clipboard')} + slotActions={ + items.length > 1 ? ( + + setKind(next as HelpKind)} + items={items} + /> + + ) : undefined + } + /> +
+ ); +}; diff --git a/web/packages/common/src/components/EntityEmptyState/registry.ts b/web/packages/common/src/components/EntityEmptyState/registry.ts new file mode 100644 index 0000000000..e7f35c7fce --- /dev/null +++ b/web/packages/common/src/components/EntityEmptyState/registry.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ShieldCheck, type LucideIcon } from 'lucide-react'; + +/** + * A create call-to-action for a first-use empty state. + * + * Provide `to` for a route-driven create flow; the {@link EntityEmptyState} + * navigates there on click. For modal-driven creates (no dedicated route), + * omit `to` and pass `onCreate` at the callsite — the label still comes from + * here so copy stays centralized. + */ +export interface EmptyStateCreateAction { + label: string; + to?: string; +} + +/** + * Per-entity copy, iconography, and self-service affordances for an empty + * state. One entry per entity lives in {@link ENTITY_EMPTY_STATES}; callsites + * never inline this content. + */ +export interface EmptyStateDescriptor { + /** A `lucide-react` icon. The component applies the standard size token. */ + icon: LucideIcon; + /** Sentence-case, entity-specific first-use heading. */ + heading: string; + /** 1–2 sentences answering "why would I create one?". */ + subheading: string; + /** Omit for entities with no in-app create flow (e.g. Agents, Members). */ + createAction?: EmptyStateCreateAction; + /** Concrete, copy-pasteable CLI command with `` args. Omit when none exists. */ + cliCommand?: string; + /** Copy-to-clipboard prompt that triggers the entity's skill. Omit when none exists. */ + skillPrompt?: string; +} + +/** Keys of entities that have a standardized empty state. */ +export type EntityKey = 'guardrails'; + +/** + * Canonical empty-state registry. Grows one entry at a time as entities migrate + * onto {@link EntityEmptyState}. + */ +export const ENTITY_EMPTY_STATES: Record = { + guardrails: { + icon: ShieldCheck, + heading: 'No guardrail configs yet', + subheading: + 'Guardrail configs add content-safety, jailbreak, and PII rails to the models in this workspace.', + // Create is a modal owned by the route, so the callsite supplies `onCreate`. + createAction: { label: 'Create guardrail config' }, + cliCommand: 'nemo guardrail configs create ', + skillPrompt: 'Help me create my first guardrail config with the nemo-guardrails skill', + }, +}; diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx index 84f8120e34..2581339968 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx @@ -22,6 +22,7 @@ const renderComponent = ( props: { onRowClick?: (config: GuardrailConfig) => void; onRequestDelete?: (config: GuardrailConfig) => void; + onCreate?: () => void; } = {} ) => { const router = createMemoryRouter([ @@ -32,6 +33,7 @@ const renderComponent = ( workspace={workspace} onRowClick={props.onRowClick ?? vi.fn()} onRequestDelete={props.onRequestDelete} + onCreate={props.onCreate ?? vi.fn()} /> ), }, @@ -83,6 +85,8 @@ describe('GuardrailsDataView', () => { }); it('shows empty state when there are no configs', async () => { + const user = userEvent.setup(); + const onCreate = vi.fn(); server.use( http.get(`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, () => HttpResponse.json({ @@ -97,12 +101,17 @@ describe('GuardrailsDataView', () => { }) ) ); - renderComponent(); + renderComponent({ onCreate }); expect( - await screen.findByText('Manage Guardrail Configs', undefined, { + await screen.findByText('No guardrail configs yet', undefined, { timeout: XL_SELECTOR_TIMEOUT, }) ).toBeInTheDocument(); + const createButton = screen.getByRole('button', { name: 'Create guardrail config' }); + expect(createButton).toBeInTheDocument(); + + await user.click(createButton); + expect(onCreate).toHaveBeenCalledTimes(1); }); it('calls onRequestDelete when the Delete row action is selected', async () => { diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx index cea8380227..b0535b2c88 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx @@ -6,19 +6,19 @@ import { ROW_ACTIONS_COLUMN_SIZE, StudioDataView, } from '@nemo/common/src/components/DataView/StudioDataView'; +import { EntityEmptyState } from '@nemo/common/src/components/EntityEmptyState'; import { ErrorPanel } from '@nemo/common/src/components/ErrorPanel'; 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 { useGuardrailsListGuardrailConfigs } from '@nemo/sdk/generated/platform/api'; import type { GuardrailConfig, GuardrailsListGuardrailConfigsParams, } from '@nemo/sdk/generated/platform/schema'; -import { Button, Flex, Text } from '@nvidia/foundations-react-core'; +import { Text } from '@nvidia/foundations-react-core'; import { countRails } from '@studio/components/dataViews/GuardrailsDataView/guardrailUtils'; import { keepPreviousData } from '@tanstack/react-query'; -import { Copy, ShieldCheck, Trash } from 'lucide-react'; +import { Copy, Trash } from 'lucide-react'; import { type ComponentProps, type FC, useCallback } from 'react'; export interface GuardrailsDataViewProps { @@ -26,7 +26,8 @@ export interface GuardrailsDataViewProps { onRowClick: (config: GuardrailConfig) => void; onRequestDuplicate?: (config: GuardrailConfig) => void; onRequestDelete?: (config: GuardrailConfig) => void; - emptyStateActions?: React.ReactNode; + /** Opens the create-guardrail flow from the first-use empty state. */ + onCreate?: () => void; } export const GuardrailsDataView: FC = ({ @@ -34,7 +35,7 @@ export const GuardrailsDataView: FC = ({ onRowClick, onRequestDuplicate, onRequestDelete, - emptyStateActions, + onCreate, }) => { const dataViewState = useStudioDataViewState({ defaultSort: [{ id: 'created_at', desc: true }], @@ -61,7 +62,6 @@ export const GuardrailsDataView: FC = ({ ); const pagination = data?.pagination; - const hasSearchOrFilters = !!dataViewState.debouncedSearchBar; const makeColumns: ComponentProps>['makeColumns'] = useCallback( @@ -150,29 +150,16 @@ export const GuardrailsDataView: FC = ({ requestStatus: error ? 'error' : isFetching ? 'loading' : undefined, }, DataViewTableContent: { - renderEmptyState: () => { - if (data?.data?.length === 0 && !isFetching && !hasSearchOrFilters) { - return ( - } - header="Manage Guardrail Configs" - emptyMessage="Create a guardrail configuration to protect your workspace models." - actions={{emptyStateActions}} - /> - ); - } - return ( - - Clear Search - - } + renderEmptyState: ({ hasFiltersApplied, hasSearchApplied }) => + hasFiltersApplied || hasSearchApplied ? ( + - ); - }, + ) : ( + + ), renderErrorState: () => ( { }} onRequestDuplicate={setConfigToDuplicate} onRequestDelete={setConfigToDelete} + onCreate={() => setIsCreateOpen(true)} />