Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ export function ChatCompletionInput<TFieldValues extends FieldValues = FieldValu
SelectTrigger: {
className: cn(
'border-none flex h-8 min-h-0 max-h-8 items-center p-0 shadow-none bg-transparent',
// The inner trigger button carries foundations' own 12px inline-start
// padding; zero it so the role badge left-aligns with the message body.
'[&_button]:!pl-0',
'data-[state=open]:shadow-none'
),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { GuardrailCheckMessage } from '@studio/api/guardrail-checks/types';

/** Coerce a message's `content` (string or content-part array) to plain display text. */
const textFromContent = (content: unknown): string => {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === 'object' && 'text' in part
? String((part as { text?: unknown }).text ?? '')
: ''
)
.join(' ')
.trim();
}
return '';
};

/** First user message text — the check's "Input" (single-turn). */
export const getCheckInputText = (messages: GuardrailCheckMessage[]): string => {
const userMsg = messages.find((m) => m.role === 'user');
return userMsg ? textFromContent(userMsg.content) : '';
};

/** First assistant message text — the check's "Output" (single-turn). */
export const getCheckOutputText = (messages: GuardrailCheckMessage[]): string => {
const assistantMsg = messages.find((m) => m.role === 'assistant');
return assistantMsg ? textFromContent(assistantMsg.content) : '';
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { GuardrailCheckEntity, Verdict } from '@studio/api/guardrail-checks/types';

/**
* Overall status of a check's most recent run (the `status` returned by the
* /checks endpoint). `undefined` means the check has never been run.
*/
export const getLatestRunStatus = (check: GuardrailCheckEntity): Verdict | undefined => {
const { runs } = check.data;
return runs.length ? runs[runs.length - 1].status : undefined;
};
1 change: 1 addition & 0 deletions web/packages/studio/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const ROUTES = {
optimizerInsight: `/workspaces/:${P.workspace}/optimizer/:${P.insightId}`,
guardrailDetail: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}`,
guardrailConfig: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}/config`,
guardrailChecks: `/workspaces/:${P.workspace}/guardrails/:${P.guardrailConfigName}/checks`,
settings: `/workspaces/:${P.workspace}/settings`,
/** Workspace members and role-based access (Entities role bindings) */
members: `/workspaces/:${P.workspace}/members`,
Expand Down
61 changes: 61 additions & 0 deletions web/packages/studio/src/mocks/handlers/guardrails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
// SPDX-License-Identifier: Apache-2.0

import type { GuardrailConfig } from '@nemo/sdk/generated/platform/schema';
import {
GUARDRAIL_CHECKS_ENTITY_TYPE,
type GuardrailCheckEntity,
} from '@studio/api/guardrail-checks/types';
import { PLATFORM_BASE_URL } from '@studio/constants/environment';
import { http, HttpResponse } from 'msw';

Expand Down Expand Up @@ -51,7 +55,64 @@ export const mockGuardrailConfigs: GuardrailConfig[] = [
},
];

/** Guardrail checks are entity-store children of a config; these hang off `cfg-1` (pii-filter). */
export const mockGuardrailChecks: GuardrailCheckEntity[] = [
{
entity_type: GUARDRAIL_CHECKS_ENTITY_TYPE,
id: 'chk-1',
parent: 'cfg-1',
db_version: 1,
name: 'leaks-ssn',
workspace: 'default',
created_at: '2026-04-12T11:00:00.000Z',
created_by: 'user@example.com',
updated_at: '2026-04-12T11:00:00.000Z',
updated_by: 'user@example.com',
data: {
messages: [{ role: 'user', content: 'My SSN is 123-45-6789' }],
runs: [
{
run_at: '2026-04-12T11:05:00.000Z',
status: 'blocked',
rails_status: { 'check pii': { status: 'blocked' } },
config_version: 1,
},
],
},
},
{
entity_type: GUARDRAIL_CHECKS_ENTITY_TYPE,
id: 'chk-2',
parent: 'cfg-1',
db_version: 1,
name: 'benign-greeting',
workspace: 'default',
created_at: '2026-04-12T11:00:00.000Z',
created_by: 'user@example.com',
updated_at: '2026-04-12T11:00:00.000Z',
updated_by: 'user@example.com',
data: {
messages: [{ role: 'user', content: 'Hello there' }],
runs: [],
},
},
];

