1- import type { LanguageModelUsage , UIMessageChunk } from 'ai' ;
2- import { useChat } from '@ai-sdk/react' ;
3- import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
4- import { useSWRConfig } from 'swr' ;
1+ import type { LanguageModelUsage } from 'ai' ;
2+ import { useEffect , useState } from 'react' ;
3+ import { useSearchParams } from 'react-router-dom' ;
54import { ChatHeader } from '@/components/chat-header' ;
6- import { fetchWithErrorHandlers , generateUUID } from '@/lib/utils ' ;
7- import { MultimodalInput } from './multimodal-input ' ;
8- import { Messages } from './messages ' ;
5+ import { useChatStream } from '@/hooks/use-chat-stream ' ;
6+ import { softNavigateToChatId } from '@/lib/navigation ' ;
7+ import { useAppConfig } from '@/contexts/AppConfigContext ' ;
98import type {
109 Attachment ,
1110 ChatMessage ,
1211 FeedbackMap ,
1312 VisibilityType ,
1413 ClientSession ,
1514} from '@/types' ;
16- import { ChatSDKError } from '@/types' ;
17- import { apiUrl } from '@/lib/config' ;
18- import { unstable_serialize } from 'swr/infinite' ;
19- import { getChatHistoryPaginationKey } from './sidebar-history' ;
20- import { toast } from './toast' ;
21- import { useSearchParams } from 'react-router-dom' ;
22- import { useChatVisibility } from '@/hooks/use-chat-visibility' ;
23- import { isCredentialErrorMessage } from '@/lib/oauth-error-utils' ;
24- import { ChatTransport } from '../lib/ChatTransport' ;
25- import { softNavigateToChatId } from '@/lib/navigation' ;
26- import { useAppConfig } from '@/contexts/AppConfigContext' ;
2715import { Greeting } from './greeting' ;
16+ import { Messages } from './messages' ;
17+ import { MultimodalInput } from './multimodal-input' ;
2818
2919export function Chat ( {
3020 id,
3121 initialMessages,
3222 initialChatModel,
3323 initialVisibilityType,
3424 isReadonly,
35- initialLastContext,
25+ session : _session ,
26+ initialLastContext : _initialLastContext ,
3627 feedback = { } ,
3728 title,
3829} : {
@@ -46,218 +37,16 @@ export function Chat({
4637 feedback ?: FeedbackMap ;
4738 title ?: string ;
4839} ) {
49- const { visibilityType } = useChatVisibility ( {
50- chatId : id ,
51- initialVisibilityType,
52- } ) ;
53-
54- const { mutate } = useSWRConfig ( ) ;
5540 const { chatHistoryEnabled } = useAppConfig ( ) ;
5641
57- const [ input , setInput ] = useState < string > ( '' ) ;
58- const [ _usage , setUsage ] = useState < LanguageModelUsage | undefined > (
59- initialLastContext ,
60- ) ;
61-
62- const [ lastPart , setLastPart ] = useState < UIMessageChunk | undefined > ( ) ;
63- const lastPartRef = useRef < UIMessageChunk | undefined > ( lastPart ) ;
64- lastPartRef . current = lastPart ;
65-
66- // Single counter for resume attempts - reset when stream parts are received
67- const resumeAttemptCountRef = useRef ( 0 ) ;
68- const maxResumeAttempts = 3 ;
69-
70- const abortController = useRef < AbortController | null > ( new AbortController ( ) ) ;
71- useEffect ( ( ) => {
72- return ( ) => {
73- abortController . current ?. abort ( 'ABORT_SIGNAL' ) ;
74- } ;
75- } , [ ] ) ;
76-
77- const fetchWithAbort = useMemo ( ( ) => {
78- return async ( input : RequestInfo | URL , init ?: RequestInit ) => {
79- // useChat does not cancel /stream requests when the component is unmounted
80- const signal = abortController . current ?. signal ;
81- return fetchWithErrorHandlers ( input , { ...init , signal } ) ;
82- } ;
83- } , [ ] ) ;
84-
85- const stop = useCallback ( ( ) => {
86- abortController . current ?. abort ( 'USER_ABORT_SIGNAL' ) ;
87- } , [ ] ) ;
88-
89- const isNewChat = initialMessages . length === 0 ;
90- const didFetchHistoryOnNewChat = useRef ( false ) ;
91- const fetchChatHistory = useCallback ( ( ) => {
92- mutate ( unstable_serialize ( getChatHistoryPaginationKey ) ) ;
93- } , [ mutate ] ) ;
94-
95- // For new chats, the title arrives via a `data-title` stream part
96- // once backend title generation completes — no separate fetch needed.
97- const [ streamTitle , setStreamTitle ] = useState < string | undefined > ( ) ;
98- const [ titlePending , setTitlePending ] = useState ( false ) ;
99- const displayTitle = title ?? streamTitle ;
100-
101- const {
102- messages,
103- setMessages,
104- sendMessage,
105- status,
106- resumeStream,
107- clearError,
108- addToolApprovalResponse,
109- regenerate,
110- } = useChat < ChatMessage > ( {
42+ const chat = useChatStream ( {
11143 id,
112- messages : initialMessages ,
113- experimental_throttle : 100 ,
114- generateId : generateUUID ,
115- resume : id !== undefined && initialMessages . length > 0 , // Enable automatic stream resumption
116- transport : new ChatTransport ( {
117- onStreamPart : ( part ) => {
118- if ( isNewChat && ! didFetchHistoryOnNewChat . current ) {
119- fetchChatHistory ( ) ;
120- if ( chatHistoryEnabled ) {
121- setTitlePending ( true ) ;
122- }
123- didFetchHistoryOnNewChat . current = true ;
124- }
125- // Reset resume attempts when we successfully receive stream parts
126- resumeAttemptCountRef . current = 0 ;
127- setLastPart ( part ) ;
128- } ,
129- api : apiUrl ( '/' ) ,
130- fetch : fetchWithAbort ,
131- prepareSendMessagesRequest ( { messages, id, body } ) {
132- const lastMessage = messages . at ( - 1 ) ;
133- const isUserMessage = lastMessage ?. role === 'user' ;
134-
135- // For continuations (non-user messages like tool results), we must always
136- // send previousMessages because the tool result only exists client-side
137- // and hasn't been saved to the database yet.
138- const needsPreviousMessages = ! chatHistoryEnabled || ! isUserMessage ;
139-
140- return {
141- body : {
142- id,
143- // Only include message field for user messages (new messages)
144- // For continuation (assistant messages with tool results), omit message field
145- ...( isUserMessage ? { message : lastMessage } : { } ) ,
146- selectedChatModel : initialChatModel ,
147- selectedVisibilityType : visibilityType ,
148- nextMessageId : generateUUID ( ) ,
149- // Send previous messages when:
150- // 1. Database is disabled (ephemeral mode) - always need client-side messages
151- // 2. Continuation request (tool results) - tool result only exists client-side
152- ...( needsPreviousMessages
153- ? {
154- previousMessages : isUserMessage
155- ? messages . slice ( 0 , - 1 )
156- : messages ,
157- }
158- : { } ) ,
159- ...body ,
160- } ,
161- } ;
162- } ,
163- prepareReconnectToStreamRequest ( { id } ) {
164- return {
165- api : apiUrl ( `/${ id } /stream` ) ,
166- credentials : 'include' ,
167- } ;
168- } ,
169- } ) ,
170- onData : ( dataPart ) => {
171- if ( dataPart . type === 'data-usage' ) {
172- setUsage ( dataPart . data as LanguageModelUsage ) ;
173- }
174- if ( dataPart . type === 'data-title' ) {
175- setStreamTitle ( dataPart . data as string ) ;
176- setTitlePending ( false ) ;
177- fetchChatHistory ( ) ;
178- }
179- } ,
180- onFinish : ( {
181- isAbort,
182- isDisconnect,
183- isError,
184- messages : finishedMessages ,
185- } ) => {
186- didFetchHistoryOnNewChat . current = false ;
187- setTitlePending ( false ) ;
188-
189- // If user aborted, don't try to resume
190- if ( isAbort ) {
191- console . log ( '[Chat onFinish] Stream was aborted by user, not resuming' ) ;
192- fetchChatHistory ( ) ;
193- return ;
194- }
195-
196- // Check if the last message contains an OAuth credential error
197- // If so, don't try to resume - the user needs to authenticate first
198- const lastMessage = finishedMessages ?. at ( - 1 ) ;
199- const hasOAuthError = lastMessage ?. parts ?. some (
200- ( part ) =>
201- part . type === 'data-error' &&
202- typeof part . data === 'string' &&
203- isCredentialErrorMessage ( part . data ) ,
204- ) ;
205-
206- if ( hasOAuthError ) {
207- console . log (
208- '[Chat onFinish] OAuth credential error detected, not resuming' ,
209- ) ;
210- fetchChatHistory ( ) ;
211- clearError ( ) ;
212- return ;
213- }
214-
215- // Determine if we should attempt to resume:
216- // 1. Stream didn't end with a 'finish' part (incomplete)
217- // 2. It was a disconnect/error that terminated the stream
218- // 3. We haven't exceeded max resume attempts
219- const streamIncomplete = lastPartRef . current ?. type !== 'finish' ;
220- const shouldResume =
221- streamIncomplete &&
222- ( isDisconnect || isError || lastPartRef . current === undefined ) ;
223-
224- if ( shouldResume && resumeAttemptCountRef . current < maxResumeAttempts ) {
225- console . log (
226- '[Chat onFinish] Resuming stream. Attempt:' ,
227- resumeAttemptCountRef . current + 1 ,
228- ) ;
229- resumeAttemptCountRef . current ++ ;
230- // Ref: https://github.com/vercel/ai/issues/8477#issuecomment-3603209884
231- queueMicrotask ( ( ) => {
232- resumeStream ( ) ;
233- } )
234- } else {
235- // Stream completed normally or we've exhausted resume attempts
236- if ( resumeAttemptCountRef . current >= maxResumeAttempts ) {
237- console . warn ( '[Chat onFinish] Max resume attempts reached' ) ;
238- }
239- fetchChatHistory ( ) ;
240- }
241- } ,
242- onError : ( error ) => {
243- console . log ( '[Chat onError] Error occurred:' , error ) ;
244-
245- // Only show toast for explicit ChatSDKError (backend validation errors)
246- // Other errors (network, schema validation) are handled silently or in message parts
247- if ( error instanceof ChatSDKError ) {
248- toast ( {
249- type : 'error' ,
250- description : error . message ,
251- } ) ;
252- } else {
253- // Non-ChatSDKError: Could be network error or in-stream error
254- // Log but don't toast - errors during streaming may be informational
255- console . warn ( '[Chat onError] Error during streaming:' , error . message ) ;
256- }
257- // Note: We don't call resumeStream here because onError can be called
258- // while the stream is still active (e.g., for data-error parts).
259- // Resume logic is handled exclusively in onFinish.
260- } ,
44+ initialMessages,
45+ model : initialChatModel ,
46+ initialVisibility : initialVisibilityType ,
47+ isReadonly,
48+ feedback,
49+ title,
26150 } ) ;
26251
26352 const [ searchParams ] = useSearchParams ( ) ;
@@ -267,33 +56,34 @@ export function Chat({
26756
26857 useEffect ( ( ) => {
26958 if ( query && ! hasAppendedQuery ) {
270- sendMessage ( {
59+ chat . sendMessage ( {
27160 role : 'user' as const ,
27261 parts : [ { type : 'text' , text : query } ] ,
27362 } ) ;
27463
27564 setHasAppendedQuery ( true ) ;
276- softNavigateToChatId ( id , chatHistoryEnabled ) ;
65+ softNavigateToChatId ( chat . id , chatHistoryEnabled ) ;
27766 }
278- } , [ query , sendMessage , hasAppendedQuery , id , chatHistoryEnabled ] ) ;
67+ } , [ query , chat . sendMessage , hasAppendedQuery , chat . id , chatHistoryEnabled ] ) ;
27968
69+ const [ input , setInput ] = useState < string > ( '' ) ;
28070 const [ attachments , setAttachments ] = useState < Array < Attachment > > ( [ ] ) ;
28171
28272 const inputElement = < MultimodalInput
283- chatId = { id }
73+ chatId = { chat . id }
28474 input = { input }
28575 setInput = { setInput }
286- status = { status }
287- stop = { stop }
76+ status = { chat . status }
77+ stop = { chat . stop }
28878 attachments = { attachments }
28979 setAttachments = { setAttachments }
290- messages = { messages }
291- setMessages = { setMessages }
292- sendMessage = { sendMessage }
293- selectedVisibilityType = { visibilityType }
80+ messages = { chat . messages }
81+ setMessages = { chat . setMessages }
82+ sendMessage = { chat . sendMessage }
83+ selectedVisibilityType = { chat . visibilityType }
29484 />
29585
296- if ( messages . length === 0 ) {
86+ if ( chat . messages . length === 0 ) {
29787 return (
29888 < div className = "flex h-dvh min-w-0 flex-col bg-background" >
29989 < ChatHeader empty />
@@ -308,30 +98,24 @@ export function Chat({
30898 }
30999
310100 return (
311- < >
312- < div className = "overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background" >
313- < ChatHeader title = { displayTitle } isLoadingTitle = { titlePending && ! displayTitle } />
314-
315- < Messages
316- status = { status }
317- messages = { messages }
318- setMessages = { setMessages }
319- addToolApprovalResponse = { addToolApprovalResponse }
320- regenerate = { regenerate }
321- sendMessage = { sendMessage }
322- isReadonly = { isReadonly }
323- selectedModelId = { initialChatModel }
324- feedback = { feedback }
325- />
326-
327-
328-
329- < div className = "sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4" >
330- { ! isReadonly && (
331- inputElement
332- ) }
333- </ div >
101+ < div className = "overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background" >
102+ < ChatHeader title = { chat . title } isLoadingTitle = { chat . isTitleLoading } />
103+
104+ < Messages
105+ status = { chat . status }
106+ messages = { chat . messages }
107+ setMessages = { chat . setMessages }
108+ addToolApprovalResponse = { chat . addToolApprovalResponse }
109+ regenerate = { chat . regenerate }
110+ sendMessage = { chat . sendMessage }
111+ isReadonly = { chat . isReadonly }
112+ selectedModelId = { chat . model }
113+ feedback = { chat . feedback }
114+ />
115+
116+ < div className = "sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4" >
117+ { ! chat . isReadonly && inputElement }
334118 </ div >
335- </ >
119+ </ div >
336120 ) ;
337121}
0 commit comments