Skip to content

Commit 989d86a

Browse files
fix(frontend): make long error toasts readable and copyable (#10243)
## 📝 Summary Long error toasts (e.g. Playwright/PDF export failures) were clipped by the toast chrome, could not be scrolled or selected, and were hard to copy. - Preserve whitespace and wrap long paths in toast descriptions - Make descriptions scrollable and selectable without fighting swipe-to-dismiss - Add `CopyClipboardIcon` on danger toasts (matching close-button chrome) - Add a smoke notebook for exercising toast error shapes - Ignore local `.pnpm-store/` directories <img width="374" height="237" alt="image" src="https://github.com/user-attachments/assets/f6d00b83-48a5-4c19-a6f7-2efbe1ae648e" /> ## 📋 Pre-Review Checklist - [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 462de97 commit 989d86a

4 files changed

Lines changed: 182 additions & 12 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ npm-debug.log*
109109
yarn-debug.log*
110110
yarn-error.log*
111111
pnpm-debug.log*
112+
.pnpm-store/
112113

113114
marimo/_static/
114115
marimo/_lsp/

frontend/src/components/ui/toast.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const VARIANT_CLASSES = {
3434
type ToastVariant = keyof typeof VARIANT_CLASSES;
3535

3636
const toastVariants = cva(
37-
"data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full",
37+
"data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-start justify-between gap-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full",
3838
{
3939
variants: {
4040
variant: VARIANT_CLASSES,
@@ -117,10 +117,18 @@ ToastTitle.displayName = ToastPrimitives.Title.displayName;
117117
const ToastDescription = React.forwardRef<
118118
React.ElementRef<typeof ToastPrimitives.Description>,
119119
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
120-
>(({ className, ...props }, ref) => (
120+
>(({ className, onPointerDown, ...props }, ref) => (
121121
<ToastPrimitives.Description
122122
ref={ref}
123-
className={cn("text-sm opacity-90", className)}
123+
className={cn(
124+
"max-h-48 overflow-auto text-sm opacity-90 whitespace-pre-wrap wrap-break-word select-text cursor-text",
125+
className,
126+
)}
127+
// Keep swipe-to-dismiss from fighting text selection / scroll.
128+
onPointerDown={(event) => {
129+
event.stopPropagation();
130+
onPointerDown?.(event);
131+
}}
124132
{...props}
125133
/>
126134
));
Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/* Copyright 2026 Marimo. All rights reserved. */
2+
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
3+
import { useRef } from "react";
4+
import { CopyClipboardIcon } from "@/components/icons/copy-icon";
25
import { useToast } from "@/components/ui/use-toast";
6+
import { cn } from "@/utils/cn";
37
import {
48
Toast,
59
ToastClose,
@@ -14,17 +18,69 @@ export const Toaster = () => {
1418

1519
return (
1620
<ToastProvider>
17-
{toasts.map(({ id, title, description, action, ...props }) => (
18-
<Toast key={id} {...props}>
19-
<div className="grid gap-1">
20-
{title && <ToastTitle>{title}</ToastTitle>}
21-
{description && <ToastDescription>{description}</ToastDescription>}
22-
</div>
23-
{action}
24-
<ToastClose />
25-
</Toast>
21+
{toasts.map(({ id, title, description, action, variant, ...props }) => (
22+
<ToastItem
23+
key={id}
24+
toastTitle={title}
25+
toastDescription={description}
26+
action={action}
27+
variant={variant}
28+
{...props}
29+
/>
2630
))}
2731
<ToastViewport />
2832
</ToastProvider>
2933
);
3034
};
35+
36+
type ToastItemProps = Omit<
37+
ComponentPropsWithoutRef<typeof Toast>,
38+
"title" | "children"
39+
> & {
40+
toastTitle?: ReactNode;
41+
toastDescription?: ReactNode;
42+
action?: ReactElement;
43+
};
44+
45+
const ToastItem = ({
46+
toastTitle,
47+
toastDescription,
48+
action,
49+
variant,
50+
...props
51+
}: ToastItemProps) => {
52+
const descriptionRef = useRef<HTMLDivElement>(null);
53+
// Show copy button for danger toasts without action, typically used for errors.
54+
const showCopy = Boolean(toastDescription) && !action && variant === "danger";
55+
56+
return (
57+
<Toast variant={variant} {...props}>
58+
<div className="grid min-w-0 flex-1 gap-1">
59+
{toastTitle && <ToastTitle>{toastTitle}</ToastTitle>}
60+
{toastDescription && (
61+
<ToastDescription ref={descriptionRef}>
62+
{toastDescription}
63+
</ToastDescription>
64+
)}
65+
</div>
66+
{action}
67+
{showCopy && (
68+
<CopyClipboardIcon
69+
tooltip="Copy error"
70+
ariaLabel="Copy error"
71+
className="h-4 w-4"
72+
buttonClassName={cn(
73+
"absolute right-8 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity",
74+
"hover:text-foreground focus:opacity-100 focus:outline-hidden group-hover:opacity-100",
75+
"group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-500 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
76+
)}
77+
value={() =>
78+
descriptionRef.current?.innerText ??
79+
(typeof toastDescription === "string" ? toastDescription : "")
80+
}
81+
/>
82+
)}
83+
<ToastClose />
84+
</Toast>
85+
);
86+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import marimo
2+
3+
__generated_with = "0.23.14"
4+
app = marimo.App(width="medium")
5+
6+
7+
@app.cell
8+
def _():
9+
import marimo as mo
10+
11+
return (mo,)
12+
13+
14+
@app.cell
15+
def _(mo):
16+
mo.md(r"""
17+
# Toast error formatting smoke test
18+
19+
Trigger each case and check: wrapping, scrolling, text selection, and copy.
20+
""")
21+
return
22+
23+
24+
@app.cell
25+
def cases(mo):
26+
from html import escape
27+
28+
LONG_PATH = (
29+
"/Users/example/Library/Caches/ms-playwright/"
30+
"chromium_headless_shell-1200/"
31+
"chrome-headless-shell-mac-arm64/chrome-headless-shell"
32+
)
33+
PLAYWRIGHT_ERR = f"""\
34+
BrowserType.launch: Executable doesn't exist at {LONG_PATH}
35+
╔════════════════════════════════════════════════════════════╗
36+
║ Looks like Playwright was just installed or updated. ║
37+
║ Please run the following command to download new browsers: ║
38+
║ ║
39+
║ playwright install ║
40+
║ ║
41+
║ <3 Playwright Team ║
42+
╚════════════════════════════════════════════════════════════╝"""
43+
LONG_STACK = """\
44+
Traceback (most recent call last):
45+
File "/Users/example/src/marimo/marimo/_server/export/_pdf_raster.py", line 601, in rasterize
46+
async with async_playwright() as playwright:
47+
File "/Users/example/src/marimo/marimo/_server/api/endpoints/export.py", line 142, in export_as_pdf
48+
pdf_bytes = await export_pdf(request)
49+
File "/Users/example/.local/share/uv/tools/marimo/lib/python3.12/site-packages/playwright/_impl/_connection.py", line 59, in send
50+
return await self._inner_send(method, dict(params), False)
51+
playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist
52+
"""
53+
54+
def fire_toast(
55+
title: str, description: str, *, as_html: bool = False, kind="danger"
56+
):
57+
if as_html:
58+
description = f'<pre style="margin:0;white-space:pre-wrap;word-break:break-word">{escape(description)}</pre>'
59+
mo.status.toast(title, description, kind)
60+
61+
cases = mo.ui.dropdown(
62+
options={
63+
"short": "short",
64+
"long path (single line)": "long_path",
65+
"playwright ascii box (plain)": "playwright_plain",
66+
"playwright ascii box (html pre)": "playwright_html",
67+
"tall traceback (plain)": "tall_plain",
68+
"tall traceback (html pre)": "tall_html",
69+
"success (control)": "success",
70+
},
71+
value="playwright ascii box (plain)",
72+
label="Error shape",
73+
)
74+
75+
fire = mo.ui.run_button(label="Show toast")
76+
mo.hstack([cases, fire], justify="start", gap=1)
77+
return LONG_PATH, LONG_STACK, PLAYWRIGHT_ERR, cases, fire, fire_toast
78+
79+
80+
@app.cell
81+
def trigger(LONG_PATH, LONG_STACK, PLAYWRIGHT_ERR, cases, fire, fire_toast, mo):
82+
if fire.value:
83+
choice = cases.value
84+
if choice == "short":
85+
fire_toast("Failed to download", "Something went wrong.")
86+
elif choice == "long_path":
87+
fire_toast(
88+
"Failed to download",
89+
f"BrowserType.launch: Executable doesn't exist at {LONG_PATH}",
90+
)
91+
elif choice == "playwright_plain":
92+
fire_toast("Failed to download", PLAYWRIGHT_ERR)
93+
elif choice == "playwright_html":
94+
fire_toast("Failed to download", PLAYWRIGHT_ERR, as_html=True)
95+
elif choice == "tall_plain":
96+
fire_toast("Failed to download", LONG_STACK)
97+
elif choice == "tall_html":
98+
fire_toast("Failed to download", LONG_STACK, as_html=True)
99+
elif choice == "success":
100+
mo.status.toast("All good", "This is a short success toast.")
101+
return
102+
103+
104+
if __name__ == "__main__":
105+
app.run()

0 commit comments

Comments
 (0)