From 4ad5d2f4b784b130044cadba44507a48579263a9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 1 May 2026 15:08:44 +0300 Subject: [PATCH] fix(ui): tolerate invalid message parts --- .../ui/src/components/chat/ChatMessage.tsx | 11 ++++---- .../ui/src/components/chat/MessageList.tsx | 25 ++++++++++++++----- .../src/components/chat/message/partUtils.ts | 13 ++++++++-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 31304e75..4283febd 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -22,7 +22,7 @@ import MessageBody from './message/MessageBody'; import type { AgentMentionInfo } from './message/types'; import type { StreamPhase, ToolPopupContent } from './message/types'; import { deriveMessageRole } from './message/messageRole'; -import { filterVisibleParts } from './message/partUtils'; +import { filterVisibleParts, normalizeParts } from './message/partUtils'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError'; @@ -216,11 +216,12 @@ const ChatMessage: React.FC = ({ ); const normalizedParts = React.useMemo(() => { + const safeParts = normalizeParts(message.parts); if (!isUser) { - return message.parts; + return safeParts; } - return normalizeUserDisplayParts(message.parts, { planModeEnabled }); + return normalizeUserDisplayParts(safeParts, { planModeEnabled }); }, [isUser, message.parts, planModeEnabled]); const previousUserMetadata = React.useMemo(() => { @@ -509,7 +510,7 @@ const ChatMessage: React.FC = ({ if (!isUser) { return undefined; } - const mentionPart = message.parts.find((part) => part.type === 'agent'); + const mentionPart = normalizedParts.find((part) => part.type === 'agent'); if (!mentionPart) { return undefined; } @@ -522,7 +523,7 @@ const ChatMessage: React.FC = ({ ? partWithName.source.value : `@${name}`; return { name, token: rawValue } satisfies AgentMentionInfo; - }, [isUser, message.parts]); + }, [isUser, normalizedParts]); const shouldHideUserMessage = isUser && displayParts.length === 0; diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 63717495..2a2c38e3 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -15,6 +15,7 @@ import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; import type { StreamPhase } from './message/types'; +import { normalizeParts } from './message/partUtils'; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = Number.POSITIVE_INFINITY; const MESSAGE_LIST_OVERSCAN = 6; @@ -51,7 +52,7 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => { const hasCompactionPart = (message: ChatMessageEntry): boolean => { return message.parts.some((part) => { - const type = (part as { type?: unknown }).type; + const type = (part as { type?: unknown } | null | undefined)?.type; return type === 'compaction'; }); }; @@ -75,7 +76,7 @@ const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessa let changedParts = false; const nextParts = message.parts.map((part) => { - const type = (part as { type?: unknown }).type; + const type = (part as { type?: unknown } | null | undefined)?.type; if (type !== 'compaction') { return part; } @@ -202,7 +203,7 @@ const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParen }; }; - if (part.type !== 'tool') { + if (part?.type !== 'tool') { return { hide: false, details: null }; } @@ -270,9 +271,9 @@ const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: b const onlyPart = message.parts[0] as unknown as { type?: unknown; tool?: unknown; - }; + } | null | undefined; - if (onlyPart.type !== 'tool') { + if (onlyPart?.type !== 'tool') { return { hide: false, taskSessionId: null }; } @@ -352,6 +353,17 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD }; }; +const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => { + const parts = normalizeParts(message.parts); + if (parts.length === message.parts.length) { + return message; + } + return { + ...message, + parts, + }; +}; + const normalizedMessageBySource = new WeakMap(); const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => { @@ -360,7 +372,8 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE return cached; } - const normalizedCompactionMessage = normalizeCompactionCommandMessage(message); + const normalizedPartMessage = normalizeMessageParts(message); + const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage); const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts); const normalized = filteredParts === normalizedCompactionMessage.parts ? normalizedCompactionMessage diff --git a/packages/ui/src/components/chat/message/partUtils.ts b/packages/ui/src/components/chat/message/partUtils.ts index 7a557671..567dcff8 100644 --- a/packages/ui/src/components/chat/message/partUtils.ts +++ b/packages/ui/src/components/chat/message/partUtils.ts @@ -2,6 +2,14 @@ import type { Part } from '@opencode-ai/sdk/v2'; type PartWithText = Part & { text?: string; content?: string; value?: string }; +export const isValidPart = (part: unknown): part is Part => { + return Boolean(part && typeof part === 'object' && typeof (part as { type?: unknown }).type === 'string'); +}; + +export const normalizeParts = (parts: Part[]): Part[] => { + return parts.filter(isValidPart); +}; + export const extractTextContent = (part: Part): string => { const partWithText = part as PartWithText; const rawText = partWithText.text; @@ -27,14 +35,15 @@ interface VisibleFilterOptions { export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions = {}): Part[] => { const { includeReasoning = true } = options; + const validParts = normalizeParts(parts); // Check if there are any non-synthetic parts - const hasNonSynthetic = parts.some((part) => { + const hasNonSynthetic = validParts.some((part) => { const partWithSynthetic = part as PartWithSynthetic; return !partWithSynthetic.synthetic; }); - return parts.filter((part) => { + return validParts.filter((part) => { const partWithSynthetic = part as PartWithSynthetic; const isSynthetic = Boolean(partWithSynthetic.synthetic);