📦 feat: Move Shared Components to @librechat/client (#8685)

* feat: init @librechat/client

* feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools

* feat: Add jotai as a peer dependency

* fix build client package

* feat: cleanup unused types from common/index.ts

- Remove 104 unused type exports from packages/client/src/common/index.ts
- Keep only 7 actually used exports (93% reduction)
- Add cleanup script with enhanced import pattern detection
- Support both named imports and namespace imports (* as t)
- Create automatic backups and comprehensive documentation
- Maintain type safety with build verification
- No breaking changes to existing code

Kept exports:
- TShowToast, Option, OptionWithIcon, DropdownValueSetter
- MentionOption, NotificationSeverity, MenuItemProps

Scripts: cleanup-common-types-safe.js, README-CLEANUP.md

* fix: cleanup

* fix: package; refactor: tsconfig

* feat: add back `recoil`

* fix: move dependencies to peerDependencies in client package

* feat: add @librechat/client as a dependency in package.json and package-lock.json

* feat: update client package configuration and dependencies

- Added new dependencies for Rollup plugins and updated existing ones in package.json and package-lock.json.
- Introduced a new Rollup configuration file for building the client package.
- Refactored build scripts to include a dedicated build command for the client.
- Updated TypeScript configuration for improved module resolution and type declaration output.
- Integrated a Toast component from the client package into the main App component.

* feat: enhance Rollup configuration for client package

- Updated terser plugin settings to preserve directives like 'use client'.
- Added custom warning handler to ignore "use client" directive warnings during the build process.

* chore: rename package/client build script command

* feat: update client package dependencies and Rollup configuration

- Added rollup-plugin-postcss to package.json and updated package-lock.json.
- Enhanced Rollup configuration to include postcss plugin for CSS handling.
- Updated index.ts to export all components from the components directory for better modularity.

* feat: add client package directory to update configuration

- Included the 'client' package directory in the update.js configuration to ensure it is recognized during updates.

* feat: export Toast component in client package

- Added export for the Toast component in index.ts to enhance modularity and accessibility of components.

* feat: /client transition to @librechat/client

* chore: fixed formatting issues

* fix: update peer dependencies in @librechat/client to prevent bundling them

* fix: correct useSprings implementation in SplitText component

* fix: circular dependencies in DataTable

* fix: add remaining peer dependencies and match actual versions previously used in `client/package.json`

* fix: correct frontend:ci script to include client package build

* chore: enhance unused package detection for @librechat/client and improve dependency extraction

* fix: add missing peer dependency for @radix-ui/react-collapsible

* chore: include "packages/client" in unused i18next keys detection

* test: update AgentFooter tests to use document.querySelector for spinner checks
test: mock window.matchMedia in setupTests.js for consistent test environment

* feat: add react-hook-form dependency and update FormInput component to use its types

* chore: linting

* refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components

* chore: linting

* feat: update GitHub Actions workflow to publish @librechat/client

* chore: update GitHub Actions workflow to install and build data-provider and client dependencies

* chore: add missing @testing-library/react dependency to client package

* chore: update tsconfig.json to exclude additional test files

* chore: fix build issues, resolve latest LC changes

* chore: move MCP components outside of `~/components/ui`

* feat: implement dynamic theme system with environment variable support and Tailwind CSS integration

* chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component

* chore: squashed cleanup commits

chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json

chore: move dependencies to peerDependencies in package.json

feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration

feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration

refactor: reorganize exports in index.ts for improved clarity

refactor: remove unused types and interfaces from common files

refactor: update peer dependencies and improve component typings

- Removed duplicate peer dependencies from package.json and organized them.
- Updated rollup.config.js to disable TypeScript checking during the build process.
- Modified AnimatedTabs component to use React.ReactNode for label and content types, and added TypeScript workarounds for compatibility.
- Enhanced Label and Separator components to accept an optional className prop and improved prop spreading.
- Updated Slider component to include an optional className prop and refined prop handling for better type safety.

refactor: clean up client workflow and update package dependencies

refactor: update package dependencies and improve PostCSS and Rollup configurations

chore: bump version to 0.1.2 in package.json

chore: bump client version to 0.1.2 in package-lock.json

chore: bump client version to 0.1.3 and update dependencies

chore: bump client version to 0.1.4 and update @react-spring dependencies

chore: update package version to 0.1.5 and adjust peer dependencies

- Bump version in package.json from 0.1.4 to 0.1.5.
- Update peer dependency for @tanstack/react-query to allow version 5.0.0.
- Add @tanstack/react-table and @tanstack/react-virtual as dependencies.
- Update various dependencies to their latest compatible versions.
- Simplify postcss.config.js by removing unnecessary options.
- Clean up rollup.config.js by removing ignored PostCSS warnings.
- Update CheckboxButton component to cast icon as React JSX element.
- Adjust Combobox component's class names for better styling.
- Change DropdownPopup component to use React's namespace import.
- Modify InputOTP component to use 'any' type for OTPInputContext.
- Ensure displayLabel and value in ModelParameters are converted to strings.
- Update MultiSearch component's placeholder to ensure it's a string.
- Cast selectIcon in MultiSelect as React JSX element for consistency.
- Update OGDialogTemplate to cast selectText as React JSX element.
- Initialize animationRef in PixelCard with undefined for clarity.
- Add TypeScript ignore comments in Select and SelectDropDown components for Radix UI type conflicts.
- Ensure title in SelectDropDown is a string and adjust rendering of options.
- Update useLocalize hook to cast options as any for compatibility.

refactor: code structure; chore: translations cleanup

chore: remove unused imports and clean up code in NewChat component

refactor: enhance Menu component to support custom render functions for menu items

style: update itemClassName in ToolsDropdown for improved UI consistency

fix: merge conflicts

chore: update @radix-ui/react-accordion to version 1.2.11

* refactor: remove unnecessary TypeScript type assertions in AnimatedTabs, Label, Separator, and Slider components

* feat: enhance theme system with localStorage persistence and new theme atoms

* chore: bump version of @librechat/client to 0.1.7

* chore: fix ci/cd warnings/errors related to linting and unused localization keys

* chore: update dependencies for class-variance-authority, clsx, and match-sorter

* chore: bump @librechat/client to v0.1.8

* feat: add utility colors for theme customization and remove unused tailwindConfig

* v0.1.9

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
Danny Avila 2025-07-27 12:19:01 -04:00 committed by GitHub
parent 97e1cdd224
commit 79197454f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
569 changed files with 7010 additions and 1848 deletions

View file

@ -1,5 +1,6 @@
import filenamify from 'filenamify';
import exportFromJSON from 'export-from-json';
import { useToastContext } from '@librechat/client';
import { QueryKeys } from 'librechat-data-provider';
import { useCallback, useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
@ -13,11 +14,11 @@ import {
} from '~/data-provider';
import { cleanupPreset, removeUnavailableTools, getConvoSwitchLogic } from '~/utils';
import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo';
import { useChatContext, useToastContext } from '~/Providers';
import { useAuthContext } from '~/hooks/AuthContext';
import { NotificationSeverity } from '~/common';
import useLocalize from '~/hooks/useLocalize';
import useNewConvo from '~/hooks/useNewConvo';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function usePresets() {

View file

@ -1,6 +1,5 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { IconMapProps, AgentIconMapProps, IconsRecord } from '~/common';
import { Feather } from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import {
MinimalPlugin,
GPTIcon,
@ -12,7 +11,8 @@ import {
LightningIcon,
BedrockIcon,
Sparkles,
} from '~/components/svg';
} from '@librechat/client';
import type { IconMapProps, AgentIconMapProps, IconsRecord } from '~/common';
import UnknownIcon from './UnknownIcon';
import { cn } from '~/utils';

View file

@ -1,6 +1,6 @@
import { memo } from 'react';
import { CustomMinimalIcon, XAIcon } from '@librechat/client';
import { EModelEndpoint, KnownEndpoints } from 'librechat-data-provider';
import { CustomMinimalIcon, XAIcon } from '~/components/svg';
import { IconContext } from '~/common';
import { cn } from '~/utils';

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useToastContext } from '~/Providers/ToastContext';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '@librechat/client';
import { useLocalize } from '~/hooks';
export const useDelayedUploadToast = () => {
const localize = useLocalize();

View file

@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
@ -17,7 +18,6 @@ import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import { processFileForUpload } from '~/utils/heicConverter';
import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext';
import { logger, validateFiles } from '~/utils';
import useClientResize from './useClientResize';

View file

@ -2,7 +2,6 @@ export * from './useAutoSave';
export { default as useUserKey } from './useUserKey';
export { default as useDebounce } from './useDebounce';
export { default as useTextarea } from './useTextarea';
export { default as useCombobox } from './useCombobox';
export { default as useQueryParams } from './useQueryParams';
export { default as useHandleKeyUp } from './useHandleKeyUp';
export { default as useRequiresKey } from './useRequiresKey';

View file

@ -1,37 +0,0 @@
import { useMemo, useState } from 'react';
import { matchSorter } from 'match-sorter';
import type { OptionWithIcon, MentionOption } from '~/common';
export default function useCombobox({
value,
options,
}: {
value: string;
options: Array<OptionWithIcon | MentionOption>;
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const matches = useMemo(() => {
if (!searchValue) {
return options;
}
const keys = ['label', 'value'];
const matches = matchSorter(options, searchValue, { keys });
// Radix Select does not work if we don't render the selected item, so we
// make sure to include it in the list of matches.
const selectedItem = options.find((currentItem) => currentItem.value === value);
if (selectedItem && !matches.includes(selectedItem)) {
matches.push(selectedItem);
}
return matches;
}, [searchValue, value, options]);
return {
open,
setOpen,
searchValue,
setSearchValue,
matches,
};
}

View file

@ -1,8 +1,8 @@
import { useEffect, useRef, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useToastContext } from '@librechat/client';
import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition';
import useGetAudioSettings from './useGetAudioSettings';
import { useToastContext } from '~/Providers';
import store from '~/store';
const useSpeechToTextBrowser = (

View file

@ -1,8 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useRecoilState } from 'recoil';
import { useToastContext } from '@librechat/client';
import { useSpeechToTextMutation } from '~/data-provider';
import useGetAudioSettings from './useGetAudioSettings';
import { useToastContext } from '~/Providers';
import store from '~/store';
const useSpeechToTextExternal = (
@ -107,7 +107,7 @@ const useSpeechToTextExternal = (
});
setPermission(true);
audioStream.current = streamData ?? null;
} catch (err) {
} catch {
setPermission(false);
}
};
@ -268,6 +268,7 @@ const useSpeechToTextExternal = (
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isListening]);
return {

View file

@ -1,8 +1,8 @@
import { useRecoilValue } from 'recoil';
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { useTextToSpeechMutation, useVoicesQuery } from '~/data-provider';
import { useToastContext } from '~/Providers/ToastContext';
import useLocalize from '~/hooks/useLocalize';
import { useLocalize } from '~/hooks';
import store from '~/store';
const createFormData = (text: string, voice: string) => {

View file

@ -16,8 +16,8 @@ import useGetSender from '~/hooks/Conversations/useGetSender';
import useFileHandling from '~/hooks/Files/useFileHandling';
import { useInteractionHealthCheck } from '~/data-provider';
import { useChatContext } from '~/Providers/ChatContext';
import useLocalize from '~/hooks/useLocalize';
import { globalAudioId } from '~/common';
import { useLocalize } from '~/hooks';
import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;

View file

@ -1,12 +1,12 @@
import { useCallback, useState, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useToastContext } from '@librechat/client';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import {
useReinitializeMCPServerMutation,
useCancelMCPOAuthMutation,
} from 'librechat-data-provider/react-query';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';

View file

@ -1,12 +1,12 @@
import { useCallback, useState, useMemo, useRef } from 'react';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { useCallback, useState, useMemo, useRef } from 'react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
import type { ConfigFieldDetail } from '~/components/ui/MCP/MCPConfigDialog';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import { useToastContext, useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerInitialization } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
export function useMCPServerManager() {
const localize = useLocalize();

View file

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { Blocks, MCPIcon, AttachmentIcon } from '@librechat/client';
import { MessageSquareQuote, ArrowRightToLine, Settings2, Database, Bookmark } from 'lucide-react';
import {
isAssistantsEndpoint,
@ -15,7 +16,6 @@ import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel';
import MemoryViewer from '~/components/SidePanel/Memories/MemoryViewer';
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
import { Blocks, MCPIcon, AttachmentIcon } from '~/components/svg';
import Parameters from '~/components/SidePanel/Parameters/Panel';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';

View file

@ -2,10 +2,8 @@ import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { AuthType, Tools, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
// import { useToastContext } from '~/Providers';
const useAuthCodeTool = (options?: { isEntityTool: boolean }) => {
// const { showToast } = useToastContext();
const queryClient = useQueryClient();
const isEntityTool = options?.isEntityTool ?? true;
const updateUserPlugins = useUpdateUserPluginsMutation({

View file

@ -1,6 +1,6 @@
import { useGetCategories } from '~/data-provider';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
import { useLocalize, TranslationKeys } from '~/hooks';
const loadingCategories: { label: TranslationKeys; value: string }[] = [
{

View file

@ -1,6 +1,6 @@
import { createContext, useRef, useContext, RefObject } from 'react';
import { toCanvas } from 'html-to-image';
import { ThemeContext } from '~/hooks/ThemeContext';
import { ThemeContext } from '@librechat/client';
type ScreenshotContextType = {
ref?: RefObject<HTMLDivElement>;

View file

@ -1,82 +0,0 @@
//ThemeContext.js
// source: https://plainenglish.io/blog/light-and-dark-mode-in-react-web-application-with-tailwind-css-89674496b942
import { useSetRecoilState } from 'recoil';
import React, { createContext, useState, useEffect } from 'react';
import { getInitialTheme, applyFontSize } from '~/utils';
import store from '~/store';
type ProviderValue = {
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
};
const defaultContextValue: ProviderValue = {
theme: getInitialTheme(),
setTheme: () => {
return;
},
};
export const isDark = (theme: string): boolean => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
};
export const ThemeContext = createContext<ProviderValue>(defaultContextValue);
export const ThemeProvider = ({ initialTheme, children }) => {
const [theme, setTheme] = useState(getInitialTheme);
const setFontSize = useSetRecoilState(store.fontSize);
const rawSetTheme = (rawTheme: string) => {
const root = window.document.documentElement;
const darkMode = isDark(rawTheme);
root.classList.remove(darkMode ? 'light' : 'dark');
root.classList.add(darkMode ? 'dark' : 'light');
localStorage.setItem('color-theme', rawTheme);
};
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const changeThemeOnSystemChange = () => {
rawSetTheme(mediaQuery.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', changeThemeOnSystemChange);
return () => {
mediaQuery.removeEventListener('change', changeThemeOnSystemChange);
};
}, []);
useEffect(() => {
const fontSize = localStorage.getItem('fontSize');
if (fontSize == null) {
setFontSize('text-base');
applyFontSize('text-base');
localStorage.setItem('fontSize', JSON.stringify('text-base'));
return;
}
try {
applyFontSize(JSON.parse(fontSize));
} catch (error) {
console.log(error);
}
// Reason: This effect should only run once, and `setFontSize` is a stable function
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (initialTheme) {
rawSetTheme(initialTheme);
}
useEffect(() => {
rawSetTheme(theme);
}, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};

View file

@ -8,30 +8,26 @@ export * from './Nav';
export * from './Files';
export * from './Generic';
export * from './Input';
export * from './MCP';
export * from './Messages';
export * from './Plugins';
export * from './Prompts';
export * from './Roles';
export * from './SSE';
export * from './AuthContext';
export * from './ThemeContext';
export * from './ScreenshotContext';
export * from './ApiErrorBoundaryContext';
export * from './Endpoint';
export type { TranslationKeys } from './useLocalize';
export { default as useToast } from './useToast';
export { default as useTimeout } from './useTimeout';
export { default as useNewConvo } from './useNewConvo';
export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';
export { default as useChatBadges } from './useChatBadges';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useDocumentTitle } from './useDocumentTitle';
export { default as useDelayedRender } from './useDelayedRender';
export { default as useOnClickOutside } from './useOnClickOutside';
export { default as useSpeechToText } from './Input/useSpeechToText';
export { default as useTextToSpeech } from './Input/useTextToSpeech';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';

View file

@ -1,37 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import type { ReactNode } from 'react';
const useDelayedRender = (delay: number) => {
const [delayed, setDelayed] = useState(true);
const timerPromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
if (delayed) {
const timerPromise = new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
setDelayed(false);
resolve();
}, delay);
return () => {
clearTimeout(timeout);
};
});
timerPromiseRef.current = timerPromise;
}
return () => {
timerPromiseRef.current = null;
};
}, [delay, delayed]);
return (fn: () => ReactNode) => {
if (delayed && timerPromiseRef.current) {
throw timerPromiseRef.current;
}
return fn();
};
};
export default useDelayedRender;

View file

@ -1,17 +0,0 @@
import { useEffect, useState } from 'react';
export default function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}

View file

@ -1,41 +0,0 @@
import { useEffect, RefObject } from 'react';
type Handler = () => void;
export default function useOnClickOutside(
ref: RefObject<HTMLElement>,
handler: Handler,
excludeIds: string[],
customCondition?: (target: EventTarget | Element | null) => boolean,
): void {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (target && 'id' in target && excludeIds.includes((target as HTMLElement).id)) {
return;
}
if (
target?.parentNode &&
'id' in target.parentNode &&
excludeIds.includes((target.parentNode as HTMLElement).id)
) {
return;
}
if (customCondition && customCondition(target)) {
return;
}
if (ref.current && !ref.current.contains(target)) {
handler();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, handler]);
}

View file

@ -1,58 +0,0 @@
import { useRecoilState } from 'recoil';
import { useRef, useEffect } from 'react';
import type { TShowToast } from '~/common';
import { NotificationSeverity } from '~/common';
import store from '~/store';
export default function useToast(showDelay = 100) {
const [toast, setToast] = useRecoilState(store.toastState);
const showTimerRef = useRef<number | null>(null);
const hideTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (showTimerRef.current !== null) {
clearTimeout(showTimerRef.current);
}
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current);
}
};
}, []);
const showToast = ({
message,
severity = NotificationSeverity.SUCCESS,
showIcon = true,
duration = 3000, // default duration for the toast to be visible
status,
}: TShowToast) => {
// Clear existing timeouts
if (showTimerRef.current !== null) {
clearTimeout(showTimerRef.current);
}
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current);
}
// Timeout to show the toast
showTimerRef.current = window.setTimeout(() => {
setToast({
open: true,
message,
severity: (status as NotificationSeverity) ?? severity,
showIcon,
});
// Hides the toast after the specified duration
hideTimerRef.current = window.setTimeout(() => {
setToast((prevToast) => ({ ...prevToast, open: false }));
}, duration);
}, showDelay);
};
return {
toast,
onOpenChange: (open: boolean) => setToast({ ...toast, open }),
showToast,
};
}