@@ -17,6 +17,9 @@ import { streamChat, type ChatWireMessage } from "@/lib/chat-stream";
1717// separate, later, flag-gated issue.
1818
1919const ASSISTANT_NAME = "LoopOver" ;
20+ /** Inline failure note appended after a failed turn — keeps history visible instead of StateBoundary wipe (#7077). */
21+ const TURN_FAILED_MESSAGE =
22+ "The latest response failed to complete. Any partial answer above is incomplete — you can try again." ;
2023
2124/** Injectable so tests can drive the stream deterministically; defaults to the real `POST /api/chat` bridge. */
2225export type StreamChatFn = ( messages : ChatWireMessage [ ] ) => AsyncIterable < string > ;
@@ -28,7 +31,6 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
2831 // #7078: true from submit until the first SSE text chunk — drives MessageList's TypingIndicator so the
2932 // pre-first-token round-trip isn't a silent gap. Cleared on first chunk, stream completion, or error.
3033 const [ awaitingFirstChunk , setAwaitingFirstChunk ] = useState ( false ) ;
31- const [ errored , setErrored ] = useState ( false ) ;
3234 const idCounter = useRef ( 0 ) ;
3335 const nextId = ( ) => `m${ ( idCounter . current += 1 ) } ` ;
3436
@@ -46,7 +48,6 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
4648 . map ( ( message ) => ( { role : message . role , content : message . content } ) ) ;
4749
4850 setMessages ( ( prev ) => [ ...prev , userMessage ] ) ;
49- setErrored ( false ) ;
5051 setAwaitingFirstChunk ( true ) ;
5152 setStreaming ( true ) ;
5253
@@ -80,7 +81,28 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
8081 } ,
8182 ] ) ;
8283 } catch {
83- setErrored ( true ) ;
84+ // #7077: never gate MessageList on isError (that replaces the whole history via StateBoundary).
85+ // Commit any partial streamed text, then append an inline system failure note so prior turns stay.
86+ const failedAt = new Date ( ) . toISOString ( ) ;
87+ setMessages ( ( prev ) => {
88+ const next = [ ...prev ] ;
89+ if ( answer . length > 0 ) {
90+ next . push ( {
91+ id : nextId ( ) ,
92+ role : "assistant" ,
93+ content : answer ,
94+ timestamp : failedAt ,
95+ authorName : ASSISTANT_NAME ,
96+ } ) ;
97+ }
98+ next . push ( {
99+ id : nextId ( ) ,
100+ role : "system" ,
101+ content : TURN_FAILED_MESSAGE ,
102+ timestamp : failedAt ,
103+ } ) ;
104+ return next ;
105+ } ) ;
84106 } finally {
85107 setAwaitingFirstChunk ( false ) ;
86108 setStreaming ( false ) ;
@@ -99,7 +121,7 @@ export function ChatConversation({ streamChatImpl = streamChat }: { streamChatIm
99121 < div className = "flex h-full flex-col gap-2 p-4" >
100122 < p className = "font-mono text-token-xs uppercase tracking-[0.2em] text-primary" > Chat</ p >
101123 < div className = "min-h-0 flex-1 overflow-hidden" >
102- < MessageList messages = { messages } isError = { errored } composing = { streaming && awaitingFirstChunk } />
124+ < MessageList messages = { messages } composing = { streaming && awaitingFirstChunk } />
103125 { streaming && activeSource ? (
104126 < div className = "flex gap-3 px-3 pt-4" data-testid = "chat-streaming-response" >
105127 < Avatar className = "size-8 shrink-0" >
0 commit comments