Skip to content

Commit ffaf197

Browse files
myieyeclaude
andcommitted
Improve error toast readability
Error toasts could grow taller than the screen and sometimes showed the same message in both the title and the description. Cap the title height, split .NET error strings at the inner-exception marker (" ---> ") as well as the first stack frame so the title stays short, and de-duplicate JS errors whose stack already repeats the message. Also fix the copy-button checkmark, which rendered white (invisible) in light mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff6ac34 commit ffaf197

3 files changed

Lines changed: 70 additions & 5 deletions

File tree

frontend/viewer/src/lib/components/ui/sonner/sonner.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ I.e. If there's small, big, small, then sonner thinks the big one is small and p
3434
classes: {
3535
toast:
3636
'gap-3 group toast data-[expanded="true"]:h-max! data-[expanded="true"]:max-h-max group-[.toaster]:bg-background! group-[.toaster]:border-border! group-[.toaster]:shadow-lg',
37+
title: 'line-clamp-4',
3738
description:
3839
'group-[.toast]:text-muted-foreground! max-h-[30vh] overflow-y-auto whitespace-break-spaces touch-pan-y' /* pan-y means the browser should handle y-scrolling (and NOT x-scrolling, which is for swiping away) */,
3940
actionButton: buttonVariants({
4041
size: 'sm',
4142
variant: 'default',
4243
class:
43-
'group-[.toast]:bg-primary! group-[.toast]:text-primary-foreground! h-9! min-h-9 px-3! group-[.toast[data-type="error"]]:i-mdi-content-copy [&.copied]:i-mdi-check! group-[.toast[data-promise="true"][data-type="loading"]]:hidden!',
44+
'group-[.toast]:bg-primary! group-[.toast]:text-primary-foreground! h-9! min-h-9 px-3! group-[.toast[data-type="error"]]:i-mdi-content-copy group-[.toast[data-type="error"]]:[&.copied]:i-mdi-check group-[.toast[data-promise="true"][data-type="loading"]]:hidden!',
4445
}),
4546
cancelButton: buttonVariants({
4647
size: 'sm',
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {describe, expect, it} from 'vitest';
2+
3+
import {processErrorIntoDetails} from './global-errors';
4+
5+
describe('processErrorIntoDetails', () => {
6+
it('splits a .NET error at the first stack frame', () => {
7+
const message = [
8+
'System.InvalidOperationException: Everything is broken.',
9+
' at FwLiteShared.Services.Foo.Bar(String x)',
10+
' at FwLiteShared.Services.Foo.Baz()',
11+
].join('\n');
12+
13+
const {message: title, detail} = processErrorIntoDetails({message, error: null});
14+
15+
expect(title).toBe('System.InvalidOperationException: Everything is broken.');
16+
expect(detail).toContain('at FwLiteShared.Services.Foo.Bar(String x)');
17+
});
18+
19+
it('splits at the inner-exception marker that precedes the outer frames, keeping a wrapped error\'s title short', () => {
20+
// MSAL wrapping an Android network failure: the whole " ---> " cascade comes before the first managed frame
21+
const message = [
22+
'Microsoft.Identity.Client.MsalServiceException: Failed to retrieve OIDC configuration. See inner exception.',
23+
' ---> System.Net.Http.HttpRequestException: Connection failure',
24+
' ---> Java.Net.UnknownHostException: Unable to resolve host',
25+
' at Java.Interop.JniEnvironment.InstanceMethods.CallVoidMethod()',
26+
].join('\n');
27+
28+
const {message: title, detail} = processErrorIntoDetails({message, error: null});
29+
30+
expect(title).toBe('Microsoft.Identity.Client.MsalServiceException: Failed to retrieve OIDC configuration. See inner exception.');
31+
expect(detail).toContain('---> System.Net.Http.HttpRequestException: Connection failure');
32+
});
33+
34+
it('keeps the whole message as the title when there is no .NET stack', () => {
35+
const {message: title, detail} = processErrorIntoDetails({message: 'plain error', error: null});
36+
37+
expect(title).toBe('plain error');
38+
expect(detail).toBeUndefined();
39+
});
40+
41+
it('does not repeat a JS Error message in both title and detail', () => {
42+
const error = new Error('boom');
43+
44+
const {message: title, detail} = processErrorIntoDetails({message: 'Uncaught Error: boom', error});
45+
46+
expect(title).toBe('boom');
47+
expect(detail).not.toContain('boom');
48+
expect(detail).toContain('at ');
49+
});
50+
});

frontend/viewer/src/lib/errors/global-errors.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,34 @@ function shouldIgnoreError(message: string): boolean {
3737
return false;
3838
}
3939

40-
/** Matches messages/stack traces of the format:
40+
/** Splits a .NET error string into its leading message and the rest (stack/inner exceptions), at whichever
41+
comes first: the first stack frame (" at ") or the first inner-exception marker (" ---> "). The latter matters
42+
because .NET prints the whole inner-exception chain before the outer frames, so without it a deeply-wrapped
43+
error (e.g. MSAL wrapping an Android network failure) dumps the entire cascade into the title.
4144
System.InvalidOperationException: Everything is broken. Here's some ice cream.
4245
at FwLiteShared.Services.ProjectServicesProvider.OpenCrdtProject(String projectName)
4346
*/
44-
const dotnetErrorRegex = /^([\s\S]+?) {3}at /m;
47+
const dotnetErrorRegex = /^([\s\S]+?)(?: {3}at | ---> )/m;
4548

46-
function processErrorIntoDetails(event: UnifiedErrorEvent): {message: string, detail?: string} {
49+
export function processErrorIntoDetails(event: UnifiedErrorEvent): {message: string, detail?: string} {
4750
const message = event.message;
4851
const match = dotnetErrorRegex.exec(message);
4952
if (match) return {message: match[1].trim(), detail: message.substring(match[1].length).trim()};
50-
else if (event.error instanceof Error) return {message: message, detail: event.error.stack};
53+
// A JS Error's stack repeats the message on its first line(s); use the prefix-free error message as the
54+
// title and just the frames as the detail, so the message isn't shown in both slots.
55+
else if (event.error instanceof Error) return {message: event.error.message || message, detail: stackFrames(event.error)};
5156
else return {message};
5257
}
5358

59+
function stackFrames(error: Error): string | undefined {
60+
const stack = error.stack;
61+
if (!stack) return undefined;
62+
// V8 stacks begin with error.toString() ("Error: <message>"); strip it so the detail is frames only.
63+
// Other engines (Firefox/Safari) don't prepend it, so the stack is already frame-only — leave it as-is.
64+
const header = error.toString();
65+
return stack.startsWith(header) ? stack.slice(header.length).trim() : stack;
66+
}
67+
5468
let setup = false;
5569
export function setupGlobalErrorHandlers() {
5670
if (setup) return;

0 commit comments

Comments
 (0)