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 };
+}