refactor: migrate heroui/toast to antd message

This commit is contained in:
dev 2025-11-05 19:08:41 +08:00
parent f4b14dfc10
commit d01609fc36
10 changed files with 279 additions and 116 deletions

View File

@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { ToastPortal } from './components/ToastPortal'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
@ -50,7 +49,6 @@ function App(): React.ReactElement {
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<ToastPortal />
</HeroUIProvider>
</QueryClientProvider>
</Provider>

View File

@ -0,0 +1,21 @@
import { App } from 'antd'
import { useEffect } from 'react'
import { initMessageApi } from './TopView/toast'
/**
* MessageInitializer component
* This component initializes the message API from App.useApp() hook
* It should be rendered inside the AntdProvider (which wraps App component)
* so that the message API can inherit the correct theme (dark/light mode)
*/
export const MessageInitializer = () => {
const { message } = App.useApp()
useEffect(() => {
// Initialize the message API for use in toast.ts
initMessageApi(message)
}, [message])
return null
}

View File

@ -1,32 +0,0 @@
import { ToastProvider } from '@heroui/toast'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
export const ToastPortal = () => {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
if (!mounted) return null
return createPortal(
<ToastProvider
placement="top-center"
regionProps={{
className: 'z-[1001]'
}}
toastOffset={20}
toastProps={{
timeout: 3000,
classNames: {
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
base: 'hero-toast'
}
}}
/>,
document.body
)
}

View File

@ -1,72 +0,0 @@
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import type { RequireSome } from '@renderer/types'
type AddToastProps = Parameters<typeof addToast>[0]
type ToastPropsColored = Omit<AddToastProps, 'color'>
const createToast = (color: 'danger' | 'success' | 'warning' | 'default') => {
return (arg: ToastPropsColored | string): string | null => {
if (typeof arg === 'string') {
return addToast({ color, title: arg })
} else {
return addToast({ color, ...arg })
}
}
}
// syntatic sugar, oh yeah
/**
* Display an error toast notification with red color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const error = createToast('danger')
/**
* Display a success toast notification with green color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const success = createToast('success')
/**
* Display a warning toast notification with yellow color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const warning = createToast('warning')
/**
* Display an info toast notification with default color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const info = createToast('default')
/**
* Display a loading toast notification that resolves with a promise
* @param args - Toast options object containing a promise to resolve
* @returns Toast ID or null
*/
export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
// Disappear immediately by default
if (args.timeout === undefined) {
args.timeout = 1
}
return addToast(args)
}
export const getToastUtilities = () =>
({
getToastQueue,
addToast,
closeToast,
closeAll,
isToastClosing,
error,
success,
warning,
info,
loading
}) as const

View File

@ -0,0 +1,231 @@
import type { RequireSome } from '@renderer/types'
import { message as antdMessage } from 'antd'
import type { MessageInstance } from 'antd/es/message/interface'
import type React from 'react'
// Global message instance for static usage
let messageApi: MessageInstance | null = null
// Initialize message API - should be called once the App component is mounted
export const initMessageApi = (api: MessageInstance) => {
messageApi = api
}
// Get message API instance
const getMessageApi = (): MessageInstance => {
if (!messageApi) {
// Fallback to static method if hook API is not available
return antdMessage
}
return messageApi
}
type ToastColor = 'danger' | 'success' | 'warning' | 'default'
type MessageType = 'error' | 'success' | 'warning' | 'info'
interface ToastConfig {
title?: React.ReactNode
icon?: React.ReactNode
description?: React.ReactNode
timeout?: number
key?: string | number
className?: string
style?: React.CSSProperties
onClick?: () => void
onClose?: () => void
}
interface LoadingToastConfig extends ToastConfig {
promise: Promise<any>
}
const colorToType = (color: ToastColor): MessageType => {
switch (color) {
case 'danger':
return 'error'
case 'success':
return 'success'
case 'warning':
return 'warning'
case 'default':
return 'info'
}
}
// Toast content component
const ToastContent: React.FC<{ title?: React.ReactNode; description?: React.ReactNode; icon?: React.ReactNode }> = ({
title,
description,
icon
}) => {
return (
<div className="flex flex-col gap-1">
{(icon || title) && (
<div className="flex items-center gap-2 font-semibold">
{icon}
{title}
</div>
)}
{description && <div className="text-sm">{description}</div>}
</div>
)
}
const createToast = (color: ToastColor) => {
return (arg: ToastConfig | string): string | null => {
const api = getMessageApi()
const type = colorToType(color) as 'error' | 'success' | 'warning' | 'info'
if (typeof arg === 'string') {
// antd message methods return a function to close the message
api[type](arg)
return null
}
const { title, description, icon, timeout, ...restConfig } = arg
// Convert timeout from milliseconds to seconds (antd uses seconds)
const duration = timeout !== undefined ? timeout / 1000 : 3
return (
(api.open({
type: type as 'error' | 'success' | 'warning' | 'info',
content: <ToastContent title={title} description={description} icon={icon} />,
duration,
...restConfig
}) as any) || null
)
}
}
/**
* Display an error toast notification with red color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const error = createToast('danger')
/**
* Display a success toast notification with green color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const success = createToast('success')
/**
* Display a warning toast notification with yellow color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const warning = createToast('warning')
/**
* Display an info toast notification with default color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const info = createToast('default')
/**
* Display a loading toast notification that resolves with a promise
* @param args - Toast options object containing a promise to resolve
*/
export const loading = (args: RequireSome<LoadingToastConfig, 'promise'>): string | null => {
const api = getMessageApi()
const { title, description, icon, promise, timeout, ...restConfig } = args
// Generate unique key for this loading message
const key = args.key || `loading-${Date.now()}-${Math.random()}`
// Show loading message
api.loading({
content: <ToastContent title={title || 'Loading...'} description={description} icon={icon} />,
duration: 0, // Don't auto-close
key,
...restConfig
})
// Handle promise resolution
promise
.then((result) => {
api.success({
content: <ToastContent title={title || 'Success'} description={description} />,
duration: timeout !== undefined ? timeout / 1000 : 2,
key,
...restConfig
})
return result
})
.catch((err) => {
api.error({
content: (
<ToastContent title={title || 'Error'} description={err?.message || description || 'An error occurred'} />
),
duration: timeout !== undefined ? timeout / 1000 : 3,
key,
...restConfig
})
throw err
})
return key as string
}
/**
* Add a toast notification
* @param config - Toast configuration object
* @returns Toast ID or null
*/
export const addToast = (config: ToastConfig) => info(config)
/**
* Close a specific toast notification by its key
* @param key - Toast key (string)
*/
export const closeToast = (key: string) => {
getMessageApi().destroy(key)
}
/**
* Close all toast notifications
*/
export const closeAll = () => {
getMessageApi().destroy()
}
/**
* Stub functions for compatibility with previous toast API
* These are no-ops since antd message doesn't expose a queue
*/
/**
* @deprecated This function is a no-op stub for backward compatibility only.
* Antd message doesn't expose a queue. Do not rely on this function.
* @returns Empty toast queue stub
*/
export const getToastQueue = (): any => ({ toasts: [] })
/**
* @deprecated This function is a no-op stub for backward compatibility only.
* Antd message doesn't track closing state. Do not rely on this function.
* @param key - Toast key (unused)
* @returns Always returns false
*/
export const isToastClosing = (key?: string): boolean => {
key // unused
return false
}
export const getToastUtilities = () =>
({
getToastQueue,
addToast,
closeToast,
closeAll,
isToastClosing,
error,
success,
warning,
info,
loading
}) as const

