Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
195 lines
7.1 KiB
TypeScript
195 lines
7.1 KiB
TypeScript
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
|
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import { setNotify } from '../../application/notification';
|
|
import { cn } from '../../lib/utils';
|
|
|
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
type: ToastType;
|
|
title?: string;
|
|
message: string;
|
|
duration?: number;
|
|
onClick?: () => void;
|
|
actionLabel?: string;
|
|
}
|
|
|
|
interface ToastActionsValue {
|
|
showToast: (toast: Omit<Toast, 'id'>) => void;
|
|
dismissToast: (id: string) => void;
|
|
}
|
|
|
|
interface ToastStateValue {
|
|
toasts: Toast[];
|
|
}
|
|
|
|
const ToastActionsContext = createContext<ToastActionsValue | null>(null);
|
|
const ToastStateContext = createContext<ToastStateValue | null>(null);
|
|
|
|
/** Actions-only subscription — does not re-render when the toast list changes. */
|
|
export const useToastActions = () => {
|
|
const context = useContext(ToastActionsContext);
|
|
if (!context) {
|
|
throw new Error('useToastActions must be used within a ToastProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
/** Full toast API. Prefer useToastActions when you do not need the list. */
|
|
export const useToast = () => {
|
|
const actions = useContext(ToastActionsContext);
|
|
const state = useContext(ToastStateContext);
|
|
if (!actions || !state) {
|
|
throw new Error('useToast must be used within a ToastProvider');
|
|
}
|
|
return { ...state, ...actions };
|
|
};
|
|
|
|
// Simple hook for components that may not be inside ToastProvider
|
|
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
|
|
|
|
export interface ToastOptions {
|
|
title?: string;
|
|
duration?: number;
|
|
onClick?: () => void;
|
|
actionLabel?: string;
|
|
}
|
|
|
|
export const toast = {
|
|
success: (message: string, titleOrOptions?: string | ToastOptions) => {
|
|
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
|
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
|
|
},
|
|
error: (message: string, titleOrOptions?: string | ToastOptions) => {
|
|
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
|
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
|
|
},
|
|
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
|
|
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
|
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
|
|
},
|
|
info: (message: string, titleOrOptions?: string | ToastOptions) => {
|
|
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
|
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
|
|
},
|
|
};
|
|
|
|
const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
|
|
success: <CheckCircle className="h-4 w-4 text-emerald-500" />,
|
|
error: <AlertCircle className="h-4 w-4 text-red-500" />,
|
|
warning: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
|
|
info: <Info className="h-4 w-4 text-blue-500" />,
|
|
};
|
|
|
|
const TOAST_STYLES: Record<ToastType, string> = {
|
|
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
|
|
error: 'border-red-600 bg-red-50 dark:bg-red-950',
|
|
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
|
|
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
|
|
};
|
|
|
|
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
const showToast = useCallback((nextToast: Omit<Toast, 'id'>) => {
|
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const created: Toast = { ...nextToast, id };
|
|
setToasts(prev => [...prev, created]);
|
|
|
|
// Auto dismiss
|
|
if (nextToast.duration !== 0) {
|
|
setTimeout(() => {
|
|
setToasts(prev => prev.filter(t => t.id !== id));
|
|
}, nextToast.duration || 4000);
|
|
}
|
|
}, []);
|
|
|
|
const dismissToast = useCallback((id: string) => {
|
|
setToasts(prev => prev.filter(t => t.id !== id));
|
|
}, []);
|
|
|
|
const actionsValue = useMemo<ToastActionsValue>(
|
|
() => ({ showToast, dismissToast }),
|
|
[showToast, dismissToast],
|
|
);
|
|
|
|
const stateValue = useMemo<ToastStateValue>(
|
|
() => ({ toasts }),
|
|
[toasts],
|
|
);
|
|
|
|
// Register global toast function
|
|
useEffect(() => {
|
|
globalShowToast = showToast;
|
|
setNotify(toast);
|
|
return () => {
|
|
globalShowToast = null;
|
|
};
|
|
}, [showToast]);
|
|
|
|
return (
|
|
<ToastActionsContext.Provider value={actionsValue}>
|
|
<ToastStateContext.Provider value={stateValue}>
|
|
{children}
|
|
<ToastContainer />
|
|
</ToastStateContext.Provider>
|
|
</ToastActionsContext.Provider>
|
|
);
|
|
};
|
|
|
|
const ToastContainer: React.FC = () => {
|
|
const { toasts } = useContext(ToastStateContext) ?? { toasts: [] as Toast[] };
|
|
const actions = useContext(ToastActionsContext);
|
|
const onDismiss = actions?.dismissToast;
|
|
|
|
if (toasts.length === 0 || !onDismiss) return null;
|
|
|
|
const handleToastClick = (t: Toast) => {
|
|
if (t.onClick) {
|
|
t.onClick();
|
|
onDismiss(t.id);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
|
|
{toasts.map(t => (
|
|
<div
|
|
key={t.id}
|
|
className={cn(
|
|
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
|
|
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
|
|
TOAST_STYLES[t.type],
|
|
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
|
|
)}
|
|
onClick={() => handleToastClick(t)}
|
|
role={t.onClick ? "button" : undefined}
|
|
tabIndex={t.onClick ? 0 : undefined}
|
|
>
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
{TOAST_ICONS[t.type]}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
{t.title && (
|
|
<div className="text-sm font-medium text-foreground">{t.title}</div>
|
|
)}
|
|
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
|
|
{t.actionLabel && t.onClick && (
|
|
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} →</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
|
|
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ToastProvider;
|