feat(nx-dev): add "new chat" button to AI page (#19150)
This commit is contained in:
parent
7fc0edacef
commit
94f71cdbbf
@ -1,9 +1,10 @@
|
|||||||
|
import { type JSX, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
XCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
export function ErrorMessage({ error }: { error: any }): JSX.Element {
|
function ErrorMessage({ error }: { error: any }): JSX.Element {
|
||||||
try {
|
try {
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
error = JSON.parse(error.message);
|
error = JSON.parse(error.message);
|
||||||
@ -57,3 +58,6 @@ export function ErrorMessage({ error }: { error: any }): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MemoErrorMessage = memo(ErrorMessage);
|
||||||
|
export { MemoErrorMessage as ErrorMessage };
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
import { sendCustomEvent } from '@nx/nx-dev/feature-analytics';
|
import { sendCustomEvent } from '@nx/nx-dev/feature-analytics';
|
||||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
type FormEvent,
|
||||||
|
type JSX,
|
||||||
|
RefObject,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { ErrorMessage } from './error-message';
|
import { ErrorMessage } from './error-message';
|
||||||
import { Feed } from './feed/feed';
|
import { Feed } from './feed/feed';
|
||||||
import { LoadingState } from './loading-state';
|
import { LoadingState } from './loading-state';
|
||||||
import { Prompt } from './prompt';
|
import { Prompt } from './prompt';
|
||||||
import { getQueryFromUid, storeQueryForUid } from '@nx/nx-dev/util-ai';
|
import { getQueryFromUid, storeQueryForUid } from '@nx/nx-dev/util-ai';
|
||||||
import { Message, useChat } from 'ai/react';
|
import { Message, useChat } from 'ai/react';
|
||||||
|
import { cx } from '@nx/nx-dev/ui-primitives';
|
||||||
|
|
||||||
const assistantWelcome: Message = {
|
const assistantWelcome: Message = {
|
||||||
id: 'first-custom-message',
|
id: 'first-custom-message',
|
||||||
@ -17,26 +26,38 @@ const assistantWelcome: Message = {
|
|||||||
export function FeedContainer(): JSX.Element {
|
export function FeedContainer(): JSX.Element {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [startedReply, setStartedReply] = useState(false);
|
const [startedReply, setStartedReply] = useState(false);
|
||||||
|
const [isStopped, setStopped] = useState(false);
|
||||||
|
|
||||||
const feedContainer: RefObject<HTMLDivElement> | undefined = useRef(null);
|
const feedContainer: RefObject<HTMLDivElement> | undefined = useRef(null);
|
||||||
const { messages, input, handleInputChange, handleSubmit, isLoading } =
|
|
||||||
useChat({
|
const {
|
||||||
api: '/api/query-ai-handler',
|
messages,
|
||||||
onError: (error) => {
|
setMessages,
|
||||||
setError(error);
|
input,
|
||||||
},
|
handleInputChange,
|
||||||
onResponse: (_response) => {
|
handleSubmit: _handleSubmit,
|
||||||
setStartedReply(true);
|
stop,
|
||||||
sendCustomEvent('ai_query', 'ai', 'query', undefined, {
|
reload,
|
||||||
query: input,
|
isLoading,
|
||||||
});
|
} = useChat({
|
||||||
setError(null);
|
api: '/api/query-ai-handler',
|
||||||
},
|
onError: (error) => {
|
||||||
onFinish: (response: Message) => {
|
setError(error);
|
||||||
setStartedReply(false);
|
},
|
||||||
storeQueryForUid(response.id, input);
|
onResponse: (_response) => {
|
||||||
},
|
setStartedReply(true);
|
||||||
});
|
sendCustomEvent('ai_query', 'ai', 'query', undefined, {
|
||||||
|
query: input,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onFinish: (response: Message) => {
|
||||||
|
setStartedReply(false);
|
||||||
|
storeQueryForUid(response.id, input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasReply = useMemo(() => messages.length > 0, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedContainer.current) {
|
if (feedContainer.current) {
|
||||||
@ -46,6 +67,18 @@ export function FeedContainer(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [messages, isLoading]);
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
setStopped(false);
|
||||||
|
_handleSubmit(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewChat = () => {
|
||||||
|
setMessages([]);
|
||||||
|
setError(null);
|
||||||
|
setStartedReply(false);
|
||||||
|
setStopped(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFeedback = (statement: 'good' | 'bad', chatItemUid: string) => {
|
const handleFeedback = (statement: 'good' | 'bad', chatItemUid: string) => {
|
||||||
const query = getQueryFromUid(chatItemUid);
|
const query = getQueryFromUid(chatItemUid);
|
||||||
sendCustomEvent('ai_feedback', 'ai', statement, undefined, {
|
sendCustomEvent('ai_feedback', 'ai', statement, undefined, {
|
||||||
@ -53,6 +86,16 @@ export function FeedContainer(): JSX.Element {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStopGenerating = () => {
|
||||||
|
setStopped(true);
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = () => {
|
||||||
|
setStopped(false);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/*WRAPPER*/}
|
{/*WRAPPER*/}
|
||||||
@ -71,25 +114,33 @@ export function FeedContainer(): JSX.Element {
|
|||||||
<div
|
<div
|
||||||
ref={feedContainer}
|
ref={feedContainer}
|
||||||
data-document="main"
|
data-document="main"
|
||||||
className="relative"
|
className="relative pb-36"
|
||||||
>
|
>
|
||||||
<Feed
|
<Feed
|
||||||
activity={!!messages.length ? messages : [assistantWelcome]}
|
activity={!!messages.length ? messages : [assistantWelcome]}
|
||||||
handleFeedback={(statement, chatItemUid) =>
|
onFeedback={handleFeedback}
|
||||||
handleFeedback(statement, chatItemUid)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Change this message if it's loading but it's writing as well */}
|
{/* Change this message if it's loading but it's writing as well */}
|
||||||
{isLoading && !startedReply && <LoadingState />}
|
{isLoading && !startedReply && <LoadingState />}
|
||||||
{error && <ErrorMessage error={error} />}
|
{error && <ErrorMessage error={error} />}
|
||||||
|
|
||||||
<div className="sticky bottom-0 left-0 right-0 w-full pt-6 pb-4 bg-gradient-to-t from-white via-white dark:from-slate-900 dark:via-slate-900">
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed bottom-0 left0 right-0 w-full py-4 px-4 lg:py-6 lg:px-0',
|
||||||
|
'bg-gradient-to-t from-white via-white/75 dark:from-slate-900 dark:via-slate-900/75'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Prompt
|
<Prompt
|
||||||
handleSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
handleInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onStopGenerating={handleStopGenerating}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
input={input}
|
input={input}
|
||||||
isDisabled={isLoading}
|
isGenerating={isLoading}
|
||||||
|
showNewChatCta={!isLoading && hasReply}
|
||||||
|
showRegenerateCta={isStopped}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export function FeedAnswer({
|
|||||||
<ReactMarkdown children={content} />
|
<ReactMarkdown children={content} />
|
||||||
</div>
|
</div>
|
||||||
{!isFirst && (
|
{!isFirst && (
|
||||||
<div className="group text-xs flex-1 md:flex md:justify-end gap-4 md:items-center text-slate-400 hover:text-slate-500 transition">
|
<div className="group text-md flex-1 md:flex md:justify-end gap-4 md:items-center text-slate-400 hover:text-slate-500 transition">
|
||||||
{feedbackStatement ? (
|
{feedbackStatement ? (
|
||||||
<p className="italic group-hover:flex">
|
<p className="italic group-hover:flex">
|
||||||
{feedbackStatement === 'good'
|
{feedbackStatement === 'good'
|
||||||
@ -89,7 +89,7 @@ export function FeedAnswer({
|
|||||||
title="Bad"
|
title="Bad"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Bad answer</span>
|
<span className="sr-only">Bad answer</span>
|
||||||
<HandThumbDownIcon className="h-5 w-5" aria-hidden="true" />
|
<HandThumbDownIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={cx(
|
className={cx(
|
||||||
@ -101,7 +101,7 @@ export function FeedAnswer({
|
|||||||
title="Good"
|
title="Good"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Good answer</span>
|
<span className="sr-only">Good answer</span>
|
||||||
<HandThumbUpIcon className="h-5 w-5" aria-hidden="true" />
|
<HandThumbUpIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import { Message } from 'ai/react';
|
|||||||
|
|
||||||
export function Feed({
|
export function Feed({
|
||||||
activity,
|
activity,
|
||||||
handleFeedback,
|
onFeedback,
|
||||||
}: {
|
}: {
|
||||||
activity: Message[];
|
activity: Message[];
|
||||||
handleFeedback: (statement: 'bad' | 'good', chatItemUid: string) => void;
|
onFeedback: (statement: 'bad' | 'good', chatItemUid: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flow-root my-12">
|
<div className="flow-root my-12">
|
||||||
@ -21,7 +21,7 @@ export function Feed({
|
|||||||
<FeedAnswer
|
<FeedAnswer
|
||||||
content={activityItem.content}
|
content={activityItem.content}
|
||||||
feedbackButtonCallback={(statement) =>
|
feedbackButtonCallback={(statement) =>
|
||||||
handleFeedback(statement, activityItem.id)
|
onFeedback(statement, activityItem.id)
|
||||||
}
|
}
|
||||||
isFirst={activityItemIdx === 0}
|
isFirst={activityItemIdx === 0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,40 +1,108 @@
|
|||||||
import { ChangeEvent, FormEvent, useEffect, useRef } from 'react';
|
import { ChangeEvent, FormEvent, useEffect, useRef } from 'react';
|
||||||
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
PaperAirplaneIcon,
|
||||||
|
PlusIcon,
|
||||||
|
StopIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
import { Button } from '@nx/nx-dev/ui-common';
|
import { Button } from '@nx/nx-dev/ui-common';
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
import { ChatRequestOptions } from 'ai';
|
import { ChatRequestOptions } from 'ai';
|
||||||
|
import { cx } from '@nx/nx-dev/ui-primitives';
|
||||||
|
|
||||||
export function Prompt({
|
export function Prompt({
|
||||||
isDisabled,
|
isGenerating,
|
||||||
handleSubmit,
|
showNewChatCta,
|
||||||
handleInputChange,
|
showRegenerateCta,
|
||||||
|
onSubmit,
|
||||||
|
onInputChange,
|
||||||
|
onNewChat,
|
||||||
|
onStopGenerating,
|
||||||
|
onRegenerate,
|
||||||
input,
|
input,
|
||||||
}: {
|
}: {
|
||||||
isDisabled: boolean;
|
isGenerating: boolean;
|
||||||
handleSubmit: (
|
showNewChatCta: boolean;
|
||||||
|
showRegenerateCta: boolean;
|
||||||
|
onSubmit: (
|
||||||
e: FormEvent<HTMLFormElement>,
|
e: FormEvent<HTMLFormElement>,
|
||||||
chatRequestOptions?: ChatRequestOptions | undefined
|
chatRequestOptions?: ChatRequestOptions | undefined
|
||||||
) => void;
|
) => void;
|
||||||
handleInputChange: (
|
onInputChange: (
|
||||||
e: ChangeEvent<HTMLTextAreaElement> | ChangeEvent<HTMLInputElement>
|
e: ChangeEvent<HTMLTextAreaElement> | ChangeEvent<HTMLInputElement>
|
||||||
) => void;
|
) => void;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onStopGenerating: () => void;
|
||||||
|
onRegenerate: () => void;
|
||||||
input: string;
|
input: string;
|
||||||
}) {
|
}) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (!isGenerating) inputRef.current?.focus();
|
||||||
inputRef.current.focus();
|
}, [isGenerating]);
|
||||||
}
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
}, []);
|
if (inputRef.current?.value.trim()) onSubmit(event);
|
||||||
|
else event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewChat = () => {
|
||||||
|
onNewChat();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopGenerating = () => {
|
||||||
|
onStopGenerating();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="relative flex gap-2 max-w-2xl mx-auto py-0 px-2 shadow-lg rounded-md border border-slate-300 bg-white dark:border-slate-900 dark:bg-slate-700"
|
className="relative flex gap-2 max-w-3xl mx-auto py-0 px-2 shadow-lg rounded-md border border-slate-300 bg-white dark:border-slate-900 dark:bg-slate-700"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'absolute -top-full left-1/2 mt-1 -translate-x-1/2',
|
||||||
|
'flex gap-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isGenerating && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
className={cx('bg-white dark:bg-slate-900')}
|
||||||
|
onClick={handleStopGenerating}
|
||||||
|
>
|
||||||
|
<StopIcon aria-hidden="true" className="h-5 w-5" />
|
||||||
|
<span className="text-base">Stop generating</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showNewChatCta && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
className={cx('bg-white dark:bg-slate-900')}
|
||||||
|
onClick={handleNewChat}
|
||||||
|
>
|
||||||
|
<PlusIcon aria-hidden="true" className="h-5 w-5" />
|
||||||
|
<span className="text-base">New Chat</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showRegenerateCta && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
className={cx('bg-white dark:bg-slate-900')}
|
||||||
|
onClick={onRegenerate}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon aria-hidden="true" className="h-5 w-5" />
|
||||||
|
<span className="text-base">Regenerate</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="overflow-y-auto w-full h-full max-h-[300px]">
|
<div className="overflow-y-auto w-full h-full max-h-[300px]">
|
||||||
<Textarea
|
<Textarea
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@ -49,10 +117,10 @@ export function Prompt({
|
|||||||
}}
|
}}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={onInputChange}
|
||||||
id="query-prompt"
|
id="query-prompt"
|
||||||
name="query"
|
name="query"
|
||||||
disabled={isDisabled}
|
disabled={isGenerating}
|
||||||
className="block w-full p-0 resize-none bg-transparent text-sm placeholder-slate-500 pl-2 py-[1.3rem] focus-within:outline-none focus:placeholder-slate-400 dark:focus:placeholder-slate-300 dark:text-white focus:outline-none focus:ring-0 border-none disabled:cursor-not-allowed"
|
className="block w-full p-0 resize-none bg-transparent text-sm placeholder-slate-500 pl-2 py-[1.3rem] focus-within:outline-none focus:placeholder-slate-400 dark:focus:placeholder-slate-300 dark:text-white focus:outline-none focus:ring-0 border-none disabled:cursor-not-allowed"
|
||||||
placeholder="How does caching work?"
|
placeholder="How does caching work?"
|
||||||
rows={1}
|
rows={1}
|
||||||
@ -63,7 +131,7 @@ export function Prompt({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="small"
|
size="small"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isDisabled}
|
disabled={isGenerating}
|
||||||
className="self-end w-12 h-12 disabled:cursor-not-allowed"
|
className="self-end w-12 h-12 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<div hidden className="sr-only">
|
<div hidden className="sr-only">
|
||||||
|
|||||||
@ -8,6 +8,8 @@ export default function AiDocs(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
|
title="Nx AI Chat (Alpha)"
|
||||||
|
description="AI chat powered by Nx docs."
|
||||||
noindex={true}
|
noindex={true}
|
||||||
robotsProps={{
|
robotsProps={{
|
||||||
nosnippet: true,
|
nosnippet: true,
|
||||||
@ -867,6 +867,13 @@ const coreFeatureRefactoring = {
|
|||||||
'/core-features/share-your-cache': '/core-features/remote-cache',
|
'/core-features/share-your-cache': '/core-features/remote-cache',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* For AI Chat to make sure old URLs are not broken (added 2023-09-14)
|
||||||
|
*/
|
||||||
|
const aiChat = {
|
||||||
|
'/ai': '/ai-chat',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public export API
|
* Public export API
|
||||||
*/
|
*/
|
||||||
@ -892,4 +899,5 @@ module.exports = {
|
|||||||
pluginsToExtendNx,
|
pluginsToExtendNx,
|
||||||
latestRecipesRefactoring,
|
latestRecipesRefactoring,
|
||||||
coreFeatureRefactoring,
|
coreFeatureRefactoring,
|
||||||
|
aiChat,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,6 +13,7 @@ type AllowedSizes = 'large' | 'default' | 'small';
|
|||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
variant?: AllowedVariants;
|
variant?: AllowedVariants;
|
||||||
size?: AllowedSizes;
|
size?: AllowedSizes;
|
||||||
|
rounded?: 'full' | 'default';
|
||||||
children: ReactNode | ReactNode[];
|
children: ReactNode | ReactNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,12 +46,15 @@ function ButtonInner({
|
|||||||
children,
|
children,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
|
rounded = 'default',
|
||||||
}: ButtonProps): JSX.Element {
|
}: ButtonProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex h-full w-full items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium shadow-sm transition',
|
'flex h-full w-full items-center justify-center whitespace-nowrap',
|
||||||
|
rounded === 'full' ? 'rounded-full' : 'rounded-md',
|
||||||
|
'border border-transparent font-medium shadow-sm transition',
|
||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
sizes[size]
|
sizes[size]
|
||||||
)}
|
)}
|
||||||
@ -69,11 +73,12 @@ export function Button({
|
|||||||
className = '',
|
className = '',
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
size = 'large',
|
size = 'large',
|
||||||
|
rounded = 'default',
|
||||||
...props
|
...props
|
||||||
}: ButtonProps & JSX.IntrinsicElements['button']): JSX.Element {
|
}: ButtonProps & JSX.IntrinsicElements['button']): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button {...props} className={getLayoutClassName(className)}>
|
<button {...props} className={getLayoutClassName(className)}>
|
||||||
<ButtonInner variant={variant} size={size}>
|
<ButtonInner variant={variant} size={size} rounded={rounded}>
|
||||||
{children}
|
{children}
|
||||||
</ButtonInner>
|
</ButtonInner>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user