feat(coderd/x/chatd): add image_too_large error classification and UI highlighting

Adds a new ChatErrorKindImageTooLarge error kind for when users upload
images that exceed the provider's maximum allowed dimensions (e.g.
Anthropic's 2000 pixel limit for multi-image requests).

Backend:
- New ChatErrorKindImageTooLarge constant and classification patterns
- Detects errors containing 'image dimensions exceed' or 'max allowed
  size for many-image' and classifies them as non-retryable
- User-friendly terminal and retry messages explaining the issue and
  how to fix it

Frontend:
- 'Image too large' error title in the status callout
- ConversationTimeline identifies user messages containing image
  attachments when an image_too_large error occurs
- Affected user message bubbles get a red error border highlight
  to guide the user to the message they need to edit
This commit is contained in:
Tracy Johnson
2026-06-02 01:34:07 +00:00
parent fc01aeeb0f
commit 7b266af992
15 changed files with 105 additions and 14 deletions
+4 -2
View File
@@ -16603,7 +16603,8 @@ const docTemplate = `{
"config",
"usage_limit",
"missing_key",
"provider_disabled"
"provider_disabled",
"image_too_large"
],
"x-enum-varnames": [
"ChatErrorKindGeneric",
@@ -16615,7 +16616,8 @@ const docTemplate = `{
"ChatErrorKindConfig",
"ChatErrorKindUsageLimit",
"ChatErrorKindMissingKey",
"ChatErrorKindProviderDisabled"
"ChatErrorKindProviderDisabled",
"ChatErrorKindImageTooLarge"
]
},
"codersdk.ChatFileMetadata": {
+4 -2
View File
@@ -14941,7 +14941,8 @@
"config",
"usage_limit",
"missing_key",
"provider_disabled"
"provider_disabled",
"image_too_large"
],
"x-enum-varnames": [
"ChatErrorKindGeneric",
@@ -14953,7 +14954,8 @@
"ChatErrorKindConfig",
"ChatErrorKindUsageLimit",
"ChatErrorKindMissingKey",
"ChatErrorKindProviderDisabled"
"ChatErrorKindProviderDisabled",
"ChatErrorKindImageTooLarge"
]
},
"codersdk.ChatFileMetadata": {
+6
View File
@@ -201,6 +201,7 @@ func Classify(err error) ClassifiedError {
usageLimitMatch := containsAny(lower, usageLimitPatterns...)
authStrong := statusCode == 401 || containsAny(lower, authStrongPatterns...)
configMatch := containsAny(lower, configPatterns...)
imageTooLargeMatch := containsAny(lower, imageTooLargePatterns...)
authWeak := statusCode == 403 || containsAny(lower, authWeakPatterns...)
rateLimitMatch := statusCode == 429 || containsAny(lower, rateLimitPatterns...)
timeoutPatternMatch := containsAny(lower, timeoutPatterns...)
@@ -264,6 +265,11 @@ func Classify(err error) ClassifiedError {
kind: codersdk.ChatErrorKindTimeout,
retryable: !deadline,
},
{
match: imageTooLargeMatch,
kind: codersdk.ChatErrorKindImageTooLarge,
retryable: false,
},
{
match: configMatch,
kind: codersdk.ChatErrorKindConfig,
+24
View File
@@ -298,6 +298,28 @@ func TestClassify(t *testing.T) {
StatusCode: 503,
},
},
{
name: "AnthropicImageDimensionsTooLarge",
err: xerrors.New("provider request failed: anthropic error 400: messages.112.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"),
want: chaterror.ClassifiedError{
Message: "One or more images in the conversation exceed the provider's maximum allowed dimensions. Edit the message containing the oversized image and remove or resize it to continue.",
Kind: codersdk.ChatErrorKindImageTooLarge,
Provider: "anthropic",
Retryable: false,
StatusCode: 0,
},
},
{
name: "GenericImageDimensionsError",
err: xerrors.New("image dimensions exceed limit"),
want: chaterror.ClassifiedError{
Message: "One or more images in the conversation exceed the provider's maximum allowed dimensions. Edit the message containing the oversized image and remove or resize it to continue.",
Kind: codersdk.ChatErrorKindImageTooLarge,
Provider: "",
Retryable: false,
StatusCode: 0,
},
},
}
for _, tt := range tests {
@@ -444,6 +466,8 @@ func TestClassify_PatternCoverage(t *testing.T) {
{name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true},
{name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false},
{name: "ImageDimensionsExceedLiteral", err: "image dimensions exceed", wantKind: codersdk.ChatErrorKindImageTooLarge, wantRetry: false},
{name: "MaxAllowedSizeForManyImageLiteral", err: "max allowed size for many-image", wantKind: codersdk.ChatErrorKindImageTooLarge, wantRetry: false},
}
for _, tt := range tests {
+5
View File
@@ -64,6 +64,9 @@ func terminalMessage(classified ClassifiedError) string {
" Contact your Coder administrator.",
displayName,
)
case codersdk.ChatErrorKindImageTooLarge:
return "One or more images in the conversation exceed the provider's maximum allowed dimensions." +
" Edit the message containing the oversized image and remove or resize it to continue."
default:
if !classified.Retryable && classified.StatusCode == 0 {
return "The chat request failed unexpectedly."
@@ -109,6 +112,8 @@ func retryMessage(classified ClassifiedError) string {
"The %s provider has been disabled by an administrator.",
displayName,
)
case codersdk.ChatErrorKindImageTooLarge:
return "An image exceeds the maximum allowed dimensions."
default:
return stringutil.Capitalize(fmt.Sprintf(
"%s returned an unexpected error.", subject,
+7
View File
@@ -97,6 +97,13 @@ func TestTerminalMessage(t *testing.T) {
retryable: false,
want: "This conversation was started with an API key that is no longer available. Send your message again to continue.",
},
{
name: "ImageTooLarge",
kind: codersdk.ChatErrorKindImageTooLarge,
provider: "",
retryable: false,
want: "One or more images in the conversation exceed the provider's maximum allowed dimensions. Edit the message containing the oversized image and remove or resize it to continue.",
},
}
for _, tt := range tests {
+1
View File
@@ -84,6 +84,7 @@ var (
"malformed configuration",
}
genericRetryablePatterns = []string{"server error", "internal server error"}
imageTooLargePatterns = []string{"image dimensions exceed", "max allowed size for many-image"}
interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"}
providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled}
)
+2
View File
@@ -1535,6 +1535,7 @@ const (
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
ChatErrorKindMissingKey ChatErrorKind = "missing_key"
ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled"
ChatErrorKindImageTooLarge ChatErrorKind = "image_too_large"
)
// AllChatErrorKinds contains every ChatErrorKind value.
@@ -1550,6 +1551,7 @@ var AllChatErrorKinds = []ChatErrorKind{
ChatErrorKindUsageLimit,
ChatErrorKindMissingKey,
ChatErrorKindProviderDisabled,
ChatErrorKindImageTooLarge,
}
// ChatError represents a terminal chat error in persisted chat state or the
+7 -7
View File
@@ -292,13 +292,13 @@ Status Code **200**
#### Enumerated Values
| Property | Value(s) |
|---------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `client_type` | `api`, `ui` |
| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
| `plan_mode` | `plan` |
| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
| Property | Value(s) |
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `client_type` | `api`, `ui` |
| `kind` | `auth`, `config`, `generic`, `image_too_large`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
| `plan_mode` | `plan` |
| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+3 -3
View File
@@ -2681,9 +2681,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values
| Value(s) |
|------------------------------------------------------------------------------------------------------------------------------------------|
| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
| Value(s) |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `auth`, `config`, `generic`, `image_too_large`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
## codersdk.ChatFileMetadata
+2
View File
@@ -1967,6 +1967,7 @@ export type ChatErrorKind =
| "auth"
| "config"
| "generic"
| "image_too_large"
| "missing_key"
| "overloaded"
| "provider_disabled"
@@ -1979,6 +1980,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [
"auth",
"config",
"generic",
"image_too_large",
"missing_key",
"overloaded",
"provider_disabled",
@@ -4,6 +4,7 @@ import {
Fragment,
memo,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -532,6 +533,7 @@ const ChatMessageItem = memo<{
hideActions?: boolean;
hasActiveStream?: boolean;
isAwaitingFirstStreamChunk?: boolean;
hasImageError?: boolean;
// When true, renders a gradient overlay inside the bubble
// that fades text out toward the bottom. Used by the sticky
@@ -561,6 +563,7 @@ const ChatMessageItem = memo<{
hideActions = false,
hasActiveStream = false,
isAwaitingFirstStreamChunk = false,
hasImageError = false,
fadeFromBottom = false,
onImplementPlan,
onSendAskUserQuestionResponse,
@@ -611,6 +614,7 @@ const ChatMessageItem = memo<{
markdown={parsed.markdown}
isEditing={editingMessageId === message.id}
fadeFromBottom={fadeFromBottom}
hasImageError={hasImageError}
onImageClick={setPreviewImage}
onTextFileClick={setPreviewText}
/>
@@ -776,6 +780,7 @@ const StickyUserMessage = memo<{
) => void;
editingMessageId?: number | null;
isAfterEditingMessage?: boolean;
hasImageError?: boolean;
prevUserMessageId?: number;
nextUserMessageId?: number;
onJumpToUserMessage?: (messageId: number) => void;
@@ -787,6 +792,7 @@ const StickyUserMessage = memo<{
onEditUserMessage,
editingMessageId,
isAfterEditingMessage = false,
hasImageError = false,
prevUserMessageId,
nextUserMessageId,
onJumpToUserMessage,
@@ -1020,6 +1026,7 @@ const StickyUserMessage = memo<{
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
hasImageError={hasImageError}
prevUserMessageId={prevUserMessageId}
nextUserMessageId={nextUserMessageId}
onJumpToUserMessage={onJumpToUserMessage}
@@ -1065,6 +1072,7 @@ const StickyUserMessage = memo<{
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
hasImageError={hasImageError}
prevUserMessageId={prevUserMessageId}
nextUserMessageId={nextUserMessageId}
onJumpToUserMessage={onJumpToUserMessage}
@@ -1112,6 +1120,7 @@ interface ConversationTimelineProps {
showDesktopPreviews?: boolean;
hasActiveStream?: boolean;
isAwaitingFirstStreamChunk?: boolean;
imageErrorKind?: string;
}
export const ConversationTimeline = memo<ConversationTimelineProps>(
@@ -1129,7 +1138,26 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
showDesktopPreviews,
hasActiveStream,
isAwaitingFirstStreamChunk,
imageErrorKind,
}) => {
// Identify user messages that contain image file parts so they
// can be highlighted when an image_too_large error occurs.
const imageErrorMessageIds = useMemo(() => {
if (imageErrorKind !== "image_too_large") return new Set<number>();
const ids = new Set<number>();
for (const { message } of parsedMessages) {
if (message.role !== "user") continue;
const hasImage = message.content?.some(
(part) =>
part.type === "file" && part.media_type?.startsWith("image/"),
);
if (hasImage) {
ids.add(message.id);
}
}
return ids;
}, [imageErrorKind, parsedMessages]);
const sentinelsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const registerSentinel = (messageId: number, el: HTMLDivElement | null) => {
if (el) {
@@ -1258,6 +1286,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
hasImageError={imageErrorMessageIds.has(message.id)}
prevUserMessageId={userNeighborsById.get(message.id)?.prevId}
nextUserMessageId={userNeighborsById.get(message.id)?.nextId}
onJumpToUserMessage={jumpToUserMessage}
@@ -32,6 +32,7 @@ export const UserMessageContent: FC<{
markdown: string;
isEditing?: boolean;
fadeFromBottom?: boolean;
hasImageError?: boolean;
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
}> = ({
@@ -39,6 +40,7 @@ export const UserMessageContent: FC<{
markdown,
isEditing = false,
fadeFromBottom = false,
hasImageError = false,
onImageClick,
onTextFileClick,
}) => {
@@ -49,6 +51,9 @@ export const UserMessageContent: FC<{
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
isEditing &&
"border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
hasImageError &&
!isEditing &&
"border-border-error shadow-[0_0_0_2px_hsla(var(--border-error),0.4)]",
fadeFromBottom && "relative overflow-hidden",
)}
style={
@@ -46,6 +46,8 @@ export const getErrorTitle = (
return "Chat interrupted";
case "provider_disabled":
return "Provider disabled";
case "image_too_large":
return "Image too large";
default:
return mode === "retry" ? "Retrying request" : "Request failed";
}
@@ -28,6 +28,7 @@ import {
selectMessagesByID,
selectOrderedMessageIDs,
selectQueuedMessages,
selectStreamError,
useChatSelector,
type useChatStore,
} from "./ChatConversation/chatStore";
@@ -71,6 +72,8 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
mcpServers,
}) => {
const [chatFullWidth] = useChatFullWidth();
const streamError = useChatSelector(store, selectStreamError);
const imageErrorKind = persistedError?.kind ?? streamError?.kind;
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
const chatStatus = useChatSelector(store, selectChatStatus);
@@ -127,6 +130,7 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
urlTransform={urlTransform}
mcpServers={mcpServers}
showDesktopPreviews={false}
imageErrorKind={imageErrorKind}
/>
<LiveStreamTail
store={store}