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:
Tomas Rimkus
2026-10-04 00:00:15 +00:00
committed by capy-ai-staging[bot]
co-authored by Claude Opus 5
parent ca56137ca0
commit 9ba2b33069
3 changed files with 99 additions and 3 deletions
@@ -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 ? (
<ToastAction
altText="Copy the full error text"
onClick={() => {
void navigator.clipboard.writeText(condensed.full);
}}
>
Copy
</ToastAction>
) : undefined,
});
}
} catch {
+67
View File
@@ -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 };
}