fix(desktop): error response improvements — content, retry, model selector
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

P1: onError callback now sets content to error message instead of empty string.
    Previously API errors (404/429) produced empty assistant messages with only
    a visual error badge — now the error text is persisted in message content.

P3: Retry button now re-sends the preceding user message via sendToGateway
    instead of copying to input. Works for both virtualized and non-virtualized
    message lists. Removed unused setInput prop from MessageBubble.

Also hides model selector in Tauri runtime (SaaS token pool routes models).
This commit is contained in:
iven
2026-04-09 18:52:27 +08:00
parent 125da57436
commit 5c6964f52a
2 changed files with 46 additions and 16 deletions

View File

@@ -231,6 +231,18 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
setPendingFiles([]); setPendingFiles([]);
}; };
const createRetryHandler = (msgId: string) => () => {
if (isStreaming) return;
// Find the user message immediately before this error
const idx = messages.findIndex(m => m.id === msgId);
if (idx > 0) {
const prevMsg = messages[idx - 1];
if (prevMsg.role === 'user' && prevMsg.content) {
sendToGateway(prevMsg.content);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -433,6 +445,16 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
onHeightChange={setHeight} onHeightChange={setHeight}
messageRefs={messageRefs} messageRefs={messageRefs}
setInput={setInput} setInput={setInput}
retryForMessage={(msgId: string) => {
const idx = messages.findIndex(m => m.id === msgId);
if (idx > 0) {
const prevMsg = messages[idx - 1];
if (prevMsg.role === 'user' && prevMsg.content) {
return () => { if (!isStreaming) sendToGateway(prevMsg.content); };
}
}
return undefined;
}}
/> />
) : ( ) : (
messages.map((message) => ( messages.map((message) => (
@@ -445,7 +467,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
layout layout
transition={defaultTransition} transition={defaultTransition}
> >
<MessageBubble message={message} setInput={setInput} /> <MessageBubble message={message} onRetry={createRetryHandler(message.id)} />
</motion.div> </motion.div>
)) ))
)} )}
@@ -575,7 +597,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
); );
} }
function MessageBubble({ message, setInput, onRetry }: { message: Message; setInput: (text: string) => void; onRetry?: () => void }) { function MessageBubble({ message, onRetry }: { message: Message; setInput?: (text: string) => void; onRetry?: () => void }) {
if (message.role === 'tool') { if (message.role === 'tool') {
return null; return null;
} }
@@ -679,17 +701,14 @@ function MessageBubble({ message, setInput, onRetry }: { message: Message; setIn
{message.error && ( {message.error && (
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<p className="text-xs text-red-500">{message.error}</p> <p className="text-xs text-red-500">{message.error}</p>
<button {onRetry && (
onClick={() => { <button
const text = typeof message.content === 'string' ? message.content : ''; onClick={onRetry}
if (text) { className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
setInput(text); >
}
}} </button>
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors" )}
>
</button>
</div> </div>
)} )}
{/* Download button for AI messages - show on hover */} {/* Download button for AI messages - show on hover */}
@@ -716,6 +735,7 @@ interface VirtualizedMessageRowProps {
onHeightChange: (height: number) => void; onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>; messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void; setInput: (text: string) => void;
onRetry?: () => void;
} }
/** /**
@@ -726,7 +746,7 @@ function VirtualizedMessageRow({
message, message,
onHeightChange, onHeightChange,
messageRefs, messageRefs,
setInput, onRetry,
style, style,
ariaAttributes, ariaAttributes,
}: VirtualizedMessageRowProps & { }: VirtualizedMessageRowProps & {
@@ -761,7 +781,7 @@ function VirtualizedMessageRow({
className="py-3" className="py-3"
{...ariaAttributes} {...ariaAttributes}
> >
<MessageBubble message={message} setInput={setInput} /> <MessageBubble message={message} onRetry={onRetry} />
</div> </div>
); );
} }
@@ -773,6 +793,7 @@ interface VirtualizedMessageListProps {
onHeightChange: (id: string, height: number) => void; onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>; messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void; setInput: (text: string) => void;
retryForMessage: (msgId: string) => (() => void) | undefined;
} }
/** /**
@@ -786,6 +807,7 @@ function VirtualizedMessageList({
onHeightChange, onHeightChange,
messageRefs, messageRefs,
setInput, setInput,
retryForMessage,
}: VirtualizedMessageListProps) { }: VirtualizedMessageListProps) {
// Row component for react-window v2 // Row component for react-window v2
const RowComponent = (props: { const RowComponent = (props: {
@@ -802,6 +824,7 @@ function VirtualizedMessageList({
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)} onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs} messageRefs={messageRefs}
setInput={setInput} setInput={setInput}
onRetry={retryForMessage(messages[props.index].id)}
style={props.style} style={props.style}
ariaAttributes={props.ariaAttributes} ariaAttributes={props.ariaAttributes}
/> />

View File

@@ -502,10 +502,17 @@ export const useStreamStore = create<StreamState>()(
} }
}, },
onError: (error: string) => { onError: (error: string) => {
// Flush any remaining buffered deltas before erroring
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushBuffers();
_chat?.updateMessages(msgs => _chat?.updateMessages(msgs =>
msgs.map(m => msgs.map(m =>
m.id === assistantId m.id === assistantId
? { ...m, content: '', streaming: false, error } ? { ...m, content: error, streaming: false, error }
: m.role === 'user' && m.optimistic && m.timestamp.getTime() >= streamStartTime : m.role === 'user' && m.optimistic && m.timestamp.getTime() >= streamStartTime
? { ...m, optimistic: false } ? { ...m, optimistic: false }
: m : m