Skip to content

Commit a6e8808

Browse files
committed
upd: added toaster for better error feedbacks
1 parent d34d2da commit a6e8808

File tree

6 files changed

+364
-1
lines changed

6 files changed

+364
-1
lines changed

client/app/components/ui/chat/chat-tts-button.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/react-query";
44
import { useState } from "react";
55
import { apiClient } from "~/lib/api";
66
import { Tooltip, TooltipTrigger, TooltipContent } from "../tooltip";
7+
import { useToast } from "~/hooks/use-toast";
78

89
export default function ChatTtsButton({
910
agentId,
@@ -12,6 +13,7 @@ export default function ChatTtsButton({
1213
agentId: string;
1314
text: string;
1415
}) {
16+
const { toast } = useToast();
1517
const [playing, setPlaying] = useState<boolean>(false);
1618
const mutation = useMutation({
1719
mutationKey: ["tts", text],
@@ -21,7 +23,11 @@ export default function ChatTtsButton({
2123
setPlaying(true);
2224
},
2325
onError: (e) => {
24-
console.error(e.message);
26+
toast({
27+
variant: "destructive",
28+
title: "Unable to read message aloud",
29+
description: e.message,
30+
});
2531
},
2632
});
2733

client/app/components/ui/toast.tsx

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import * as React from "react"
2+
import * as ToastPrimitives from "@radix-ui/react-toast"
3+
import { cva, type VariantProps } from "class-variance-authority"
4+
import { X } from "lucide-react"
5+
6+
import { cn } from "~/lib/utils"
7+
8+
const ToastProvider = ToastPrimitives.Provider
9+
10+
const ToastViewport = React.forwardRef<
11+
React.ElementRef<typeof ToastPrimitives.Viewport>,
12+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
13+
>(({ className, ...props }, ref) => (
14+
<ToastPrimitives.Viewport
15+
ref={ref}
16+
className={cn(
17+
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
18+
className
19+
)}
20+
{...props}
21+
/>
22+
))
23+
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24+
25+
const toastVariants = cva(
26+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27+
{
28+
variants: {
29+
variant: {
30+
default: "border bg-background text-foreground",
31+
destructive:
32+
"destructive group border-destructive bg-destructive text-destructive-foreground",
33+
},
34+
},
35+
defaultVariants: {
36+
variant: "default",
37+
},
38+
}
39+
)
40+
41+
const Toast = React.forwardRef<
42+
React.ElementRef<typeof ToastPrimitives.Root>,
43+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
44+
VariantProps<typeof toastVariants>
45+
>(({ className, variant, ...props }, ref) => {
46+
return (
47+
<ToastPrimitives.Root
48+
ref={ref}
49+
className={cn(toastVariants({ variant }), className)}
50+
{...props}
51+
/>
52+
)
53+
})
54+
Toast.displayName = ToastPrimitives.Root.displayName
55+
56+
const ToastAction = React.forwardRef<
57+
React.ElementRef<typeof ToastPrimitives.Action>,
58+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
59+
>(({ className, ...props }, ref) => (
60+
<ToastPrimitives.Action
61+
ref={ref}
62+
className={cn(
63+
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
64+
className
65+
)}
66+
{...props}
67+
/>
68+
))
69+
ToastAction.displayName = ToastPrimitives.Action.displayName
70+
71+
const ToastClose = React.forwardRef<
72+
React.ElementRef<typeof ToastPrimitives.Close>,
73+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
74+
>(({ className, ...props }, ref) => (
75+
<ToastPrimitives.Close
76+
ref={ref}
77+
className={cn(
78+
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
79+
className
80+
)}
81+
toast-close=""
82+
{...props}
83+
>
84+
<X className="h-4 w-4" />
85+
</ToastPrimitives.Close>
86+
))
87+
ToastClose.displayName = ToastPrimitives.Close.displayName
88+
89+
const ToastTitle = React.forwardRef<
90+
React.ElementRef<typeof ToastPrimitives.Title>,
91+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
92+
>(({ className, ...props }, ref) => (
93+
<ToastPrimitives.Title
94+
ref={ref}
95+
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
96+
{...props}
97+
/>
98+
))
99+
ToastTitle.displayName = ToastPrimitives.Title.displayName
100+
101+
const ToastDescription = React.forwardRef<
102+
React.ElementRef<typeof ToastPrimitives.Description>,
103+
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
104+
>(({ className, ...props }, ref) => (
105+
<ToastPrimitives.Description
106+
ref={ref}
107+
className={cn("text-sm opacity-90", className)}
108+
{...props}
109+
/>
110+
))
111+
ToastDescription.displayName = ToastPrimitives.Description.displayName
112+
113+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
114+
115+
type ToastActionElement = React.ReactElement<typeof ToastAction>
116+
117+
export {
118+
type ToastProps,
119+
type ToastActionElement,
120+
ToastProvider,
121+
ToastViewport,
122+
Toast,
123+
ToastTitle,
124+
ToastDescription,
125+
ToastClose,
126+
ToastAction,
127+
}

client/app/components/ui/toaster.tsx

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useToast } from "~/hooks/use-toast"
2+
import {
3+
Toast,
4+
ToastClose,
5+
ToastDescription,
6+
ToastProvider,
7+
ToastTitle,
8+
ToastViewport,
9+
} from "~/components/ui/toast"
10+
11+
export function Toaster() {
12+
const { toasts } = useToast()
13+
14+
return (
15+
<ToastProvider>
16+
{toasts.map(function ({ id, title, description, action, ...props }) {
17+
return (
18+
<Toast key={id} {...props}>
19+
<div className="grid gap-1">
20+
{title && <ToastTitle>{title}</ToastTitle>}
21+
{description && (
22+
<ToastDescription>{description}</ToastDescription>
23+
)}
24+
</div>
25+
{action}
26+
<ToastClose />
27+
</Toast>
28+
)
29+
})}
30+
<ToastViewport />
31+
</ToastProvider>
32+
)
33+
}

client/app/hooks/use-toast.ts

+194
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"use client"
2+
3+
// Inspired by react-hot-toast library
4+
import * as React from "react"
5+
6+
import type {
7+
ToastActionElement,
8+
ToastProps,
9+
} from "~/components/ui/toast"
10+
11+
const TOAST_LIMIT = 1
12+
const TOAST_REMOVE_DELAY = 1000000
13+
14+
type ToasterToast = ToastProps & {
15+
id: string
16+
title?: React.ReactNode
17+
description?: React.ReactNode
18+
action?: ToastActionElement
19+
}
20+
21+
const actionTypes = {
22+
ADD_TOAST: "ADD_TOAST",
23+
UPDATE_TOAST: "UPDATE_TOAST",
24+
DISMISS_TOAST: "DISMISS_TOAST",
25+
REMOVE_TOAST: "REMOVE_TOAST",
26+
} as const
27+
28+
let count = 0
29+
30+
function genId() {
31+
count = (count + 1) % Number.MAX_SAFE_INTEGER
32+
return count.toString()
33+
}
34+
35+
type ActionType = typeof actionTypes
36+
37+
type Action =
38+
| {
39+
type: ActionType["ADD_TOAST"]
40+
toast: ToasterToast
41+
}
42+
| {
43+
type: ActionType["UPDATE_TOAST"]
44+
toast: Partial<ToasterToast>
45+
}
46+
| {
47+
type: ActionType["DISMISS_TOAST"]
48+
toastId?: ToasterToast["id"]
49+
}
50+
| {
51+
type: ActionType["REMOVE_TOAST"]
52+
toastId?: ToasterToast["id"]
53+
}
54+
55+
interface State {
56+
toasts: ToasterToast[]
57+
}
58+
59+
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
60+
61+
const addToRemoveQueue = (toastId: string) => {
62+
if (toastTimeouts.has(toastId)) {
63+
return
64+
}
65+
66+
const timeout = setTimeout(() => {
67+
toastTimeouts.delete(toastId)
68+
dispatch({
69+
type: "REMOVE_TOAST",
70+
toastId: toastId,
71+
})
72+
}, TOAST_REMOVE_DELAY)
73+
74+
toastTimeouts.set(toastId, timeout)
75+
}
76+
77+
export const reducer = (state: State, action: Action): State => {
78+
switch (action.type) {
79+
case "ADD_TOAST":
80+
return {
81+
...state,
82+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83+
}
84+
85+
case "UPDATE_TOAST":
86+
return {
87+
...state,
88+
toasts: state.toasts.map((t) =>
89+
t.id === action.toast.id ? { ...t, ...action.toast } : t
90+
),
91+
}
92+
93+
case "DISMISS_TOAST": {
94+
const { toastId } = action
95+
96+
// ! Side effects ! - This could be extracted into a dismissToast() action,
97+
// but I'll keep it here for simplicity
98+
if (toastId) {
99+
addToRemoveQueue(toastId)
100+
} else {
101+
state.toasts.forEach((toast) => {
102+
addToRemoveQueue(toast.id)
103+
})
104+
}
105+
106+
return {
107+
...state,
108+
toasts: state.toasts.map((t) =>
109+
t.id === toastId || toastId === undefined
110+
? {
111+
...t,
112+
open: false,
113+
}
114+
: t
115+
),
116+
}
117+
}
118+
case "REMOVE_TOAST":
119+
if (action.toastId === undefined) {
120+
return {
121+
...state,
122+
toasts: [],
123+
}
124+
}
125+
return {
126+
...state,
127+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
128+
}
129+
}
130+
}
131+
132+
const listeners: Array<(state: State) => void> = []
133+
134+
let memoryState: State = { toasts: [] }
135+
136+
function dispatch(action: Action) {
137+
memoryState = reducer(memoryState, action)
138+
listeners.forEach((listener) => {
139+
listener(memoryState)
140+
})
141+
}
142+
143+
type Toast = Omit<ToasterToast, "id">
144+
145+
function toast({ ...props }: Toast) {
146+
const id = genId()
147+
148+
const update = (props: ToasterToast) =>
149+
dispatch({
150+
type: "UPDATE_TOAST",
151+
toast: { ...props, id },
152+
})
153+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154+
155+
dispatch({
156+
type: "ADD_TOAST",
157+
toast: {
158+
...props,
159+
id,
160+
open: true,
161+
onOpenChange: (open) => {
162+
if (!open) dismiss()
163+
},
164+
},
165+
})
166+
167+
return {
168+
id: id,
169+
dismiss,
170+
update,
171+
}
172+
}
173+
174+
function useToast() {
175+
const [state, setState] = React.useState<State>(memoryState)
176+
177+
React.useEffect(() => {
178+
listeners.push(setState)
179+
return () => {
180+
const index = listeners.indexOf(setState)
181+
if (index > -1) {
182+
listeners.splice(index, 1)
183+
}
184+
}
185+
}, [state])
186+
187+
return {
188+
...state,
189+
toast,
190+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191+
}
192+
}
193+
194+
export { useToast, toast }

0 commit comments

Comments
 (0)