mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 17:15:19 -07:00
fix(ui): make long error toasts readable and copyable
A failed generation put the server's error straight into a toast. The transformers "Unrecognized model" error is ~4.8KB, of which the first sentence carries the meaning and the remaining 4.7KB is an alphabetical list of every architecture it knows. In a 420px toast with overflow-hidden and no scroll, that clipped the text at both ends and pushed the close button off-screen: unreadable and undismissable. - ToastDescription is capped at 40vh and scrolls, wraps on whitespace and breaks long unspaced tokens so a path cannot widen the toast. - The toast aligns to the start rather than centring, so the title stays visible next to a tall description. - condenseError() keeps the head of an oversized error, cutting at the first newline or the last sentence end inside a 400-char budget, and reports how many characters it dropped. Short errors pass through untouched. - When it does truncate, the toast offers a Copy action for the full text and points at Settings -> Logs. Verified against the real 4795-char error: 4795 -> 400 chars keeping both meaningful sentences. The hook moves to .tsx to render ToastAction, matching useAutoUpdater.tsx which is a .tsx hook for the same reason. createElement was tried first but this repo's ToastActionElement type is the older shadcn definition (ReactElement<typeof ToastAction>) which only accepts JSX-constructed elements. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
committed by
capy-ai-staging[bot]
co-authored by
Claude Opus 5
parent
ca56137ca0
commit
9ba2b33069
@@ -22,7 +22,9 @@ const ToastViewport = React.forwardRef<
|
|||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
const toastVariants = cva(
|
||||||
'group pointer-events-auto relative 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=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',
|
// items-start rather than items-center: a long description scrolls inside a
|
||||||
|
// capped box, and centring it would push the title out of view.
|
||||||
|
'group pointer-events-auto relative flex w-full items-start justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 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',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -98,7 +100,15 @@ const ToastDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Description
|
<ToastPrimitives.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-sm opacity-90', className)}
|
// A server traceback can run to thousands of characters. Unbounded, it
|
||||||
|
// overflowed the viewport and got clipped at both ends with the close
|
||||||
|
// button pushed off-screen, so the text was unreadable and the toast
|
||||||
|
// undismissable. Cap it and let it scroll; break-words keeps a long
|
||||||
|
// unspaced token (a path, a URL) from widening the toast.
|
||||||
|
className={cn(
|
||||||
|
'max-h-[40vh] overflow-y-auto overscroll-contain whitespace-pre-wrap break-words pr-1 text-sm opacity-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
+20
-1
@@ -1,7 +1,9 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { condenseError } from '@/lib/utils/errorText';
|
||||||
import { useGenerationSettings } from '@/lib/hooks/useSettings';
|
import { useGenerationSettings } from '@/lib/hooks/useSettings';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
@@ -131,10 +133,27 @@ export function useGenerationProgress() {
|
|||||||
|
|
||||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
const condensed = condenseError(
|
||||||
|
data.error || 'An error occurred during generation',
|
||||||
|
);
|
||||||
toast({
|
toast({
|
||||||
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||||
description: data.error || 'An error occurred during generation',
|
description: condensed.truncated
|
||||||
|
? `${condensed.display}\n\n(${condensed.omitted} more characters — copy for the full error, or see Settings → Logs)`
|
||||||
|
: condensed.display,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
|
// Only offered when there is more to read than what is shown, so
|
||||||
|
// the common short error keeps a plain toast.
|
||||||
|
action: condensed.truncated ? (
|
||||||
|
<ToastAction
|
||||||
|
altText="Copy the full error text"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(condensed.full);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</ToastAction>
|
||||||
|
) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Condense a server error for display in a toast.
|
||||||
|
*
|
||||||
|
* Some backend errors are enormous and mostly noise. The transformers
|
||||||
|
* "Unrecognized model" error is ~4.8KB, of which the first sentence carries all
|
||||||
|
* the meaning and the remaining 4.7KB is an alphabetical list of every model
|
||||||
|
* architecture it knows about. Rendering that in a 420px toast clipped the text
|
||||||
|
* at both ends and pushed the close button off-screen.
|
||||||
|
*
|
||||||
|
* The rule is deliberately generic rather than pattern-matching any one
|
||||||
|
* library: keep the head, cut at the most natural boundary available inside the
|
||||||
|
* budget, and report how much was dropped so nobody assumes they read it all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Characters of an error worth putting in a toast. Roughly the first
|
||||||
|
* paragraph — enough for a sentence or two of real message. */
|
||||||
|
const TOAST_ERROR_BUDGET = 400;
|
||||||
|
|
||||||
|
/** Below this there is nothing to gain by condensing. */
|
||||||
|
const MIN_TO_CONDENSE = TOAST_ERROR_BUDGET + 120;
|
||||||
|
|
||||||
|
export interface CondensedError {
|
||||||
|
/** What to show in the toast. */
|
||||||
|
display: string;
|
||||||
|
/** The untouched original, for copying. */
|
||||||
|
full: string;
|
||||||
|
/** Whether `display` is shorter than `full`. */
|
||||||
|
truncated: boolean;
|
||||||
|
/** How many characters `display` leaves out. */
|
||||||
|
omitted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function condenseError(raw: string | null | undefined): CondensedError {
|
||||||
|
const full = (raw ?? '').trim();
|
||||||
|
|
||||||
|
if (full.length <= MIN_TO_CONDENSE) {
|
||||||
|
return { display: full, full, truncated: false, omitted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A traceback's first line is nearly always the message; prefer it whenever
|
||||||
|
// it fits, since a newline is a stronger boundary than any punctuation.
|
||||||
|
const firstLine = full.split('\n', 1)[0].trim();
|
||||||
|
let head =
|
||||||
|
firstLine.length > 0 && firstLine.length <= TOAST_ERROR_BUDGET
|
||||||
|
? firstLine
|
||||||
|
: full.slice(0, TOAST_ERROR_BUDGET);
|
||||||
|
|
||||||
|
if (head.length < full.length && head === full.slice(0, head.length)) {
|
||||||
|
// Back off to the last sentence end inside the budget so the text does not
|
||||||
|
// stop mid-word. Only accept it if it keeps most of the budget — otherwise
|
||||||
|
// a stray early period would throw away usable context.
|
||||||
|
const lastStop = Math.max(head.lastIndexOf('. '), head.lastIndexOf('? '));
|
||||||
|
if (lastStop > TOAST_ERROR_BUDGET * 0.4) {
|
||||||
|
head = head.slice(0, lastStop + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
head = head.trimEnd();
|
||||||
|
const omitted = full.length - head.length;
|
||||||
|
|
||||||
|
// Guard against the boundary search having produced nothing shorter.
|
||||||
|
if (omitted <= 0) {
|
||||||
|
return { display: full, full, truncated: false, omitted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { display: `${head} …`, full, truncated: true, omitted };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user