1
- import { useCallback , useMemo , useRef , useState } from 'react'
1
+ import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
2
2
import { useChat } from 'ai/react'
3
- import { MessageSquarePlus } from 'lucide-react'
3
+ import { MessageSquarePlus , Clock } from 'lucide-react'
4
4
import { useModel } from '../contexts/ModelContext'
5
5
import { useUser } from '../contexts/UserContext'
6
6
import { generateMessageId } from '../mcp/client'
@@ -14,6 +14,8 @@ import { BotMessage } from './BotMessage'
14
14
import { UserMessage } from './UserMessage'
15
15
import { CodeInterpreterToggle } from './CodeInterpreterToggle'
16
16
import { WebSearchToggle } from './WebSearchToggle'
17
+ import { BackgroundToggle } from './BackgroundToggle'
18
+ import { BackgroundJobsSidebar } from './BackgroundJobsSidebar'
17
19
import { ModelSelect } from './ModelSelect'
18
20
import { BotThinking } from './BotThinking'
19
21
import { BotError } from './BotError'
@@ -26,6 +28,7 @@ import { useStreamingChat } from '@/hooks/useStreamingChat'
26
28
import { getTimestamp } from '@/lib/utils/date'
27
29
import { isCodeInterpreterSupported } from '@/lib/utils/prompting'
28
30
import { useHasMounted } from '@/hooks/useHasMounted'
31
+ import { useBackgroundJobs } from '@/hooks/useBackgroundJobs'
29
32
30
33
const getEventKey = ( event : StreamEvent | Message , idx : number ) : string => {
31
34
if ( 'type' in event ) {
@@ -61,14 +64,19 @@ const TIMEOUT_ERROR_MESSAGE =
61
64
export function Chat ( ) {
62
65
const hasMounted = useHasMounted ( )
63
66
const messagesEndRef = useRef < HTMLDivElement > ( null )
67
+ const lastPromptRef = useRef < string > ( '' )
64
68
const [ hasStartedChat , setHasStartedChat ] = useState ( false )
65
69
const [ focusTimestamp , setFocusTimestamp ] = useState ( Date . now ( ) )
66
70
const [ servers , setServers ] = useState < Servers > ( { } )
67
71
const [ selectedServers , setSelectedServers ] = useState < Array < string > > ( [ ] )
68
72
const [ useCodeInterpreter , setUseCodeInterpreter ] = useState ( false )
69
73
const [ useWebSearch , setUseWebSearch ] = useState ( false )
74
+ const [ useBackground , setUseBackground ] = useState ( false )
75
+ const [ backgroundJobsSidebarOpen , setBackgroundJobsSidebarOpen ] =
76
+ useState ( false )
70
77
const { selectedModel, setSelectedModel } = useModel ( )
71
78
const { user } = useUser ( )
79
+ const { jobs : backgroundJobs } = useBackgroundJobs ( )
72
80
73
81
const {
74
82
streamBuffer,
@@ -110,6 +118,7 @@ export function Chat() {
110
118
userId : user ?. id ,
111
119
codeInterpreter : useCodeInterpreter ,
112
120
webSearch : useWebSearch ,
121
+ background : useBackground ,
113
122
} ) ,
114
123
[
115
124
selectedServers ,
@@ -118,13 +127,28 @@ export function Chat() {
118
127
user ?. id ,
119
128
useCodeInterpreter ,
120
129
useWebSearch ,
130
+ useBackground ,
121
131
] ,
122
132
)
123
133
134
+ const handleResponseWithBackground = useCallback (
135
+ ( response : Response ) => {
136
+ if ( useBackground ) {
137
+ // The background job title is the last user prompt
138
+ const title = lastPromptRef . current
139
+
140
+ handleResponse ( response , { background : true , title } )
141
+ } else {
142
+ handleResponse ( response , { background : false } )
143
+ }
144
+ } ,
145
+ [ handleResponse , useBackground ] ,
146
+ )
147
+
124
148
const { messages, isLoading, setMessages, append, stop } = useChat ( {
125
149
body : chatBody ,
126
150
onError : handleError ,
127
- onResponse : handleResponse ,
151
+ onResponse : handleResponseWithBackground ,
128
152
} )
129
153
130
154
const renderEvents = useMemo < Array < StreamEvent | Message > > ( ( ) => {
@@ -143,6 +167,7 @@ export function Chat() {
143
167
if ( ! hasStartedChat ) {
144
168
setHasStartedChat ( true )
145
169
}
170
+ lastPromptRef . current = prompt // Store the prompt for background job title
146
171
addUserMessage ( prompt )
147
172
append ( { role : 'user' , content : prompt } )
148
173
} ,
@@ -167,8 +192,66 @@ export function Chat() {
167
192
setFocusTimestamp ( Date . now ( ) )
168
193
setUseCodeInterpreter ( false )
169
194
setUseWebSearch ( false )
195
+ setUseBackground ( false )
170
196
} , [ setMessages , stop , cancelStream , clearBuffer ] )
171
197
198
+ // Check if max concurrent background jobs limit is reached
199
+ const maxJobsReached = useMemo ( ( ) => {
200
+ if ( ! hasMounted ) return false
201
+ const runningJobs = backgroundJobs . filter ( ( job ) => job . status === 'running' )
202
+ return runningJobs . length >= 5 // Default limit from PRD
203
+ } , [ hasMounted , backgroundJobs ] )
204
+
205
+ // Update chat messages when background jobs update (for streaming loaded responses)
206
+ useEffect ( ( ) => {
207
+ setMessages ( ( prevMessages ) =>
208
+ prevMessages . map ( ( message ) => {
209
+ if ( message ) {
210
+ const job = backgroundJobs . find (
211
+ ( j ) => j . id === message . backgroundJobId ,
212
+ )
213
+ if ( job && job . response && job . response !== message . content ) {
214
+ // Update message content with latest job response
215
+ return { ...message , content : job . response }
216
+ }
217
+ }
218
+ return message
219
+ } ) ,
220
+ )
221
+ } , [ backgroundJobs ] )
222
+
223
+ const handleLoadJobResponse = useCallback (
224
+ async ( jobId : string ) => {
225
+ const job = backgroundJobs . find ( ( j ) => j . id === jobId )
226
+
227
+ if ( ! job || job . status === 'failed' ) {
228
+ console . warn ( 'Job failed, cannot load response' , job )
229
+ return
230
+ }
231
+
232
+ try {
233
+ const url = new URL ( '/api/background-jobs' , window . location . origin )
234
+ url . searchParams . set ( 'id' , job . id )
235
+ const streamResponse = await fetch ( url . toString ( ) , {
236
+ method : 'GET' ,
237
+ } )
238
+
239
+ setBackgroundJobsSidebarOpen ( false )
240
+ handleResponse ( streamResponse , { background : false } )
241
+ return
242
+ } catch ( error ) {
243
+ console . error ( 'Failed to stream background job response:' , error )
244
+ handleError ( new Error ( 'Failed to load background job' ) )
245
+ }
246
+ } ,
247
+ [ backgroundJobs ] ,
248
+ )
249
+
250
+ const handleCancelJob = useCallback ( ( jobId : string ) => {
251
+ // TODO: Implement actual job cancellation via OpenAI API
252
+ console . log ( 'Cancelling job:' , jobId )
253
+ } , [ ] )
254
+
172
255
const handleScrollToBottom = useCallback ( ( ) => {
173
256
messagesEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } )
174
257
} , [ ] )
@@ -200,19 +283,46 @@ export function Chat() {
200
283
/>
201
284
) ,
202
285
} ,
286
+ {
287
+ key : 'background' ,
288
+ isActive : useBackground ,
289
+ component : (
290
+ < BackgroundToggle
291
+ key = "background"
292
+ useBackground = { useBackground }
293
+ onToggle = { setUseBackground }
294
+ selectedModel = { selectedModel }
295
+ disabled = { hasStartedChat }
296
+ maxJobsReached = { maxJobsReached }
297
+ />
298
+ ) ,
299
+ } ,
203
300
]
204
301
205
302
return (
206
303
< div className = "flex flex-col min-h-full relative" >
207
304
< div className = "sticky top-0 z-10 bg-background border-b px-4 py-2 flex justify-between items-center" >
208
- < Button
209
- variant = "outline"
210
- onClick = { handleNewChat }
211
- className = "flex items-center gap-2"
212
- >
213
- < MessageSquarePlus className = "size-4" />
214
- < span className = "sr-only sm:not-sr-only" > New Chat</ span >
215
- </ Button >
305
+ < div className = "flex items-center gap-2" >
306
+ < Button
307
+ variant = "outline"
308
+ onClick = { handleNewChat }
309
+ className = "flex items-center gap-2"
310
+ >
311
+ < MessageSquarePlus className = "size-4" />
312
+ < span className = "sr-only sm:not-sr-only" > New Chat</ span >
313
+ </ Button >
314
+ { hasMounted && (
315
+ < Button
316
+ variant = "outline"
317
+ onClick = { ( ) => setBackgroundJobsSidebarOpen ( true ) }
318
+ className = "flex items-center gap-2"
319
+ title = "Background Jobs"
320
+ >
321
+ < Clock className = "size-4" />
322
+ < span className = "sr-only sm:not-sr-only" > Jobs</ span >
323
+ </ Button >
324
+ ) }
325
+ </ div >
216
326
< ModelSelect value = { selectedModel } onValueChange = { handleModelChange } />
217
327
</ div >
218
328
< div className = "flex-1" >
@@ -313,6 +423,12 @@ export function Chat() {
313
423
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
314
424
} else if ( 'type' in event && event . type === 'web_search' ) {
315
425
return < WebSearchMessage key = { key } event = { event } />
426
+ } else if (
427
+ 'type' in event &&
428
+ event . type === 'background_job_created'
429
+ ) {
430
+ // Background job handled by streaming hook - no UI rendering needed
431
+ return null
316
432
} else {
317
433
// Fallback for Message type (from useChat)
318
434
const message = event
@@ -439,6 +555,12 @@ export function Chat() {
439
555
focusTimestamp = { focusTimestamp }
440
556
/>
441
557
</ div >
558
+ < BackgroundJobsSidebar
559
+ isOpen = { backgroundJobsSidebarOpen }
560
+ onClose = { ( ) => setBackgroundJobsSidebarOpen ( false ) }
561
+ onLoadResponse = { handleLoadJobResponse }
562
+ onCancelJob = { handleCancelJob }
563
+ />
442
564
</ div >
443
565
)
444
566
}
0 commit comments