View File

@ -1,6 +1,7 @@
import { MessageInitializer } from '@renderer/components/MessageInitializer'
import { useSettings } from '@renderer/hooks/useSettings'
import type { LanguageVarious } from '@renderer/types'
import { ConfigProvider, theme } from 'antd'
import { App, ConfigProvider, theme } from 'antd'
import deDE from 'antd/locale/de_DE'
import elGR from 'antd/locale/el_GR'
import enUS from 'antd/locale/en_US'
@ -114,7 +115,15 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
motionDurationMid: '100ms'
}
}}>
{children}
<App
message={{
top: 20,
maxCount: 3,
duration: 3
}}>
<MessageInitializer />
{children}
</App>
</ConfigProvider>
)
}

View File

@ -1,12 +1,22 @@
/// <reference types="vite/client" />
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import type KeyvStorage from '@kangfenmao/keyv-storage'
import type { HookAPI } from 'antd/es/modal/useModal'
import type { NavigateFunction } from 'react-router-dom'
import type { error, info, loading, success, warning } from './components/TopView/toast'
import type {
addToast,
closeAll,
closeToast,
error,
getToastQueue,
info,
isToastClosing,
loading,
success,
warning
} from './components/TopView/toast'
interface ImportMetaEnv {
VITE_RENDERER_INTEGRATED_MODEL: string

View File

@ -69,8 +69,10 @@ export async function checkDataLimit() {
const toastId = window.toast.warning({
title: t('settings.data.limit.appDataDiskQuota'),
description: t('settings.data.limit.appDataDiskQuotaDescription'),
timeout: 0, // Never auto-dismiss
hideCloseButton: true // Hide close button so user cannot dismiss
timeout: 0 // Never auto-dismiss
// hideCloseButton: true // Hide close button so user cannot dismiss
// commented out because antd message doesn't support hiding close button
// so we just rely on the timeout: 0 to keep it persistent
})
currentToastId = toastId

View File

@ -1,7 +1,6 @@
import '@renderer/databases'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { ToastPortal } from '@renderer/components/ToastPortal'
import { getToastUtilities } from '@renderer/components/TopView/toast'
import { HeroUIProvider } from '@renderer/context/HeroUIProvider'
import { useSettings } from '@renderer/hooks/useSettings'
@ -55,7 +54,6 @@ function MiniWindow(): React.ReactElement {
</CodeStyleProvider>
</AntdProvider>
</ThemeProvider>
<ToastPortal />
</HeroUIProvider>
</Provider>
)

View File

@ -4,7 +4,6 @@ import '@ant-design/v5-patch-for-react-19'
import KeyvStorage from '@kangfenmao/keyv-storage'
import { loggerService } from '@logger'
import { ToastPortal } from '@renderer/components/ToastPortal'
import { getToastUtilities } from '@renderer/components/TopView/toast'
import AntdProvider from '@renderer/context/AntdProvider'
import { CodeStyleProvider } from '@renderer/context/CodeStyleProvider'
@ -54,7 +53,6 @@ const App: FC = () => {
</CodeStyleProvider>
</AntdProvider>
</ThemeProvider>
<ToastPortal />
</HeroUIProvider>
</Provider>
)