diff --git a/app/src/components/ui/toast.tsx b/app/src/components/ui/toast.tsx index 35150afd..8243b6d4 100644 --- a/app/src/components/ui/toast.tsx +++ b/app/src/components/ui/toast.tsx @@ -22,7 +22,9 @@ const ToastViewport = React.forwardRef< ToastViewport.displayName = ToastPrimitives.Viewport.displayName; 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: { variant: { @@ -98,7 +100,15 @@ const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/app/src/lib/hooks/useGenerationProgress.ts b/app/src/lib/hooks/useGenerationProgress.tsx similarity index 86% rename from app/src/lib/hooks/useGenerationProgress.ts rename to app/src/lib/hooks/useGenerationProgress.tsx index 23eb9623..d81e9547 100644 --- a/app/src/lib/hooks/useGenerationProgress.ts +++ b/app/src/lib/hooks/useGenerationProgress.tsx @@ -1,7 +1,9 @@ import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; +import { ToastAction } from '@/components/ui/toast'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; +import { condenseError } from '@/lib/utils/errorText'; import { useGenerationSettings } from '@/lib/hooks/useSettings'; import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -131,10 +133,27 @@ export function useGenerationProgress() { queryClient.refetchQueries({ queryKey: ['history'] }); + const condensed = condenseError( + data.error || 'An error occurred during generation', + ); toast({ 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', + // Only offered when there is more to read than what is shown, so + // the common short error keeps a plain toast. + action: condensed.truncated ? ( + { + void navigator.clipboard.writeText(condensed.full); + }} + > + Copy + + ) : undefined, }); } } catch { diff --git a/app/src/lib/utils/errorText.ts b/app/src/lib/utils/errorText.ts new file mode 100644 index 00000000..9a41373e --- /dev/null +++ b/app/src/lib/utils/errorText.ts @@ -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 }; +}