ErrorMessage

Render a failed assistant turn. retry shows a live countdown to the next automatic attempt, onRetry adds a button to retry right away, and variant="warning" with resetsAt fits usage limits that lift at a known time.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Basic
Retry countdown
Usage limit
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

ErrorMessage/ErrorMessage.tsx
import React, { memo, useMemo, useState } from 'react';
import { Box, Button, VisuallyHidden } from '@mantine/core';
import { useCountdown } from '../hooks/use-countdown';
import { cx } from '../utils/cx';
import { truncateErrorMessage } from './error-message';
import classes from './ErrorMessage.module.css';

export type ErrorMessageRetry = {
  /** Current retry attempt, 1-based */
  attempt: number;
  /** Total attempts the client will make */
  maxAttempts?: number;
  /** When the next attempt starts, as a timestamp in ms or a `Date` */
  retryAt: number | Date;
};

export type ErrorMessageProps = {
  /** Card title, `Something went wrong` by default */
  title?: string;
  /** Error details shown under the title */
  message: string;
  /** Visual tone: `error` for failures, `warning` for rate limits and transient problems */
  variant?: 'error' | 'warning';
  /** Automatic retry schedule, renders a live countdown */
  retry?: ErrorMessageRetry;
  /** When a usage limit resets; rendered as a local time */
  resetsAt?: number | Date;
  /** Collapses a message longer than 6 lines or 600 characters behind "Show more", `false` by default */
  collapsible?: boolean;
  /** Renders a retry button */
  onRetry?: () => void;
  /** Overrides of the default English labels */
  labels?: Partial<ErrorMessageLabels>;
  /** Class name added to the root element */
  className?: string;
};

export interface ErrorMessageLabels {
  /** Retry button, `Retry` by default */
  retry: string;
  /** Countdown text of an automatic retry, `secondsLeft` is `0` once the attempt has started */
  retrying: (secondsLeft: number, retry: ErrorMessageRetry) => string;
  /** Reset time text, receives the formatted local time, `Available again at {time}` by default */
  resetsAt: (time: string) => string;
  /** Button that expands a long collapsed message, `Show more` by default */
  showMore: string;
  /** Button that collapses an expanded long message, `Show less` by default */
  showLess: string;
}

function formatRetrying(secondsLeft: number, retry: ErrorMessageRetry): string {
  const attempt = retry.maxAttempts
    ? `${retry.attempt} of ${retry.maxAttempts}`
    : `${retry.attempt}`;
  return secondsLeft > 0 ? `Next try in ${secondsLeft}s · ${attempt}` : `Trying again · ${attempt}`;
}

function formatResetsAt(time: string): string {
  return `Available again at ${time}`;
}

export const DEFAULT_ERROR_MESSAGE_LABELS: ErrorMessageLabels = {
  retry: 'Retry',
  retrying: formatRetrying,
  resetsAt: formatResetsAt,
  showMore: 'Show more',
  showLess: 'Show less',
};

/** Inline error card rendered in place of an assistant reply */
export const ErrorMessage = memo(function ErrorMessage({
  title = 'Something went wrong',
  message,
  variant = 'error',
  retry,
  resetsAt,
  collapsible = false,
  onRetry,
  labels: labelsProp,
  className,
}: ErrorMessageProps) {
  const labels = { ...DEFAULT_ERROR_MESSAGE_LABELS, ...labelsProp };
  const secondsLeft = useCountdown(retry?.retryAt);
  const [expanded, setExpanded] = useState(false);
  const shortened = useMemo(
    () => (collapsible ? truncateErrorMessage(message) : { text: message, truncated: false }),
    [collapsible, message]
  );

  const resetsAtText = useMemo(() => {
    if (resetsAt === undefined) {
      return null;
    }
    const time = new Intl.DateTimeFormat(undefined, {
      hour: '2-digit',
      minute: '2-digit',
    }).format(resetsAt instanceof Date ? resetsAt : new Date(resetsAt));
    return labels.resetsAt(time);
  }, [resetsAt, labels.resetsAt]);

  const hasFooter = Boolean(retry || resetsAtText || onRetry);

  return (
    <Box className={cx(classes.root, className)}>
      <div className={classes.card} data-variant={variant}>
        <div className={classes.title}>{title}</div>
        <div className={classes.message} data-collapsible={collapsible || undefined}>
          {shortened.truncated && !expanded ? shortened.text : message}
        </div>
        {shortened.truncated && (
          <Button
            variant="subtle"
            color="gray"
            size="compact-xs"
            className={classes.toggle}
            aria-expanded={expanded}
            onClick={() => setExpanded((value) => !value)}
          >
            {expanded ? labels.showLess : labels.showMore}
          </Button>
        )}
        {hasFooter && (
          <div className={classes.footer}>
            <div className={classes.meta}>
              {retry && secondsLeft !== undefined && (
                <span>{labels.retrying(secondsLeft, retry)}</span>
              )}
              {retry && (
                <VisuallyHidden aria-live="polite">
                  {secondsLeft !== undefined && secondsLeft <= 0 ? labels.retrying(0, retry) : ''}
                </VisuallyHidden>
              )}
              {resetsAtText && <span>{resetsAtText}</span>}
            </div>
            {onRetry && (
              <Button size="compact-xs" variant="default" onClick={onRetry}>
                {labels.retry}
              </Button>
            )}
          </div>
        )}
      </div>
    </Box>
  );
});

ErrorMessage.displayName = 'ErrorMessage';
API reference
Prop
Type
Required
title
string
No
Card title, `Something went wrong` by default
message
string
Yes
Error details shown under the title
variant
'error' | 'warning'
No
Visual tone: `error` for failures, `warning` for rate limits and transient problems
retry
ErrorMessageRetry
No
Automatic retry schedule, renders a live countdown
resetsAt
number | Date
No
When a usage limit resets; rendered as a local time
collapsible
boolean
No
Collapses a message longer than 6 lines or 600 characters behind "Show more", `false` by default
onRetry
() => void
No
Renders a retry button
labels
Partial<ErrorMessageLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element