export const guardrailsHandlers = [
http.get(
`${PLATFORM_BASE_URL}/apis/entities/v2/workspaces/:workspace/entities/${GUARDRAIL_CHECKS_ENTITY_TYPE}`,
() =>
HttpResponse.json({
data: mockGuardrailChecks,
pagination: {
page: 1,
page_size: 1000,
current_page_size: mockGuardrailChecks.length,
total_pages: 1,
total_results: mockGuardrailChecks.length,
},
})
),
Comment thread
nakolean marked this conversation as resolved.
http.get(`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/configs`, () =>
HttpResponse.json({
data: mockGuardrailConfigs,
Expand Down
10 changes: 10 additions & 0 deletions web/packages/studio/src/routes/groups/guardrailsRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const GuardrailConfigTab = lazy(() =>
}))
);

const GuardrailChecksTab = lazy(() =>
import('@studio/routes/guardrails/GuardrailChecksTab').then((m) => ({
default: m.GuardrailChecksTab,
}))
);

export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([
{
path: ROUTES.workspace.guardrails,
Expand All @@ -44,6 +50,10 @@ export const guardrailsRoutes: RouteObject[] = gateGuardrailsRoutes([
path: ROUTES.workspace.guardrailConfig,
element: <GuardrailConfigTab />,
},
{
path: ROUTES.workspace.guardrailChecks,
element: <GuardrailChecksTab />,
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect';
import { Button, Flex, Stack, Text, Tooltip } from '@nvidia/foundations-react-core';
import type { GuardrailCheckMessage } from '@studio/api/guardrail-checks/types';
import cn from 'classnames';
import { ArrowDown, ArrowUp, Copy, type LucideIcon, Trash2 } from 'lucide-react';
import { type FC, useRef } from 'react';
import { type Control, type Path, useController } from 'react-hook-form';

/** RHF row shape for a single guardrail-check message. No collapse state — the body is always shown. */
export interface GuardrailMessageFormRow {
role: GuardrailCheckMessage['role'];
content: string;
}

export interface GuardrailCheckFormValues {
messages: GuardrailMessageFormRow[];
}

/** Roles offered in the row's role dropdown (subset of the chat-completion roles). */
const ROLE_ITEMS: { value: string; children: string }[] = [
{ value: 'user', children: 'user' },
{ value: 'assistant', children: 'assistant' },
{ value: 'system', children: 'system' },
];

/** Plain-text role label (mock shows e.g. "user ⌄", not a colored badge). */
const renderRoleValue = (value: string) => <Text kind="label/bold/sm">{value}</Text>;

/** A single icon action in the row header. */
const RowIconButton: FC<{
label: string;
icon: LucideIcon;
onClick: () => void;
disabled?: boolean;
}> = ({ label, icon: Icon, onClick, disabled }) => (
<Tooltip slotContent={label} side="top">
<Button
type="button"
size="tiny"
kind="tertiary"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<Icon className="size-3.5" aria-hidden />
</Button>
</Tooltip>
);

export interface GuardrailMessageRowProps {
control: Control<GuardrailCheckFormValues>;
/** Path to this message row in the form, e.g. `messages.0`. */
name: string;
onMoveUp?: () => void;
onMoveDown?: () => void;
onDuplicate: () => void;
onRemove: () => void;
/** When false, the delete action is disabled (never remove the last message). */
allowRemove: boolean;
dataTestId?: string;
}

/**
* One guardrail-check message: a role dropdown + an always-visible, manually resizable
* text body, with reorder / duplicate / delete actions. Purpose-built for the guardrails
* Tests tab (replaces the shared ChatCompletionInput).
*
* The row is a single bordered card with a native textarea — no nested input shell — so the
* body has exactly one border (which highlights on focus) rather than a doubled one.
*/
export const GuardrailMessageRow: FC<GuardrailMessageRowProps> = ({
control,
name,
onMoveUp,
onMoveDown,
onDuplicate,
onRemove,
allowRemove,
dataTestId,
}) => {
const rolePath = `${name}.role` as Path<GuardrailCheckFormValues>;
const contentPath = `${name}.content` as Path<GuardrailCheckFormValues>;
const { field: content } = useController({ control, name: contentPath });
const bodyRef = useRef<HTMLTextAreaElement>(null);

// After a role is chosen the Select closes and restores focus to its trigger at an unknown tick.
// Re-focus the message body across a short window so it wins whenever that restoration fires,
// keeping the user in the typing flow.
const focusBody = () => {
let ticks = 0;
const id = setInterval(() => {
bodyRef.current?.focus();
if (++ticks >= 8) clearInterval(id);
}, 25);
};
Comment thread
nakolean marked this conversation as resolved.

return (
<Stack
gap="density-sm"
className={cn(
'rounded-md border border-interaction-base bg-surface-base px-density-md py-density-sm',
'transition-[border-color] focus-within:border-[var(--border-color-brand)]'
)}
{...(dataTestId ? { 'data-testid': dataTestId } : {})}
>
<Flex align="center" justify="between" gap="density-sm">
{/* Role dropdown: plain text + chevron, sized to the selected role (not the widest). */}
<div className="w-max shrink-0">
<ControlledSelect
useControllerProps={{ control, name: rolePath }}
items={ROLE_ITEMS}
renderValue={renderRoleValue}
onChange={focusBody}
hideError
attributes={{
SelectTrigger: {
className: cn(
'w-max border-none bg-transparent shadow-none items-center gap-density-xs',
'h-6 min-h-0 max-h-6 p-0 [&_button]:!px-0 [&_*]:!min-w-max',
'data-[state=open]:shadow-none'
),
},
// Menu width is decoupled from the (content-hugging) trigger so item labels aren't clipped.
SelectContent: { className: 'min-w-[8rem]' },
}}
/>
</div>

<Flex align="center" gap="density-xs" className="shrink-0 text-text-secondary">
{onMoveUp ? <RowIconButton label="Move up" icon={ArrowUp} onClick={onMoveUp} /> : null}
{onMoveDown ? (
<RowIconButton label="Move down" icon={ArrowDown} onClick={onMoveDown} />
) : null}
<RowIconButton label="Duplicate message" icon={Copy} onClick={onDuplicate} />
<RowIconButton
label="Delete message"
icon={Trash2}
onClick={onRemove}
disabled={!allowRemove}
/>
</Flex>
</Flex>

<textarea
name={content.name}
ref={(el) => {
content.ref(el);
bodyRef.current = el;
}}
value={typeof content.value === 'string' ? content.value : ''}
onChange={content.onChange}
onBlur={content.onBlur}
placeholder="Type your message..."
aria-label="Message content"
data-testid="guardrail-check-message-content"
className={cn(
'min-h-[3.5rem] w-full resize-y border-none bg-transparent p-0 outline-none',
'text-sm leading-normal text-text-primary placeholder:text-muted focus:outline-none'
)}
/>
</Stack>
);
};
Loading