studio: add dismissable toasts with corner close button (#5509)

* studio: add dismissable toasts with corner close button

- Enable Sonner's close button globally on the Toaster, so every toast
  (model load progress, model loaded, load failure, etc.) gets an X that
  users can click to dismiss without waiting for the auto-dismiss timer.
  This matches the Claude desktop notification behavior.
- Drop the per-toast 'closeButton: false' overrides in the model load
  runtime so they inherit the global default. The existing 'onDismiss'
  handler already flips state to show an inline header status, so the
  X on the loading toast hides the toast without canceling the load
  (Cancel still aborts).
- Pin the close button to the top-right corner inside the toast box.
  Overrides Sonner's left-side default placement, outside-corner
  translate, and hardcoded 'top: 0'. Top is set via a small rule in
  index.css because Sonner does not expose it as a CSS variable.
- Add a small offset on the Toaster so toasts sit at the chat header
  line, shifted left of the parameters and settings buttons on the
  right edge instead of stacking on top of them.
- Bump the post-load success and failure durations from 2s and 5s to
  8s so users actually have time to read and click the new close X
  before the toast auto-dismisses.

* studio: explicit boolean for closeButton prop to satisfy biome

* studio: keep close button X visible in dark mode

Two defensive fixes for the dark-mode close button visibility:

- Use resolvedTheme so sonner's data-sonner-theme always matches the
  class next-themes applies to <html>. Passing theme can be 'system',
  which makes sonner resolve via its own media query; that can disagree
  with next-themes (Tauri webview, hydration races, OS quirks), leaving
  CSS vars dark while sonner still applies its light close-button colors
  (dark X on dark background).
- Bump the close-icon stroke from sonner's default 1.5 to 2.25 so the X
  is readable on a 12x12 svg sitting on dark backgrounds.

---------

Co-authored-by: shimmyshimmer <datta_mike@hotmail.com>
This commit is contained in:
Michael Han 2026-05-18 03:47:36 -07:00 committed by GitHub
commit eb6b0c6db6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 30 additions and 12 deletions

View file

@ -291,7 +291,14 @@ export function AppProvider({ children }: AppProviderProps) {
<TauriWrapper>
{children}
</TauriWrapper>
<Toaster position="top-right" visibleToasts={2} expand={true} />
<Toaster
position="top-right"
visibleToasts={2}
expand={true}
closeButton={true}
// Clear the chat header buttons on the right.
offset={{ top: 12, right: 64 }}
/>
</ThemeProvider>
);
}

View file

@ -13,11 +13,13 @@ import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
// Use resolvedTheme so sonner's data-sonner-theme always matches the class
// next-themes puts on <html>; sonner-side "system" resolution can drift.
const { resolvedTheme } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={(resolvedTheme as ToasterProps["theme"]) ?? "light"}
className="toaster group"
duration={5000}
icons={{
@ -63,6 +65,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
// Pin close button to the top-right corner inside the toast.
// Overrides sonner's default left placement and outside-corner
// translate; top offset is set via a rule in index.css since sonner
// hardcodes `top: 0` (not a CSS variable).
"--toast-close-button-start": "unset",
"--toast-close-button-end": "8px",
"--toast-close-button-transform": "none",
} as React.CSSProperties
}
// No swipe gestures; keeps toast text selectable.
@ -71,7 +80,6 @@ const Toaster = ({ ...props }: ToasterProps) => {
classNames: {
toast: "cn-toast",
description: "!text-muted-foreground",
closeButton: "!top-3 !right-3 !translate-y-0",
},
}}
{...props}

View file

@ -729,7 +729,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) {
@ -852,7 +851,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
@ -892,7 +890,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
@ -955,7 +952,6 @@ export function useChatModelRuntime() {
cancelLoading,
),
duration: Infinity,
closeButton: false,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
@ -987,8 +983,7 @@ export function useChatModelRuntime() {
toast.success(`${displayName} loaded`, {
id: toastId,
description: undefined,
closeButton: false,
duration: 2000,
duration: 8000,
});
}
notifyNative({
@ -1007,8 +1002,7 @@ export function useChatModelRuntime() {
toast.error(message, {
id: toastId,
description: undefined,
closeButton: false,
duration: 5000,
duration: 8000,
});
}
notifyNative({

View file

@ -1160,3 +1160,12 @@
animation: none;
mix-blend-mode: normal;
}
/* Override sonner's hardcoded top: 0 on the toast close button. */
[data-sonner-toast][data-styled="true"] [data-close-button] {
top: 8px !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;
}