From ee41aa0c1a0a58fc693a585d7355359a75d2e557 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 17 Jun 2026 16:46:43 -0400 Subject: [PATCH] feat(desktop): add dismiss control to chat error banners (#47985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed turn leaves a red error banner inline in the transcript. These errors are renderer-local state (never persisted) and stay pinned to the message until the session is reloaded, so a stale, no-longer-relevant error (e.g. a transient provider/inference error) lingers with no way to clear it. Add an 'x' dismiss button inside the existing MessagePrimitive.Error block. Clicking it clears the error from BOTH the live $messages view and the per-runtime session cache — the view first, because preserveLocalAssistantErrors re-grafts any still-errored message it finds in the view onto the next session.info flush, so clearing only the cache would let the heartbeat resurrect the banner. A bare error placeholder (no streamed content) is dropped entirely; a turn that streamed partial output before failing keeps its text and just sheds the error. The control only renders when an onDismissError handler is wired, so secondary/embedded Thread usages are unaffected. Adds the dismissError string to all four locales (en/ja/zh/zh-hant) and two behavior tests. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/src/app/chat/index.tsx | 5 +- apps/desktop/src/app/desktop-controller.tsx | 47 ++++++++++++++++++- .../assistant-ui/streaming.test.tsx | 31 ++++++++++++ .../src/components/assistant-ui/thread.tsx | 28 ++++++++--- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 9 files changed, 108 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 8982b14d5e6..8cf4145cf84 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -87,6 +87,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onReload: (parentId: string | null) => Promise onRestoreToMessage?: (messageId: string) => Promise onTranscribeAudio?: (audio: Blob) => Promise + onDismissError?: (messageId: string) => void } interface ChatHeaderProps { @@ -272,7 +273,8 @@ export function ChatView({ onEdit, onReload, onRestoreToMessage, - onTranscribeAudio + onTranscribeAudio, + onDismissError }: ChatViewProps) { const location = useLocation() const activeSessionId = useStore($activeSessionId) @@ -432,6 +434,7 @@ export function ChatView({ loading={threadLoading} onBranchInNewChat={onBranchInNewChat} onCancel={onCancel} + onDismissError={onDismissError} onRestoreToMessage={onRestoreToMessage} sessionId={activeSessionId} sessionKey={threadKey} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 45251ceef9b..74f544c7099 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' -import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' +import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { isMessagingSource, LOCAL_SESSION_SOURCE_IDS, @@ -52,6 +52,7 @@ import { $currentCwd, $freshDraftReady, $gatewayState, + $messages, $messagingSessions, $selectedStoredSessionId, $sessions, @@ -736,6 +737,49 @@ export function DesktopController() { [branchCurrentSession, refreshSessions] ) + // Clear a failed turn's red error banner from the transcript. Errors are + // renderer-local state (never persisted), so dismissing is purely a view + + // session-cache edit. A message that errored before emitting any visible + // text is a bare error placeholder → drop it entirely; one that streamed + // partial output then failed keeps its content and just sheds the error. + // Both the per-runtime cache AND the live $messages view must be updated: + // `preserveLocalAssistantErrors` re-grafts any still-errored message it + // finds in the view onto the next session.info flush, so clearing only the + // cache would let the heartbeat resurrect the banner. + const dismissError = useCallback( + (messageId: string) => { + const runtimeSessionId = activeSessionIdRef.current + + if (!runtimeSessionId) { + return + } + + const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] => + messages.flatMap(message => { + if (message.id !== messageId || !message.error) { + return [message] + } + + if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) { + return [] + } + + return [{ ...message, error: undefined, pending: false }] + }) + + // View first: the flush below reads $messages as the "current" baseline + // for error preservation, so the banner must be gone from it before the + // cache update triggers a re-sync. + setMessages(clearErrorIn($messages.get())) + + updateSessionState(runtimeSessionId, state => ({ + ...state, + messages: clearErrorIn(state.messages) + })) + }, + [activeSessionIdRef, updateSessionState] + ) + const startSessionInWorkspace = useCallback( (path: null | string) => { startFreshSessionDraft() @@ -994,6 +1038,7 @@ export function DesktopController() { void removeSession(selectedStoredSessionId) } }} + onDismissError={dismissError} onEdit={editMessage} onPasteClipboardImage={() => void composer.pasteClipboardImage()} onPickFiles={() => void composer.pickContextPaths('file')} diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx index a1383172655..d23bbb42049 100644 --- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -378,6 +378,20 @@ function IntroHarness() { ) } +function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) { + const runtime = useExternalStoreRuntime({ + messages: [assistantErrorMessage('OpenRouter rejected the request (403).')], + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + describe('assistant-ui streaming renderer', () => { beforeEach(() => { resizeObservers.clear() @@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => { expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).') }) + it('omits the dismiss control when no onDismissError handler is supplied', () => { + render() + + expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull() + }) + + it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => { + const onDismissError = vi.fn() + render() + + const dismiss = screen.getByRole('button', { name: 'Dismiss error' }) + fireEvent.click(dismiss) + + expect(onDismissError).toHaveBeenCalledTimes(1) + expect(onDismissError).toHaveBeenCalledWith('assistant-error-1') + }) + // Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned // by the use-stick-to-bottom library and covered by its own test suite. We // don't re-assert its scrollTop mechanics here — doing so in jsdom (no real diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 80e9c33ec2e..1c8f41d66e6 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { LinkifiedText } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' -import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' +import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -169,6 +169,7 @@ export const Thread: FC<{ loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void onCancel?: () => Promise | void + onDismissError?: (messageId: string) => void onRestoreToMessage?: (messageId: string) => Promise | void sessionId?: string | null sessionKey?: string | null @@ -180,18 +181,19 @@ export const Thread: FC<{ loading, onBranchInNewChat, onCancel, + onDismissError, onRestoreToMessage, sessionId = null, sessionKey }) => { const messageComponents = useMemo( () => ({ - AssistantMessage: () => , + AssistantMessage: () => , SystemMessage, UserEditComposer: () => , UserMessage: () => }), - [cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId] + [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId] ) const emptyPlaceholder = intro ? ( @@ -245,9 +247,13 @@ const CenteredThreadSpinner: FC = () => { ) } -const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { +const AssistantMessage: FC<{ + onBranchInNewChat?: (messageId: string) => void + onDismissError?: (messageId: string) => void +}> = ({ onBranchInNewChat, onDismissError }) => { const messageId = useAuiState(s => s.message.id) const messageRuntime = useMessageRuntime() + const { t } = useI18n() // PERF: this component must NOT subscribe to the streaming text. Every // selector here returns a value that stays referentially stable across @@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> )} - + + {onDismissError && ( + onDismissError(messageId)} + side="top" + tooltip={t.assistant.thread.dismissError} + > + + + )} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fa6465c3388..70720adec1e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1733,6 +1733,7 @@ export const en: Translations = { refresh: 'Refresh', moreActions: 'More actions', branchNewChat: 'Branch in new chat', + dismissError: 'Dismiss error', readAloudFailed: 'Read aloud failed', preparingAudio: 'Preparing audio...', stopReading: 'Stop reading', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f26508e5897..48b46ac9267 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1864,6 +1864,7 @@ export const ja = defineLocale({ refresh: '更新', moreActions: 'その他のアクション', branchNewChat: '新しいチャットでブランチ', + dismissError: 'エラーを閉じる', readAloudFailed: '読み上げに失敗しました', preparingAudio: '音声を準備中...', stopReading: '読み上げを停止', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2e9cc76ab98..dc3be24765c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1373,6 +1373,7 @@ export interface Translations { refresh: string moreActions: string branchNewChat: string + dismissError: string readAloudFailed: string preparingAudio: string stopReading: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 6f964c071f2..1f5be40cad5 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1806,6 +1806,7 @@ export const zhHant = defineLocale({ refresh: '重新整理', moreActions: '更多動作', branchNewChat: '在新聊天中分支', + dismissError: '关闭错误', readAloudFailed: '朗讀失敗', preparingAudio: '正在準備音訊...', stopReading: '停止朗讀', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index db13108d8f7..6a2e426eee1 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1912,6 +1912,7 @@ export const zh: Translations = { refresh: '刷新', moreActions: '更多操作', branchNewChat: '在新对话中分支', + dismissError: '关闭错误', readAloudFailed: '朗读失败', preparingAudio: '正在准备音频...', stopReading: '停止朗读',