From 450bc18d20c31c1cb3ff3d7ee0f244fbd1dfd632 Mon Sep 17 00:00:00 2001 From: Aaron Hunt Date: Wed, 12 Aug 2026 14:50:00 -0700 Subject: [PATCH 1/8] docs(studio): add ui-design skill with empty-states reference Adds a ui-design agent skill under web/.agents/skills that routes UI changes to standardized references. First reference documents the EntityEmptyState empty-state pattern (ASTD-394). Signed-off-by: Aaron Hunt --- web/.agents/skills/ui-design/SKILL.md | 14 ++ .../ui-design/references/empty-states.md | 143 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 web/.agents/skills/ui-design/SKILL.md create mode 100644 web/.agents/skills/ui-design/references/empty-states.md diff --git a/web/.agents/skills/ui-design/SKILL.md b/web/.agents/skills/ui-design/SKILL.md new file mode 100644 index 0000000000..6dc7f3191a --- /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..6cfd50375d --- /dev/null +++ b/web/.agents/skills/ui-design/references/empty-states.md @@ -0,0 +1,143 @@ +# 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/error +markup. Adding a new empty state means adding a registry entry and pointing a +callsite at it — nothing more. + +> 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 three variants + +Every empty state is exactly one of three governed variants. Never invent a +fourth idiom. + +| Variant | When | Required affordances | +| --- | --- | --- | +| `first-use` | Data source is genuinely empty; user hasn't created anything | icon, heading, subheading, primary create CTA, CLI + agent-prompt rows | +| `no-results` | Items exist but current filters/search match zero | heading naming the mismatch, **"Clear filters"** action; **no** create CTA | +| `error` | Load failed (network/server/timeout) | non-technical heading, **"Try again"** 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`) and the error branch (→ `error`); otherwise `first-use`. + +## 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, `onClick` for imperative + flows. Renders as ` + ) : null + } + /> + + ); + } + + if (variant === 'error') { + return ( + + } + slotHeading="Something went wrong" + slotSubheading="We couldn't load this list. Please try again." + slotFooter={ + onRetry ? ( + + ) : 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) && ( + + {cliCommand && ( + + )} + {skillPrompt && ( + + )} + + )} + + ); +}; + +const Centered: FC<{ children: React.ReactNode; className?: string; testId: string }> = ({ + children, + className, + testId, +}) => ( + + {children} + +); + +/** + * A compact, copy-to-clipboard row: a small label, a monospace snippet, and a + * copy button. Kept out of the StatusMessage footer so the ≤2-action rule for + * empty states holds. + */ +const CopyRow: FC<{ label: string; value: string; copyLabel: string }> = ({ + label, + value, + copyLabel, +}) => { + const toast = useToast(); + const { copyToClipboard } = useCopyToClipboard({ + onSuccess: () => toast.success('Copied to clipboard'), + onError: () => toast.error('Failed to copy to clipboard'), + }); + const handleCopy = useCallback(() => void copyToClipboard(value), [copyToClipboard, value]); + + return ( + + + + {label} + + {value} + + + + ); +}; 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..15c2ce8194 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()} /> ), }, @@ -99,10 +101,11 @@ describe('GuardrailsDataView', () => { ); renderComponent(); expect( - await screen.findByText('Manage Guardrail Configs', undefined, { + await screen.findByText('No guardrail configs yet', undefined, { timeout: XL_SELECTOR_TIMEOUT, }) ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create guardrail config' })).toBeInTheDocument(); }); it('calls onRequestDelete when the Delete row action is selected', async () => { @@ -127,7 +130,9 @@ describe('GuardrailsDataView', () => { ); renderComponent(); expect( - await screen.findByTestId('error-panel', undefined, { timeout: XL_SELECTOR_TIMEOUT }) + await screen.findByTestId('entity-empty-state-error', undefined, { + timeout: XL_SELECTOR_TIMEOUT, + }) ).toBeInTheDocument(); }); }); diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx index cea8380227..d23a462ed2 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx @@ -1,24 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getErrorMessage } from '@nemo/common/src/api/common/utils'; import { ROW_ACTIONS_COLUMN_SIZE, StudioDataView, } from '@nemo/common/src/components/DataView/StudioDataView'; -import { ErrorPanel } from '@nemo/common/src/components/ErrorPanel'; +import { EntityEmptyState } from '@nemo/common/src/components/EntityEmptyState'; 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 +24,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 +33,7 @@ export const GuardrailsDataView: FC = ({ onRowClick, onRequestDuplicate, onRequestDelete, - emptyStateActions, + onCreate, }) => { const dataViewState = useStudioDataViewState({ defaultSort: [{ id: 'created_at', desc: true }], @@ -45,7 +44,7 @@ export const GuardrailsDataView: FC = ({ sortState ? `${sortState.desc ? '-' : ''}${sortState.id}` : 'created_at' ) as GuardrailsListGuardrailConfigsParams['sort']; - const { data, isFetching, error } = useGuardrailsListGuardrailConfigs( + const { data, isFetching, error, refetch } = useGuardrailsListGuardrailConfigs( workspace, { page: dataViewState.pagination.state.pageIndex + 1, @@ -61,7 +60,6 @@ export const GuardrailsDataView: FC = ({ ); const pagination = data?.pagination; - const hasSearchOrFilters = !!dataViewState.debouncedSearchBar; const makeColumns: ComponentProps>['makeColumns'] = useCallback( @@ -150,36 +148,17 @@ 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 - - } - /> - ); - }, - renderErrorState: () => ( - ( + ), + renderErrorState: () => ( + void refetch()} /> + ), }, }} /> diff --git a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx index 46fdf80c2d..078d81db61 100644 --- a/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx +++ b/web/packages/studio/src/routes/guardrails/GuardrailsRoute/index.tsx @@ -81,6 +81,7 @@ export const GuardrailsRoute: FC = () => { }} onRequestDuplicate={setConfigToDuplicate} onRequestDelete={setConfigToDelete} + onCreate={() => setIsCreateOpen(true)} /> From 0ae53a4cb108504735d2f9aef73cc2efb8f0249b Mon Sep 17 00:00:00 2001 From: Aaron Hunt Date: Wed, 12 Aug 2026 15:31:56 -0700 Subject: [PATCH 3/8] refactor(studio): use CodeSnippet + SegmentedControl for empty-state help Replace the custom copy rows in EntityEmptyState with a single KUI CodeSnippet (built-in copy) whose slotActions hosts a tiny SegmentedControl toggling between the NeMo CLI command and the Ask an Agent prompt. Update the ui-design empty-states reference to match. Signed-off-by: Aaron Hunt --- .../ui-design/references/empty-states.md | 13 +-- .../EntityEmptyState.test.tsx | 26 +++--- .../src/components/EntityEmptyState/index.tsx | 83 +++++++++++-------- 3 files changed, 71 insertions(+), 51 deletions(-) diff --git a/web/.agents/skills/ui-design/references/empty-states.md b/web/.agents/skills/ui-design/references/empty-states.md index 6cfd50375d..c051aca694 100644 --- a/web/.agents/skills/ui-design/references/empty-states.md +++ b/web/.agents/skills/ui-design/references/empty-states.md @@ -31,7 +31,7 @@ fourth idiom. | Variant | When | Required affordances | | --- | --- | --- | -| `first-use` | Data source is genuinely empty; user hasn't created anything | icon, heading, subheading, primary create CTA, CLI + agent-prompt rows | +| `first-use` | Data source is genuinely empty; user hasn't created anything | icon, heading, subheading, primary create CTA, and the "NeMo CLI · Ask an Agent" self-service snippet | | `no-results` | Items exist but current filters/search match zero | heading naming the mismatch, **"Clear filters"** action; **no** create CTA | | `error` | Load failed (network/server/timeout) | non-technical heading, **"Try again"** action; no create CTA | @@ -72,7 +72,8 @@ Field rules: CLI equivalent. - `skillPrompt` — copy-to-clipboard string that triggers the entity's skill. **Not wired to Copilot** (deferred by ticket decision 1) — it is stored for a - future integration and surfaced as a copy row today. Omit if no skill exists. + future integration and surfaced under the "Ask an Agent" snippet toggle today. + Omit if no skill exists. ### 2. Wire the callsite @@ -114,9 +115,11 @@ as a fallback. - Headings are sentence case and name the entity. CTAs are verb + noun ("Create fileset", not "Get started"). -- At most **2 buttons** in the footer (Kaizen empty-state rule). The CLI and - agent-prompt rows are copy-to-clipboard rows **below** the footer, not - buttons — they do not count against the 2-action limit. +- At most **2 buttons** in the footer (Kaizen empty-state rule). The CLI command + and agent prompt live **below** the footer in a single KUI `CodeSnippet` + (with its built-in copy button); a tiny `SegmentedControl` in the snippet's + `slotActions` toggles between **NeMo CLI** and **Ask an Agent**. This is not a + footer button and does not count against the 2-action limit. - CLI commands must match the shipping `nemo` CLI. Verify against the relevant plugin skill (`nemo files`, `nemo models`, `nemo guardrail`, `nemo secrets`, …) before committing. Because commands are centralized in the registry, a CLI diff --git a/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx b/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx index 7fab2900da..36fc1ac425 100644 --- a/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx +++ b/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx @@ -24,13 +24,25 @@ describe('EntityEmptyState', () => { const descriptor = ENTITY_EMPTY_STATES.guardrails; describe('first-use', () => { - it('renders registry heading, subheading, and the CLI / prompt copy rows', () => { + it('renders the registry heading and subheading', () => { wrap(); expect(screen.getByText(descriptor.heading)).toBeInTheDocument(); expect(screen.getByText(descriptor.subheading)).toBeInTheDocument(); - expect(screen.getByText(descriptor.cliCommand as string)).toBeInTheDocument(); - expect(screen.getByText(descriptor.skillPrompt as string)).toBeInTheDocument(); + }); + + it('toggles between the NeMo CLI command and the agent prompt', async () => { + const user = userEvent.setup(); + wrap(); + + const help = screen.getByTestId('entity-empty-state-help'); + // CLI is the default selection. + expect(help).toHaveTextContent(descriptor.cliCommand as string); + expect(help).not.toHaveTextContent(descriptor.skillPrompt as string); + + await user.click(screen.getByRole('radio', { name: 'Ask an Agent' })); + expect(help).toHaveTextContent(descriptor.skillPrompt as string); + expect(help).not.toHaveTextContent(descriptor.cliCommand as string); }); it('invokes onCreate from the primary CTA', async () => { @@ -50,14 +62,6 @@ describe('EntityEmptyState', () => { screen.queryByRole('button', { name: descriptor.createAction?.label }) ).not.toBeInTheDocument(); }); - - it('copies the CLI command to the clipboard', async () => { - const user = userEvent.setup(); - wrap(); - - await user.click(screen.getByRole('button', { name: 'Copy CLI command' })); - expect(await navigator.clipboard.readText()).toBe(descriptor.cliCommand); - }); }); describe('no-results', () => { diff --git a/web/packages/common/src/components/EntityEmptyState/index.tsx b/web/packages/common/src/components/EntityEmptyState/index.tsx index b8a7572501..1d8bdf811a 100644 --- a/web/packages/common/src/components/EntityEmptyState/index.tsx +++ b/web/packages/common/src/components/EntityEmptyState/index.tsx @@ -5,11 +5,17 @@ import { ENTITY_EMPTY_STATES, type EntityKey, } from '@nemo/common/src/components/EntityEmptyState/registry'; -import { useCopyToClipboard } from '@nemo/common/src/hooks/useCopyToClipboard'; import { useToast } from '@nemo/common/src/providers/toast/useToast'; -import { Button, Flex, StatusMessage, Stack, Text } from '@nvidia/foundations-react-core'; -import { Copy, TriangleAlert } from 'lucide-react'; -import { type FC, useCallback } from 'react'; +import { + Button, + CodeSnippet, + type CodeSnippetLanguage, + Flex, + SegmentedControl, + StatusMessage, +} from '@nvidia/foundations-react-core'; +import { TriangleAlert } from 'lucide-react'; +import { type FC, useState } from 'react'; import { useNavigate } from 'react-router'; /** The three governed empty-state variants. */ @@ -104,14 +110,7 @@ export const EntityEmptyState: FC = ({ } /> {(cliCommand || skillPrompt) && ( - - {cliCommand && ( - - )} - {skillPrompt && ( - - )} - + )} ); @@ -133,34 +132,48 @@ const Centered: FC<{ children: React.ReactNode; className?: string; testId: stri ); +/** Self-service help kind. */ +type HelpKind = 'cli' | 'agent'; + /** - * A compact, copy-to-clipboard row: a small label, a monospace snippet, and a - * copy button. Kept out of the StatusMessage footer so the ≤2-action rule for - * empty states holds. + * A compact "NeMo CLI · Ask an Agent" disclosure: a KUI CodeSnippet (with its + * built-in copy affordance) whose action row hosts a tiny SegmentedControl to + * switch between the CLI command and the agent prompt. Kept out of the + * StatusMessage footer so the ≤2-action rule for empty states holds. */ -const CopyRow: FC<{ label: string; value: string; copyLabel: string }> = ({ - label, - value, - copyLabel, +const SelfServiceHelp: FC<{ cliCommand?: string; skillPrompt?: string }> = ({ + cliCommand, + skillPrompt, }) => { const toast = useToast(); - const { copyToClipboard } = useCopyToClipboard({ - onSuccess: () => toast.success('Copied to clipboard'), - onError: () => toast.error('Failed to copy to clipboard'), - }); - const handleCopy = useCallback(() => void copyToClipboard(value), [copyToClipboard, value]); + const [kind, setKind] = useState(cliCommand ? 'cli' : 'agent'); + + const items: { value: HelpKind; children: string }[] = []; + if (cliCommand) items.push({ value: 'cli', children: 'NeMo CLI' }); + if (skillPrompt) items.push({ value: 'agent', children: 'Ask an Agent' }); + + const showCli = kind === 'cli' && !!cliCommand; + const value = showCli ? (cliCommand as string) : (skillPrompt ?? cliCommand ?? ''); + const language: CodeSnippetLanguage = showCli ? 'bash' : 'markdown'; return ( - - - - {label} - - {value} - - - +
+ toast.success('Copied to clipboard')} + slotActions={ + items.length > 1 ? ( + setKind(next as HelpKind)} + items={items} + /> + ) : undefined + } + /> +
); }; From 97bf3a557944ee13efca0d5500f4316e7e80c9b4 Mon Sep 17 00:00:00 2001 From: Aaron Hunt Date: Wed, 12 Aug 2026 15:52:45 -0700 Subject: [PATCH 4/8] refactor(studio): left-align empty-state help toggle above snippet Wrap the SegmentedControl in a full-width Flex inside the CodeSnippet slotActions so the nemo CLI / Ask an Agent toggle left-aligns above the command, size it tiny, and align the ui-design reference label casing. Signed-off-by: Aaron Hunt --- .../ui-design/references/empty-states.md | 4 +-- .../src/components/EntityEmptyState/index.tsx | 32 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/web/.agents/skills/ui-design/references/empty-states.md b/web/.agents/skills/ui-design/references/empty-states.md index c051aca694..322393d243 100644 --- a/web/.agents/skills/ui-design/references/empty-states.md +++ b/web/.agents/skills/ui-design/references/empty-states.md @@ -31,7 +31,7 @@ fourth 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 "NeMo CLI · Ask an Agent" self-service snippet | +| `first-use` | Data source is genuinely empty; user hasn't created anything | icon, heading, subheading, primary create CTA, and the "nemo CLI · Ask an Agent" self-service snippet | | `no-results` | Items exist but current filters/search match zero | heading naming the mismatch, **"Clear filters"** action; **no** create CTA | | `error` | Load failed (network/server/timeout) | non-technical heading, **"Try again"** action; no create CTA | @@ -118,7 +118,7 @@ as a fallback. - At most **2 buttons** in the footer (Kaizen empty-state rule). The CLI command and agent prompt live **below** the footer in a single KUI `CodeSnippet` (with its built-in copy button); a tiny `SegmentedControl` in the snippet's - `slotActions` toggles between **NeMo CLI** and **Ask an Agent**. This is not a + `slotActions` toggles between **nemo CLI** and **Ask an Agent**. This is not a footer button and does not count against the 2-action limit. - CLI commands must match the shipping `nemo` CLI. Verify against the relevant plugin skill (`nemo files`, `nemo models`, `nemo guardrail`, `nemo secrets`, diff --git a/web/packages/common/src/components/EntityEmptyState/index.tsx b/web/packages/common/src/components/EntityEmptyState/index.tsx index 1d8bdf811a..a8b95d33bd 100644 --- a/web/packages/common/src/components/EntityEmptyState/index.tsx +++ b/web/packages/common/src/components/EntityEmptyState/index.tsx @@ -110,7 +110,9 @@ export const EntityEmptyState: FC = ({ } /> {(cliCommand || skillPrompt) && ( - +
+ +
)} ); @@ -148,16 +150,21 @@ const SelfServiceHelp: FC<{ cliCommand?: string; skillPrompt?: string }> = ({ const toast = useToast(); const [kind, setKind] = useState(cliCommand ? 'cli' : 'agent'); - const items: { value: HelpKind; children: string }[] = []; - if (cliCommand) items.push({ value: 'cli', children: 'NeMo CLI' }); - if (skillPrompt) items.push({ value: 'agent', children: 'Ask an Agent' }); + const items: { value: HelpKind; children: React.ReactNode }[] = []; + if (cliCommand) + items.push({ value: 'cli', children: "nemo CLI" }); + if (skillPrompt) + items.push({ + value: 'agent', + children: "Ask an Agent", + }); const showCli = kind === 'cli' && !!cliCommand; const value = showCli ? (cliCommand as string) : (skillPrompt ?? cliCommand ?? ''); const language: CodeSnippetLanguage = showCli ? 'bash' : 'markdown'; return ( -
+
= ({ onCopySuccess={() => toast.success('Copied to clipboard')} slotActions={ items.length > 1 ? ( - setKind(next as HelpKind)} - items={items} - /> + + setKind(next as HelpKind)} + items={items} + /> + ) : undefined } /> From caee2217421b3b16095dcf80c66c44180c623683 Mon Sep 17 00:00:00 2001 From: Aaron Hunt Date: Wed, 12 Aug 2026 16:16:09 -0700 Subject: [PATCH 5/8] refactor(studio): keep empty-state error branch on ErrorPanel EntityEmptyState now covers only first-use and no-results; the error variant is removed so failed loads keep routing through ErrorPanel with getErrorMessage(error), surfacing the real failure instead of hardcoded generic copy. Updates the GuardrailsDataView error branch, tests, and the ui-design empty-states reference to match. Signed-off-by: Aaron Hunt --- web/.agents/skills/ui-design/SKILL.md | 4 +- .../ui-design/references/empty-states.md | 49 ++++++++++--------- .../EntityEmptyState.test.tsx | 15 +----- .../src/components/EntityEmptyState/index.tsx | 32 ++---------- .../GuardrailsDataView.test.tsx | 4 +- .../dataViews/GuardrailsDataView/index.tsx | 10 +++- 6 files changed, 43 insertions(+), 71 deletions(-) diff --git a/web/.agents/skills/ui-design/SKILL.md b/web/.agents/skills/ui-design/SKILL.md index 6dc7f3191a..499d6b4f19 100644 --- a/web/.agents/skills/ui-design/SKILL.md +++ b/web/.agents/skills/ui-design/SKILL.md @@ -9,6 +9,6 @@ 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 | -| --- | --- | +| 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 index 322393d243..aa30f5a5e3 100644 --- a/web/.agents/skills/ui-design/references/empty-states.md +++ b/web/.agents/skills/ui-design/references/empty-states.md @@ -1,10 +1,12 @@ # Studio Empty States -Studio renders every empty state through **one** primitive: `EntityEmptyState` +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/error -markup. Adding a new empty state means adding a registry entry and pointing a -callsite at it — nothing more. +`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`, @@ -24,20 +26,20 @@ Canonical locations: - Registry: `packages/common/src/components/EntityEmptyState/registry.ts` (`ENTITY_EMPTY_STATES: Record`) -## The three variants +## The two variants -Every empty state is exactly one of three governed variants. Never invent a -fourth idiom. +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 "nemo CLI · Ask an Agent" self-service snippet | -| `no-results` | Items exist but current filters/search match zero | heading naming the mismatch, **"Clear filters"** action; **no** create CTA | -| `error` | Load failed (network/server/timeout) | non-technical heading, **"Try again"** action; no create CTA | +| Variant | When | Required affordances | +| ------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `first-use` | Data source is genuinely empty; user hasn't created anything | icon, heading, subheading, primary create CTA, and the "nemo CLI · Ask an Agent" 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`) and the error branch (→ `error`); otherwise `first-use`. +(→ `no-results`); otherwise `first-use`. The error branch is handled separately +by `ErrorPanel`, not by a variant. ## Adding a new empty state @@ -87,7 +89,9 @@ renderEmptyState={({ hasFiltersApplied, hasSearchApplied }) => ( variant={hasFiltersApplied || hasSearchApplied ? 'no-results' : 'first-use'} /> )} -renderErrorState={() => } +renderErrorState={() => ( + +)} ``` Prefer the DataView/ScrollTable **defaults**: if the shared default already @@ -106,10 +110,10 @@ do not replace the animation. ### 3. Delete the old markup -When migrating a callsite: remove any hand-rolled `StatusMessage`, local -first-use/no-results `if` branching, and raw error empty-state markup. Route -the error branch through the `error` variant. Do not leave the old path behind -as a fallback. +When migrating a callsite: remove any hand-rolled `StatusMessage` and local +first-use/no-results `if` branching. Route the error branch through `ErrorPanel` +with `getErrorMessage(error)`. Do not leave the old empty-state path behind as a +fallback. ## Copy & CLI accuracy @@ -128,11 +132,12 @@ as a fallback. ## Verify - `pnpm --filter @nemo/common test` — the `EntityEmptyState` unit tests cover - the three variants and the CLI/prompt copy affordances. Add a case if you - introduced a new descriptor shape or affordance, not for a plain new entry. + the `first-use` and `no-results` variants and the CLI/prompt copy affordances. + Add a case if you introduced a new descriptor shape or affordance, not for a + plain new entry. - `pnpm --filter nemo-studio-ui test` for migrated studio callsites. -- Storybook: check `first-use`, `no-results`, and `error` render for a - representative entity. +- Storybook: check `first-use` and `no-results` render for a representative + entity. ## Do / Don't diff --git a/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx b/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx index 36fc1ac425..a6bc0614f8 100644 --- a/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx +++ b/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.test.tsx @@ -49,9 +49,7 @@ describe('EntityEmptyState', () => { const onCreate = vi.fn(); wrap(); - await userEvent.click( - screen.getByRole('button', { name: descriptor.createAction?.label }) - ); + await userEvent.click(screen.getByRole('button', { name: descriptor.createAction?.label })); expect(onCreate).toHaveBeenCalledTimes(1); }); @@ -78,15 +76,4 @@ describe('EntityEmptyState', () => { expect(onClearFilters).toHaveBeenCalledTimes(1); }); }); - - describe('error', () => { - it('shows the retry action', async () => { - const onRetry = vi.fn(); - wrap(); - - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'Try again' })); - expect(onRetry).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/web/packages/common/src/components/EntityEmptyState/index.tsx b/web/packages/common/src/components/EntityEmptyState/index.tsx index a8b95d33bd..6cde2ad005 100644 --- a/web/packages/common/src/components/EntityEmptyState/index.tsx +++ b/web/packages/common/src/components/EntityEmptyState/index.tsx @@ -14,12 +14,11 @@ import { SegmentedControl, StatusMessage, } from '@nvidia/foundations-react-core'; -import { TriangleAlert } from 'lucide-react'; import { type FC, useState } from 'react'; import { useNavigate } from 'react-router'; -/** The three governed empty-state variants. */ -export type EntityEmptyStateVariant = 'first-use' | 'no-results' | 'error'; +/** The governed empty-state variants. Errors are handled separately by `ErrorPanel`. */ +export type EntityEmptyStateVariant = 'first-use' | 'no-results'; export interface EntityEmptyStateProps { entity: EntityKey; @@ -32,8 +31,6 @@ export interface EntityEmptyStateProps { onCreate?: () => void; /** Clears the active filters/search. `no-results` only. */ onClearFilters?: () => void; - /** Re-runs the failed request. `error` only. */ - onRetry?: () => void; className?: string; } @@ -48,7 +45,6 @@ export const EntityEmptyState: FC = ({ variant, onCreate, onClearFilters, - onRetry, className, }) => { const descriptor = ENTITY_EMPTY_STATES[entity]; @@ -72,25 +68,6 @@ export const EntityEmptyState: FC = ({ ); } - if (variant === 'error') { - return ( - - } - slotHeading="Something went wrong" - slotSubheading="We couldn't load this list. Please try again." - slotFooter={ - onRetry ? ( - - ) : null - } - /> - - ); - } - const { icon: Icon, heading, subheading, createAction, cliCommand, skillPrompt } = descriptor; const handleCreate = onCreate ?? (createAction?.to ? () => navigate(createAction.to as string) : undefined); @@ -151,12 +128,11 @@ const SelfServiceHelp: FC<{ cliCommand?: string; skillPrompt?: string }> = ({ const [kind, setKind] = useState(cliCommand ? 'cli' : 'agent'); const items: { value: HelpKind; children: React.ReactNode }[] = []; - if (cliCommand) - items.push({ value: 'cli', children: "nemo CLI" }); + if (cliCommand) items.push({ value: 'cli', children: 'nemo CLI' }); if (skillPrompt) items.push({ value: 'agent', - children: "Ask an Agent", + children: 'Ask an Agent', }); const showCli = kind === 'cli' && !!cliCommand; 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 15c2ce8194..c9933bb05c 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/GuardrailsDataView.test.tsx @@ -130,9 +130,7 @@ describe('GuardrailsDataView', () => { ); renderComponent(); expect( - await screen.findByTestId('entity-empty-state-error', undefined, { - timeout: XL_SELECTOR_TIMEOUT, - }) + await screen.findByTestId('error-panel', undefined, { timeout: XL_SELECTOR_TIMEOUT }) ).toBeInTheDocument(); }); }); diff --git a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx index d23a462ed2..ad32594c1c 100644 --- a/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/GuardrailsDataView/index.tsx @@ -1,11 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { getErrorMessage } from '@nemo/common/src/api/common/utils'; 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 { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; import { useGuardrailsListGuardrailConfigs } from '@nemo/sdk/generated/platform/api'; @@ -44,7 +46,7 @@ export const GuardrailsDataView: FC = ({ sortState ? `${sortState.desc ? '-' : ''}${sortState.id}` : 'created_at' ) as GuardrailsListGuardrailConfigsParams['sort']; - const { data, isFetching, error, refetch } = useGuardrailsListGuardrailConfigs( + const { data, isFetching, error } = useGuardrailsListGuardrailConfigs( workspace, { page: dataViewState.pagination.state.pageIndex + 1, @@ -157,7 +159,11 @@ export const GuardrailsDataView: FC = ({ /> ), renderErrorState: () => ( - void refetch()} /> + ), }, }} From 25e969ae1a56f94b171b68cd16eb5f61f2e586b5 Mon Sep 17 00:00:00 2001 From: Aaron Hunt Date: Wed, 12 Aug 2026 16:24:04 -0700 Subject: [PATCH 6/8] test(studio): add EntityEmptyState Storybook stories Adds first-use and no-results stories for EntityEmptyState so the two governed empty-state variants render in isolation for visual review. Signed-off-by: Aaron Hunt --- .../EntityEmptyState.stories.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 web/packages/common/src/components/EntityEmptyState/EntityEmptyState.stories.tsx diff --git a/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.stories.tsx b/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.stories.tsx new file mode 100644 index 0000000000..e87aaf75a0 --- /dev/null +++ b/web/packages/common/src/components/EntityEmptyState/EntityEmptyState.stories.tsx @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EntityEmptyState } from '@nemo/common/src/components/EntityEmptyState'; +import { ToastProvider } from '@nemo/common/src/providers/toast/ToastProvider'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Common/EntityEmptyState', + component: EntityEmptyState, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const FirstUse: Story = { + args: { entity: 'guardrails', variant: 'first-use', onCreate: () => {} }, +}; + +export const NoResults: Story = { + args: { entity: 'guardrails', variant: 'no-results', onClearFilters: () => {} }, +}; From f02b325462e622f8c71681da1d217c12f67ae007 Mon Sep 17 00:00:00 2001 From: Aaron Hunt Date: Thu, 13 Aug 2026 10:10:41 -0700 Subject: [PATCH 7/8] fix(studio): address CodeRabbit feedback and relabel self-service help tabs - Discriminated EntityEmptyStateProps by variant so no-results requires onClearFilters - Branch GuardrailsDataView's EntityEmptyState usage by variant to match - Use type-only React import in EntityEmptyState.test.tsx - Correct empty-states.md createAction guidance (onCreate, not onClick) - Assert onCreate is invoked from the empty-state CTA in GuardrailsDataView.test.tsx - Swap self-service help tab order to Ask an agent, CLI and rename labels ('nemo CLI' -> 'CLI', 'Ask an Agent' -> 'Ask an agent') Signed-off-by: Aaron Hunt --- .../ui-design/references/empty-states.md | 18 +++++---- .../EntityEmptyState.test.tsx | 19 ++++++--- .../src/components/EntityEmptyState/index.tsx | 40 ++++++++++++------- .../GuardrailsDataView.test.tsx | 10 ++++- .../dataViews/GuardrailsDataView/index.tsx | 18 +++++---- 5 files changed, 66 insertions(+), 39 deletions(-) diff --git a/web/.agents/skills/ui-design/references/empty-states.md b/web/.agents/skills/ui-design/references/empty-states.md index aa30f5a5e3..e4e88c809e 100644 --- a/web/.agents/skills/ui-design/references/empty-states.md +++ b/web/.agents/skills/ui-design/references/empty-states.md @@ -31,10 +31,10 @@ Canonical locations: 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 "nemo CLI · Ask an Agent" self-service snippet | -| `no-results` | Items exist but current filters/search match zero | heading naming the mismatch, **"Clear filters"** action; **no** create CTA | +| 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` @@ -67,14 +67,16 @@ Field rules: "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, `onClick` for imperative - flows. Renders as `