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([]);
};
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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -433,6 +445,16 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
onHeightChange={setHeight}
messageRefs={messageRefs}
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) => (
@@ -445,7 +467,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
layout
transition={defaultTransition}
>
<MessageBubble message={message} setInput={setInput} />
<MessageBubble message={message} onRetry={createRetryHandler(message.id)} />
</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') {
return null;
}
@@ -679,17 +701,14 @@ function MessageBubble({ message, setInput, onRetry }: { message: Message; setIn
{message.error && (
<div className="flex items-center gap-2 mt-2">
<p className="text-xs text-red-500">{message.error}</p>
<button
onClick={() => {
const text = typeof message.content === 'string' ? message.content : '';
if (text) {
setInput(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"
>
</button>
{onRetry && (
<button
onClick={onRetry}
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>
)}
{/* Download button for AI messages - show on hover */}
@@ -716,6 +735,7 @@ interface VirtualizedMessageRowProps {
onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
onRetry?: () => void;
}
/**
@@ -726,7 +746,7 @@ function VirtualizedMessageRow({
message,
onHeightChange,
messageRefs,
setInput,
onRetry,
style,
ariaAttributes,
}: VirtualizedMessageRowProps & {
@@ -761,7 +781,7 @@ function VirtualizedMessageRow({
className="py-3"
{...ariaAttributes}
>
<MessageBubble message={message} setInput={setInput} />
<MessageBubble message={message} onRetry={onRetry} />
</div>
);
}
@@ -773,6 +793,7 @@ interface VirtualizedMessageListProps {
onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
retryForMessage: (msgId: string) => (() => void) | undefined;
}
/**
@@ -786,6 +807,7 @@ function VirtualizedMessageList({
onHeightChange,
messageRefs,
setInput,
retryForMessage,
}: VirtualizedMessageListProps) {
// Row component for react-window v2
const RowComponent = (props: {
@@ -802,6 +824,7 @@ function VirtualizedMessageList({
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs}
setInput={setInput}
onRetry={retryForMessage(messages[props.index].id)}
style={props.style}
ariaAttributes={props.ariaAttributes}
/>

View File

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