@@ -280,7 +280,7 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
280280 const abortRef = useRef < AbortController | null > ( null ) ;
281281 const stepIdRef = useRef ( 0 ) ;
282282 const toolOccurredRef = useRef ( false ) ;
283- const sendMessageRef = useRef < ( text ?: string ) => void > ( ( ) => { } ) ;
283+ const sendMessageRef = useRef < ( text ?: string , options ?: { attach ?: boolean } ) => void > ( ( ) => { } ) ;
284284
285285 // ── Streaming chunk buffer ──────────────────────────────────────
286286 // Buffer SSE chunks in a ref and flush to the store via rAF.
@@ -471,6 +471,12 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
471471 // Stop streaming — reads fresh state from store to avoid stale closures
472472 const handleStop = useCallback ( ( ) => {
473473 log . stream . warn ( 'User stopped generation' ) ;
474+ // Tell the backend to cancel the run. Since runs now outlive the SSE
475+ // connection (#1580), aborting the client alone only detaches us — the
476+ // agent would keep generating in the background. The cancel endpoint
477+ // sets the handler's cancelled flag so the producer bails at its next
478+ // step boundary.
479+ api . cancelStream ( sessionId ) . catch ( ( ) => { /* best-effort */ } ) ;
474480 if ( abortRef . current ) {
475481 abortRef . current . abort ( ) ;
476482 abortRef . current = null ;
@@ -632,7 +638,13 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
632638 } , [ ] ) ;
633639
634640 // Send message
635- const sendMessage = useCallback ( async ( overrideText ?: string ) => {
641+ const sendMessage = useCallback ( async ( overrideText ?: string , options ?: { attach ?: boolean } ) => {
642+ // attach=true re-subscribes to a run already in flight server-side
643+ // (revisiting a backgrounded session, #1580). It reuses the entire
644+ // stream-event handling below but skips composing/sending a new turn:
645+ // no optimistic user message, no input/attachment handling, and the
646+ // controller comes from api.attachToRun instead of api.sendMessageStream.
647+ const attach = options ?. attach === true ;
636648 const text = ( overrideText || input ) . trim ( ) ;
637649 const hasAttachments = attachments . length > 0 && attachments . some ( a => a . uploaded ) ;
638650
@@ -641,7 +653,10 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
641653 isNearBottomRef . current = true ;
642654
643655 const isInitializing = systemStatus ?. init_state === 'initializing' ;
644- if ( ( ! text && ! hasAttachments ) || isStreaming || isInitializing ) {
656+ if ( attach ) {
657+ // Don't double-attach if a stream is already live in this view.
658+ if ( isStreaming ) return ;
659+ } else if ( ( ! text && ! hasAttachments ) || isStreaming || isInitializing ) {
645660 if ( ! text && ! hasAttachments ) log . chat . debug ( 'Send blocked: empty message' ) ;
646661 if ( isStreaming ) log . chat . debug ( 'Send blocked: already streaming' ) ;
647662 if ( isInitializing ) log . chat . debug ( 'Send blocked: system initializing' ) ;
@@ -650,43 +665,47 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
650665
651666 // Build message text with attachment references
652667 let messageText = text ;
653- const uploadedAttachments = attachments . filter ( a => a . uploaded && a . serverUrl ) ;
654- if ( uploadedAttachments . length > 0 ) {
655- const attachmentLines = uploadedAttachments . map ( a => {
656- if ( a . isImage ) {
657- return `` ;
658- }
659- return `[${ a . name } ](${ a . serverUrl } )` ;
660- } ) . join ( '\n' ) ;
661- messageText = messageText
662- ? `${ messageText } \n\n${ attachmentLines } `
663- : attachmentLines ;
664- }
668+ if ( ! attach ) {
669+ const uploadedAttachments = attachments . filter ( a => a . uploaded && a . serverUrl ) ;
670+ if ( uploadedAttachments . length > 0 ) {
671+ const attachmentLines = uploadedAttachments . map ( a => {
672+ if ( a . isImage ) {
673+ return `` ;
674+ }
675+ return `[${ a . name } ](${ a . serverUrl } )` ;
676+ } ) . join ( '\n' ) ;
677+ messageText = messageText
678+ ? `${ messageText } \n\n${ attachmentLines } `
679+ : attachmentLines ;
680+ }
665681
666- log . chat . info ( `Sending message to session=${ sessionId } ` , { length : messageText . length , preview : messageText . slice ( 0 , 80 ) } ) ;
682+ log . chat . info ( `Sending message to session=${ sessionId } ` , { length : messageText . length , preview : messageText . slice ( 0 , 80 ) } ) ;
667683
668- setInput ( '' ) ;
669- if ( inputRef . current ) {
670- inputRef . current . style . height = 'auto' ;
671- inputRef . current . focus ( ) ;
672- }
684+ setInput ( '' ) ;
685+ if ( inputRef . current ) {
686+ inputRef . current . style . height = 'auto' ;
687+ inputRef . current . focus ( ) ;
688+ }
673689
674- // Clear attachments
675- setAttachments ( prev => {
676- prev . forEach ( a => { if ( a . url ) URL . revokeObjectURL ( a . url ) ; } ) ;
677- return [ ] ;
678- } ) ;
690+ // Clear attachments
691+ setAttachments ( prev => {
692+ prev . forEach ( a => { if ( a . url ) URL . revokeObjectURL ( a . url ) ; } ) ;
693+ return [ ] ;
694+ } ) ;
679695
680- // Optimistic user message
681- const userMsg : Message = {
682- id : Date . now ( ) ,
683- session_id : sessionId ,
684- role : 'user' ,
685- content : messageText ,
686- created_at : new Date ( ) . toISOString ( ) ,
687- rag_sources : null ,
688- } ;
689- addMessage ( userMsg ) ;
696+ // Optimistic user message
697+ const userMsg : Message = {
698+ id : Date . now ( ) ,
699+ session_id : sessionId ,
700+ role : 'user' ,
701+ content : messageText ,
702+ created_at : new Date ( ) . toISOString ( ) ,
703+ rag_sources : null ,
704+ } ;
705+ addMessage ( userMsg ) ;
706+ } else {
707+ log . chat . info ( `Re-attaching to background run for session=${ sessionId } ` ) ;
708+ }
690709
691710 // Start streaming
692711 setStreaming ( true ) ;
@@ -703,7 +722,7 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
703722 let doneHandled = false ;
704723 streamBufferRef . current = '' ;
705724
706- const controller = api . sendMessageStream ( sessionId , messageText , {
725+ const streamCallbacks : api . StreamCallbacks = {
707726 onChunk : ( event ) => {
708727 if ( isStale ( ) ) return ; // stop writing after a session switch (#1580)
709728 const content = event . content || '' ;
@@ -1046,7 +1065,9 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
10461065 } , 300 ) ;
10471066
10481067 // Auto-title on first message
1049- if ( session && session . title === 'New Task' ) {
1068+ // Skip client-side auto-title when re-attaching (no user text in
1069+ // hand and the run's lifecycle already titles server-side, #1580).
1070+ if ( ! attach && session && session . title === 'New Task' ) {
10501071 const autoTitle = text . slice ( 0 , 50 ) + ( text . length > 50 ? '...' : '' ) ;
10511072 api . updateSession ( sessionId , { title : autoTitle } )
10521073 . then ( ( ) => updateSessionInList ( sessionId , { title : autoTitle } ) )
@@ -1113,14 +1134,51 @@ export function ChatView({ sessionId, onCreateAgent, onAgentChange }: ChatViewPr
11131134 . then ( ( data ) => useChatStore . getState ( ) . setAgents ( data . agents || [ ] ) )
11141135 . catch ( ( ) => { /* non-critical */ } ) ;
11151136 } ,
1116- } , undefined , undefined , activeAgentId ) ;
1137+ } ;
1138+
1139+ const controller = attach
1140+ ? api . attachToRun ( sessionId , streamCallbacks )
1141+ : api . sendMessageStream ( sessionId , messageText , streamCallbacks , undefined , undefined , activeAgentId ) ;
11171142
11181143 abortRef . current = controller ;
11191144 } , [ input , attachments , isStreaming , sessionId , session , addMessage , setMessages , setStreaming , flushStreamBuffer , clearStreamContent , updateSessionInList , addAgentStep , updateLastAgentStep , appendThinkingContent , updateLastToolStep , clearAgentSteps , activeAgentId , addNotification , isStale ] ) ;
11201145
11211146 // Keep ref in sync so event listeners always call the latest sendMessage
11221147 sendMessageRef . current = sendMessage ;
11231148
1149+ // Re-attach to an in-flight background run on mount (#1580). When the
1150+ // user revisits a session whose turn is still running server-side, hook
1151+ // back into its live stream so progress resumes in the view instead of
1152+ // sitting static until the run finishes. Once per session mount; the
1153+ // attach path no-ops if a stream is already live here.
1154+ const reattachedRef = useRef ( false ) ;
1155+ useEffect ( ( ) => {
1156+ reattachedRef . current = false ;
1157+ } , [ sessionId ] ) ;
1158+ useEffect ( ( ) => {
1159+ const attemptAttach = ( ) => {
1160+ if ( reattachedRef . current ) return ;
1161+ if ( useChatStore . getState ( ) . isStreaming ) return ;
1162+ reattachedRef . current = true ;
1163+ log . chat . info ( `Resuming live view of background run for session=${ sessionId } ` ) ;
1164+ sendMessageRef . current ( undefined , { attach : true } ) ;
1165+ } ;
1166+ // Fast path: the global poll already knows this session is running.
1167+ if ( useChatStore . getState ( ) . runningSessionIds . includes ( sessionId ) ) {
1168+ attemptAttach ( ) ;
1169+ return ;
1170+ }
1171+ // Otherwise confirm once with the backend on mount.
1172+ let cancelled = false ;
1173+ api . getActiveRuns ( )
1174+ . then ( ( { session_ids } ) => {
1175+ if ( cancelled ) return ;
1176+ if ( session_ids . includes ( sessionId ) ) attemptAttach ( ) ;
1177+ } )
1178+ . catch ( ( ) => { /* non-critical — sidebar spinner still signals running */ } ) ;
1179+ return ( ) => { cancelled = true ; } ;
1180+ } , [ sessionId ] ) ;
1181+
11241182 // Listen for programmatic message dispatches from rich-content
11251183 // components (currently the EmailPreScanCard's Approve / Reply
11261184 // buttons). Wired as a window-level CustomEvent rather than prop
0 commit comments