From 8d968bba75af2dfd0904a23ba554bb86fd2f2436 Mon Sep 17 00:00:00 2001 From: i-subham Date: Thu, 5 Mar 2026 15:36:43 +0530 Subject: [PATCH 1/2] Refactor error handling and display stream errors in thread UI - Detect and surface error objects in OpenAI stream adapters by yielding RUN_ERROR events instead of silently ignoring them - Propagate RUN_ERROR as thrown exceptions in processStreamedMessage so they reach the chat store's error handling - Add empty-response detection in createChatStore to catch cases where streaming completes without producing any messages - Introduce ThreadError component with Callout to display threadError in the Shell thread UI - Clean up SCSS formatting in cssUtils.scss (collapse multi-line var declarations) Made-with: Cursor --- .../src/stream/adapters/openai-completions.ts | 16 ++- .../stream/adapters/openai-readable-stream.ts | 16 ++- .../src/stream/processStreamedMessage.ts | 7 +- .../react-headless/src/v2/createChatStore.ts | 10 +- .../react-ui/src/components/Shell/Thread.tsx | 18 +++ .../react-ui/src/components/Shell/thread.scss | 9 ++ packages/react-ui/src/cssUtils.scss | 129 ++++-------------- 7 files changed, 91 insertions(+), 114 deletions(-) diff --git a/packages/react-headless/src/stream/adapters/openai-completions.ts b/packages/react-headless/src/stream/adapters/openai-completions.ts index d45ca9be8..d93cfb4f3 100644 --- a/packages/react-headless/src/stream/adapters/openai-completions.ts +++ b/packages/react-headless/src/stream/adapters/openai-completions.ts @@ -24,8 +24,20 @@ export const openAIAdapter = (): StreamProtocolAdapter => ({ if (!data || data === "[DONE]") continue; try { - const json = JSON.parse(data) as ChatCompletionChunk; - const choice = json.choices?.[0]; + const json = JSON.parse(data); + + if (json.error) { + yield { + type: EventType.RUN_ERROR, + message: + typeof json.error === "string" + ? json.error + : json.error.message || "Unknown stream error", + } as AGUIEvent; + continue; + } + + const choice = (json as ChatCompletionChunk).choices?.[0]; const delta = choice?.delta; if (!delta) continue; diff --git a/packages/react-headless/src/stream/adapters/openai-readable-stream.ts b/packages/react-headless/src/stream/adapters/openai-readable-stream.ts index 9497a4ad1..1282d9167 100644 --- a/packages/react-headless/src/stream/adapters/openai-readable-stream.ts +++ b/packages/react-headless/src/stream/adapters/openai-readable-stream.ts @@ -30,8 +30,20 @@ export const openAIReadableStreamAdapter = (): StreamProtocolAdapter => ({ if (!data) continue; try { - const json = JSON.parse(data) as ChatCompletionChunk; - const choice = json.choices?.[0]; + const json = JSON.parse(data); + + if (json.error) { + yield { + type: EventType.RUN_ERROR, + message: + typeof json.error === "string" + ? json.error + : json.error.message || "Unknown stream error", + } as AGUIEvent; + continue; + } + + const choice = (json as ChatCompletionChunk).choices?.[0]; const delta = choice?.delta; if (!delta) continue; diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index fb8002f69..5c59da1d5 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -91,9 +91,10 @@ export const processStreamedMessage = async ({ } break; - case EventType.RUN_ERROR: - console.error("Stream error:", (event as any).error); - break; + case EventType.RUN_ERROR: { + const msg = (event as any).message || (event as any).error || "Stream error"; + throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); + } } if (isFirst) { diff --git a/packages/react-headless/src/v2/createChatStore.ts b/packages/react-headless/src/v2/createChatStore.ts index 55c6d4c88..6811f0f6f 100644 --- a/packages/react-headless/src/v2/createChatStore.ts +++ b/packages/react-headless/src/v2/createChatStore.ts @@ -245,6 +245,8 @@ export const createChatStore = (config: StoreConfig) => { throw new Error(`Request failed: ${response.status} ${response.statusText}`); } + const messageCountBefore = get().messages.length; + await processStreamedMessage({ response, createMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), @@ -255,9 +257,15 @@ export const createChatStore = (config: StoreConfig) => { deleteMessage: (id) => set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })), adapter: streamProtocol, }); + + if (get().messages.length === messageCountBefore) { + throw new Error("Failed to get a response. Please check your connection and try again."); + } } catch (e) { if (!abortController.signal.aborted) { - set({ threadError: e instanceof Error ? e : new Error(String(e)) }); + const error = e instanceof Error ? e : new Error(String(e)); + console.error("[OpenUI] processMessage failed:", error); + set({ threadError: error }); } } finally { set({ _abortController: null, isRunning: false }); diff --git a/packages/react-ui/src/components/Shell/Thread.tsx b/packages/react-ui/src/components/Shell/Thread.tsx index 68af847da..86aaf21d8 100644 --- a/packages/react-ui/src/components/Shell/Thread.tsx +++ b/packages/react-ui/src/components/Shell/Thread.tsx @@ -4,6 +4,7 @@ import clsx from "clsx"; import React, { memo, useEffect, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { Callout } from "../Callout"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; import type { AssistantMessageComponent, UserMessageComponent } from "../OpenUIChat/types"; @@ -320,6 +321,21 @@ export const MessageLoading = () => { ); }; +export const ThreadError = () => { + const threadError = useThread((s) => s.threadError); + if (!threadError) return null; + + return ( +
+ +
+ ); +}; + export const Messages = ({ className, loader, @@ -333,6 +349,7 @@ export const Messages = ({ }) => { const messages = useThread((s) => s.messages); const isRunning = useThread((s) => s.isRunning); + const threadError = useThread((s) => s.threadError); return (
@@ -349,6 +366,7 @@ export const Messages = ({ ); })} {isRunning &&
{loader}
} + {!isRunning && threadError && }
); }; diff --git a/packages/react-ui/src/components/Shell/thread.scss b/packages/react-ui/src/components/Shell/thread.scss index 996f7a596..13248980d 100644 --- a/packages/react-ui/src/components/Shell/thread.scss +++ b/packages/react-ui/src/components/Shell/thread.scss @@ -264,6 +264,15 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); } } +.openui-shell-thread-error { + padding: 0 $center-align-spacing; + min-height: auto !important; + + .openui-shell-container--mobile & { + padding: 0; + } +} + .openui-shell-thread-message-loading { display: flex; padding: 0 $center-align-spacing; diff --git a/packages/react-ui/src/cssUtils.scss b/packages/react-ui/src/cssUtils.scss index 00e4b2f1c..12aa0c6c1 100644 --- a/packages/react-ui/src/cssUtils.scss +++ b/packages/react-ui/src/cssUtils.scss @@ -45,18 +45,9 @@ $text-alert-inverted: var(--openui-text-alert-inverted, oklch(0.973 0.069 103.19 $text-danger-primary: var(--openui-text-danger-primary, oklch(0.505 0.19 27.518 / 1)); $text-danger-secondary: var(--openui-text-danger-secondary, oklch(0.711 0.166 22.216 / 1)); $text-danger-tertiary: var(--openui-text-danger-tertiary, oklch(0.808 0.103 19.571 / 1)); -$text-danger-inverted-primary: var( - --openui-text-danger-inverted-primary, - oklch(0.982 0.009 17.303 / 1) -); -$text-danger-inverted-secondary: var( - --openui-text-danger-inverted-secondary, - oklch(0.982 0.009 17.303 / 0.5) -); -$text-danger-inverted-tertiary: var( - --openui-text-danger-inverted-tertiary, - oklch(0.982 0.009 17.303 / 0.3) -); +$text-danger-inverted-primary: var(--openui-text-danger-inverted-primary, oklch(0.982 0.009 17.303 / 1)); +$text-danger-inverted-secondary: var(--openui-text-danger-inverted-secondary, oklch(0.982 0.009 17.303 / 0.5)); +$text-danger-inverted-tertiary: var(--openui-text-danger-inverted-tertiary, oklch(0.982 0.009 17.303 / 0.3)); $text-info-primary: var(--openui-text-info-primary, oklch(0.424 0.181 265.638 / 1)); $text-info-inverted: var(--openui-text-info-inverted, oklch(0.932 0.032 255.585 / 1)); $text-pink-primary: var(--openui-text-pink-primary, oklch(0.459 0.17 3.815 / 1)); @@ -70,38 +61,14 @@ $interactive-accent-default: var(--openui-interactive-accent-default, oklch(0.09 $interactive-accent-hover: var(--openui-interactive-accent-hover, oklch(0.097 0 0 / 0.8)); $interactive-accent-disabled: var(--openui-interactive-accent-disabled, oklch(0.097 0 0 / 0.4)); $interactive-accent-pressed: var(--openui-interactive-accent-pressed, oklch(0.097 0 0 / 1)); -$interactive-destructive-default: var( - --openui-interactive-destructive-default, - oklch(0.577 0.215 27.325 / 0.02) -); -$interactive-destructive-hover: var( - --openui-interactive-destructive-hover, - oklch(0.577 0.215 27.325 / 0.08) -); -$interactive-destructive-disabled: var( - --openui-interactive-destructive-disabled, - oklch(0.577 0.215 27.325 / 0.02) -); -$interactive-destructive-pressed: var( - --openui-interactive-destructive-pressed, - oklch(0.577 0.215 27.325 / 0.1) -); -$interactive-destructive-accent-default: var( - --openui-interactive-destructive-accent-default, - oklch(0.577 0.215 27.325 / 1) -); -$interactive-destructive-accent-hover: var( - --openui-interactive-destructive-accent-hover, - oklch(0.637 0.208 25.331 / 1) -); -$interactive-destructive-accent-pressed: var( - --openui-interactive-destructive-accent-pressed, - oklch(0.505 0.19 27.518 / 1) -); -$interactive-destructive-accent-disabled: var( - --openui-interactive-destructive-accent-disabled, - oklch(0.577 0.215 27.325 / 0.4) -); +$interactive-destructive-default: var(--openui-interactive-destructive-default, oklch(0.577 0.215 27.325 / 0.02)); +$interactive-destructive-hover: var(--openui-interactive-destructive-hover, oklch(0.577 0.215 27.325 / 0.08)); +$interactive-destructive-disabled: var(--openui-interactive-destructive-disabled, oklch(0.577 0.215 27.325 / 0.02)); +$interactive-destructive-pressed: var(--openui-interactive-destructive-pressed, oklch(0.577 0.215 27.325 / 0.1)); +$interactive-destructive-accent-default: var(--openui-interactive-destructive-accent-default, oklch(0.577 0.215 27.325 / 1)); +$interactive-destructive-accent-hover: var(--openui-interactive-destructive-accent-hover, oklch(0.637 0.208 25.331 / 1)); +$interactive-destructive-accent-pressed: var(--openui-interactive-destructive-accent-pressed, oklch(0.505 0.19 27.518 / 1)); +$interactive-destructive-accent-disabled: var(--openui-interactive-destructive-accent-disabled, oklch(0.577 0.215 27.325 / 0.4)); // Chat Colors @@ -225,11 +192,7 @@ $text-label-sm-heavy: var(--openui-text-label-sm-heavy, 500 14px/1.25 "Inter", s $text-label-sm-heavy-letter-spacing: var(--openui-text-label-sm-heavy-letter-spacing, 0); $text-label-default: var(--openui-text-label-default, 400 16px/1.25 "Inter", sans-serif); $text-label-default-letter-spacing: var(--openui-text-label-default-letter-spacing, 0); -$text-label-default-heavy: var( - --openui-text-label-default-heavy, - 500 16px/1.25 "Inter", - sans-serif -); +$text-label-default-heavy: var(--openui-text-label-default-heavy, 500 16px/1.25 "Inter", sans-serif); $text-label-default-heavy-letter-spacing: var(--openui-text-label-default-heavy-letter-spacing, 0); $text-label-lg: var(--openui-text-label-lg, 400 18px/1.25 "Inter", sans-serif); $text-label-lg-letter-spacing: var(--openui-text-label-lg-letter-spacing, 0); @@ -245,76 +208,30 @@ $text-numbers-sm-heavy: var(--openui-text-numbers-sm-heavy, 500 14px/1.5 "Inter" $text-numbers-sm-heavy-letter-spacing: var(--openui-text-numbers-sm-heavy-letter-spacing, 0); $text-numbers-default: var(--openui-text-numbers-default, 400 16px/1.5 "Inter", sans-serif); $text-numbers-default-letter-spacing: var(--openui-text-numbers-default-letter-spacing, 0); -$text-numbers-default-heavy: var( - --openui-text-numbers-default-heavy, - 500 16px/1.5 "Inter", - sans-serif -); -$text-numbers-default-heavy-letter-spacing: var( - --openui-text-numbers-default-heavy-letter-spacing, - 0 -); +$text-numbers-default-heavy: var(--openui-text-numbers-default-heavy, 500 16px/1.5 "Inter", sans-serif); +$text-numbers-default-heavy-letter-spacing: var(--openui-text-numbers-default-heavy-letter-spacing, 0); $text-numbers-lg: var(--openui-text-numbers-lg, 400 18px/1.5 "Inter", sans-serif); $text-numbers-lg-letter-spacing: var(--openui-text-numbers-lg-letter-spacing, 0); $text-numbers-lg-heavy: var(--openui-text-numbers-lg-heavy, 500 18px/1.5 "Inter", sans-serif); $text-numbers-lg-heavy-letter-spacing: var(--openui-text-numbers-lg-heavy-letter-spacing, 0); $text-code-sm: var(--openui-text-code-sm, 400 12px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-sm-letter-spacing: var(--openui-text-code-sm-letter-spacing, 0); -$text-code-sm-heavy: var( - --openui-text-code-sm-heavy, - 700 12px/1.5 "SFMono-Regular", - Menlo, - monospace -); +$text-code-sm-heavy: var(--openui-text-code-sm-heavy, 700 12px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-sm-heavy-letter-spacing: var(--openui-text-code-sm-heavy-letter-spacing, 0); -$text-code-default: var( - --openui-text-code-default, - 400 14px/1.5 "SFMono-Regular", - Menlo, - monospace -); +$text-code-default: var(--openui-text-code-default, 400 14px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-default-letter-spacing: var(--openui-text-code-default-letter-spacing, 0); -$text-code-default-heavy: var( - --openui-text-code-default-heavy, - 700 14px/1.5 "SFMono-Regular", - Menlo, - monospace -); +$text-code-default-heavy: var(--openui-text-code-default-heavy, 700 14px/1.5 "SFMono-Regular", Menlo, monospace); $text-code-default-heavy-letter-spacing: var(--openui-text-code-default-heavy-letter-spacing, 0); // Shadows $shadow-0: var(--openui-shadow-0, none); -$shadow-s: var( - --openui-shadow-s, - 0 1px 3px -2px oklch(0 0 0 / 0.02), - 0 2px 5px -2px oklch(0 0 0 / 0.04) -); -$shadow-m: var( - --openui-shadow-m, - 0 4px 6px -2px oklch(0 0 0 / 0.025), - 0 2px 2px -2px oklch(0 0 0 / 0.05) -); -$shadow-l: var( - --openui-shadow-l, - 0 4px 4px -2px oklch(0 0 0 / 0.05), - 0 4px 8px -2px oklch(0 0 0 / 0.04) -); -$shadow-xl: var( - --openui-shadow-xl, - 0 8px 16px -4px oklch(0 0 0 / 0.08), - 0 16px 32px -6px oklch(0 0 0 / 0.12) -); -$shadow-2xl: var( - --openui-shadow-2xl, - 0 12px 24px -6px oklch(0 0 0 / 0.12), - 0 24px 48px -8px oklch(0 0 0 / 0.16) -); -$shadow-3xl: var( - --openui-shadow-3xl, - 0 16px 32px -8px oklch(0 0 0 / 0.16), - 0 32px 64px -12px oklch(0 0 0 / 0.22) -); +$shadow-s: var(--openui-shadow-s, 0 1px 3px -2px oklch(0 0 0 / 0.02), 0 2px 5px -2px oklch(0 0 0 / 0.04)); +$shadow-m: var(--openui-shadow-m, 0 4px 6px -2px oklch(0 0 0 / 0.025), 0 2px 2px -2px oklch(0 0 0 / 0.05)); +$shadow-l: var(--openui-shadow-l, 0 4px 4px -2px oklch(0 0 0 / 0.05), 0 4px 8px -2px oklch(0 0 0 / 0.04)); +$shadow-xl: var(--openui-shadow-xl, 0 8px 16px -4px oklch(0 0 0 / 0.08), 0 16px 32px -6px oklch(0 0 0 / 0.12)); +$shadow-2xl: var(--openui-shadow-2xl, 0 12px 24px -6px oklch(0 0 0 / 0.12), 0 24px 48px -8px oklch(0 0 0 / 0.16)); +$shadow-3xl: var(--openui-shadow-3xl, 0 16px 32px -8px oklch(0 0 0 / 0.16), 0 32px 64px -12px oklch(0 0 0 / 0.22)); $chat-container-bg: $background; From 4fb6410273352bd4a09b1a1cba22ed5b174279e8 Mon Sep 17 00:00:00 2001 From: i-subham Date: Wed, 11 Mar 2026 17:22:12 +0530 Subject: [PATCH 2/2] Refactor OpenAI stream adapters to simplify error handling - Remove redundant error checks in openAIAdapter and openAIReadableStreamAdapter. - Directly parse JSON data and access choices, improving code clarity and maintainability. --- .../src/stream/adapters/openai-completions.ts | 16 ++-------------- .../stream/adapters/openai-readable-stream.ts | 16 ++-------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/react-headless/src/stream/adapters/openai-completions.ts b/packages/react-headless/src/stream/adapters/openai-completions.ts index d93cfb4f3..d45ca9be8 100644 --- a/packages/react-headless/src/stream/adapters/openai-completions.ts +++ b/packages/react-headless/src/stream/adapters/openai-completions.ts @@ -24,20 +24,8 @@ export const openAIAdapter = (): StreamProtocolAdapter => ({ if (!data || data === "[DONE]") continue; try { - const json = JSON.parse(data); - - if (json.error) { - yield { - type: EventType.RUN_ERROR, - message: - typeof json.error === "string" - ? json.error - : json.error.message || "Unknown stream error", - } as AGUIEvent; - continue; - } - - const choice = (json as ChatCompletionChunk).choices?.[0]; + const json = JSON.parse(data) as ChatCompletionChunk; + const choice = json.choices?.[0]; const delta = choice?.delta; if (!delta) continue; diff --git a/packages/react-headless/src/stream/adapters/openai-readable-stream.ts b/packages/react-headless/src/stream/adapters/openai-readable-stream.ts index 1282d9167..9497a4ad1 100644 --- a/packages/react-headless/src/stream/adapters/openai-readable-stream.ts +++ b/packages/react-headless/src/stream/adapters/openai-readable-stream.ts @@ -30,20 +30,8 @@ export const openAIReadableStreamAdapter = (): StreamProtocolAdapter => ({ if (!data) continue; try { - const json = JSON.parse(data); - - if (json.error) { - yield { - type: EventType.RUN_ERROR, - message: - typeof json.error === "string" - ? json.error - : json.error.message || "Unknown stream error", - } as AGUIEvent; - continue; - } - - const choice = (json as ChatCompletionChunk).choices?.[0]; + const json = JSON.parse(data) as ChatCompletionChunk; + const choice = json.choices?.[0]; const delta = choice?.delta; if (!delta) continue;