From c4908b7929a25fc8f228e7da96164800c4466dbf Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 19 May 2026 00:55:55 -0700 Subject: [PATCH] studio: fix toast close-button click and light-mode hover (#5597) Two related issues on the chat toasts: 1. Close X did nothing. The lib/toast.ts wrapper defaulted every toast to `dismissible: false` (originally to keep swipe capture from stealing text selection). In sonner v2, `dismissible: false` makes the close-button onClick a no-op, so the X looked clickable but never dismissed the toast. The Toaster already sets `swipeDirections={[]}` in components/ui/sonner.tsx, so the per-toast swipe workaround is unnecessary and harmful. Replace the wrapper with a thin re-export of sonner. 2. Close X hover collapsed to a near-black circle in light mode. Sonner's default close-button styling uses fixed gray-scale tokens (--gray2 hover, --gray12 text) that ignore the theme attribute. Once the Toaster's inline style overrides --normal-bg with var(--popover), the base background follows the app theme but the hover state does not, so the hover bg lands on a color that has no contrast with the X glyph. Pin both base and hover to theme tokens (--popover, --muted, --popover-foreground, --border) so contrast stays visible in both light and dark modes. Repro: open chat, load any cached model, hover the X on the " loaded" toast in light mode -- before this change the circle turned dark and the click did nothing; after, the circle stays light and the click dismisses the toast. --- studio/frontend/src/index.css | 11 ++++-- studio/frontend/src/lib/toast.ts | 57 ++------------------------------ 2 files changed, 12 insertions(+), 56 deletions(-) diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index dc73112994..7afa87f597 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -1172,11 +1172,18 @@ mix-blend-mode: normal; } -/* Override sonner's hardcoded top: 0 on the toast close button. */ +/* Override sonner top: 0 and pin to theme tokens (--gray2 hover ignores data-sonner-theme). */ [data-sonner-toast][data-styled="true"] [data-close-button] { top: 8px !important; + background: var(--popover) !important; + color: var(--popover-foreground) !important; + border-color: var(--border) !important; } -/* Bump the X stroke so it stays visible against dark backgrounds. */ [data-sonner-toast][data-styled="true"] [data-close-button] svg { stroke-width: 2.25; } +[data-sonner-toast][data-styled="true"]:hover [data-close-button]:hover { + background: var(--muted) !important; + color: var(--popover-foreground) !important; + border-color: var(--border) !important; +} diff --git a/studio/frontend/src/lib/toast.ts b/studio/frontend/src/lib/toast.ts index 138ceb4c4d..6b1635b42e 100644 --- a/studio/frontend/src/lib/toast.ts +++ b/studio/frontend/src/lib/toast.ts @@ -1,59 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -// sonner `toast` wrapper that defaults `dismissible: false` so swipe -// capture doesn't block text selection. Drop-in for `from "sonner"`. - -import { toast as sonnerToast, type ExternalToast } from "sonner"; - -type AnyFn = (...args: unknown[]) => unknown; - -function withDismissibleFalse(fn: F): F { - return ((...args: unknown[]) => { - // Branch by arity: React-element messages are objects too. - if (args.length <= 1) { - args.push({ dismissible: false } satisfies ExternalToast); - } else { - const lastIdx = args.length - 1; - const last = args[lastIdx]; - if (last && typeof last === "object" && !Array.isArray(last)) { - const opts = last as ExternalToast; - if (!("dismissible" in opts)) { - args[lastIdx] = { dismissible: false, ...opts }; - } - } - } - return fn(...args); - }) as F; -} - -const wrappedCallable = withDismissibleFalse( - sonnerToast as unknown as AnyFn, -) as typeof sonnerToast; - -// `promise(p, data?)` carries `dismissible` at the top of `data`, -// covering loading / success / error states. `dismiss`, `getHistory`, -// `getToasts` take no options. -const wrappedPromise: typeof sonnerToast.promise = ((promise, data) => { - const merged = - data && typeof data === "object" && !("dismissible" in data) - ? { dismissible: false, ...data } - : (data ?? { dismissible: false }); - return sonnerToast.promise(promise, merged); -}) as typeof sonnerToast.promise; - -export const toast: typeof sonnerToast = Object.assign(wrappedCallable, { - success: withDismissibleFalse(sonnerToast.success.bind(sonnerToast) as AnyFn) as typeof sonnerToast.success, - info: withDismissibleFalse(sonnerToast.info.bind(sonnerToast) as AnyFn) as typeof sonnerToast.info, - warning: withDismissibleFalse(sonnerToast.warning.bind(sonnerToast) as AnyFn) as typeof sonnerToast.warning, - error: withDismissibleFalse(sonnerToast.error.bind(sonnerToast) as AnyFn) as typeof sonnerToast.error, - message: withDismissibleFalse(sonnerToast.message.bind(sonnerToast) as AnyFn) as typeof sonnerToast.message, - loading: withDismissibleFalse(sonnerToast.loading.bind(sonnerToast) as AnyFn) as typeof sonnerToast.loading, - custom: withDismissibleFalse(sonnerToast.custom.bind(sonnerToast) as AnyFn) as typeof sonnerToast.custom, - promise: wrappedPromise, - dismiss: sonnerToast.dismiss.bind(sonnerToast) as typeof sonnerToast.dismiss, - getHistory: sonnerToast.getHistory.bind(sonnerToast) as typeof sonnerToast.getHistory, - getToasts: sonnerToast.getToasts.bind(sonnerToast) as typeof sonnerToast.getToasts, -}); +// Re-export of sonner. Swipe blocking lives on the Toaster via +// `swipeDirections={[]}`, so no per-toast dismissible override. +export { toast } from "sonner"; export type { ExternalToast } from "sonner";