Skip to content

Commit 657cd4e

Browse files
author
Ovtcharov
committed
fix(agent-ui): keep chat runs alive after New Task + show running indicator (#1580)
Clicking New Task (or switching sessions) while an agent was still generating used to abort the run: the SSE was torn down on the old view's unmount, the backend cancelled the turn, and the in-flight answer was discarded with no trace it had ever been running. The user lost both the work and any way to tell the original agent was still going. Now a chat turn is a first-class background run that outlives the SSE connection. Navigating away leaves it running server-side; it finishes and persists to the DB on its own. The sidebar shows a spinner on any session with a live run (backend-truth, so it survives refresh and revisit), and re-opening a running session re-attaches to its live stream so progress resumes in the view. - RunManager owns each run (producer thread + persistence) in a detached task; the SSE endpoint is now a thin subscriber over a replay buffer with multi-subscriber fan-out. - GET /api/chat/active surfaces running session ids; GET /api/chat/attach reconnects to an in-flight run. - Stop now calls the cancel endpoint explicitly (a client disconnect no longer cancels the run), and deleting a session cancels its run so it can't persist to a dead session.
1 parent 5ecf423 commit 657cd4e

14 files changed

Lines changed: 948 additions & 124 deletions

File tree

src/gaia/apps/webui/src/App.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ function App() {
8080
setSystemStatus,
8181
setBackendConnected,
8282
setAgents,
83+
setRunningSessions,
8384
} = useChatStore();
8485
const showNotificationPanel = useNotificationStore((s) => s.showPanel);
8586
const setShowNotificationPanel = useNotificationStore((s) => s.setShowPanel);
@@ -270,6 +271,25 @@ function App() {
270271
};
271272
}, [setSessions, addSession, removeSession, updateSessionInList, setBackendConnected]);
272273

274+
// Poll which sessions have a running turn so the sidebar can show a
275+
// "still running" spinner on backgrounded runs. Backend-truth
276+
// (/api/chat/active), independent of any open SSE stream (#1580).
277+
const activeRunsPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
278+
useEffect(() => {
279+
const poll = () => {
280+
api.getActiveRuns()
281+
.then((data) => setRunningSessions(data.session_ids || []))
282+
.catch(() => { /* non-critical — sidebar just won't show spinners */ });
283+
};
284+
poll();
285+
// 2.6s (off the :00/:30 marks) — responsive enough to feel live without
286+
// hammering the backend.
287+
activeRunsPollRef.current = setInterval(poll, 2_600);
288+
return () => {
289+
if (activeRunsPollRef.current) clearInterval(activeRunsPollRef.current);
290+
};
291+
}, [setRunningSessions]);
292+
273293
// Support URL-based session navigation (?session=<id> or #<hash>)
274294
useEffect(() => {
275295
if (currentSessionId) return; // Already have a session selected

src/gaia/apps/webui/src/components/ChatView.tsx

Lines changed: 97 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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 `![${a.name}](${a.serverUrl})`;
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 `![${a.name}](${a.serverUrl})`;
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

src/gaia/apps/webui/src/components/Sidebar.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,16 @@
335335
vertical-align: middle;
336336
}
337337

338+
/* Spinner shown inline before the title while a session's agent is still
339+
running in the background (#1580). Uses the global `spin` keyframe. */
340+
.session-running-spinner {
341+
flex-shrink: 0;
342+
margin-right: 4px;
343+
color: var(--accent, var(--text-muted));
344+
vertical-align: middle;
345+
animation: spin 1s linear infinite;
346+
}
347+
338348
/* Session hash link -- short permalink for troubleshooting */
339349
.session-hash {
340350
font-size: 9px;

src/gaia/apps/webui/src/components/Sidebar.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: MIT
33

44
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
5-
import { Plus, Search, Settings, Sun, Moon, Trash2, PanelLeftClose, PanelLeftOpen, Smartphone, Brain, EyeOff, Clock } from 'lucide-react';
5+
import { Plus, Search, Settings, Sun, Moon, Trash2, PanelLeftClose, PanelLeftOpen, Smartphone, Brain, EyeOff, Clock, Loader2 } from 'lucide-react';
66
import { useChatStore } from '../stores/chatStore';
77
import * as api from '../services/api';
88
import { log } from '../utils/logger';
@@ -34,9 +34,10 @@ interface SidebarProps {
3434
}
3535

3636
/** Extracted session row to share between grouped and flat rendering. */
37-
function SessionItem({ session: s, isActive, isPendingDelete, isDeleting, onSelect, onKeyDown, onDelete, formatTime }: {
37+
function SessionItem({ session: s, isActive, isRunning, isPendingDelete, isDeleting, onSelect, onKeyDown, onDelete, formatTime }: {
3838
session: Session;
3939
isActive: boolean;
40+
isRunning: boolean;
4041
isPendingDelete: boolean;
4142
isDeleting: boolean;
4243
onSelect: (id: string) => void;
@@ -64,6 +65,13 @@ function SessionItem({ session: s, isActive, isPendingDelete, isDeleting, onSele
6465
>
6566
<span className="session-title">
6667
{s.private && <EyeOff size={10} className="session-private-icon" aria-label="Private session" />}
68+
{isRunning && (
69+
<Loader2
70+
size={11}
71+
className="session-running-spinner"
72+
aria-label="Agent running"
73+
/>
74+
)}
6775
{s.title}
6876
</span>
6977
<a
@@ -110,6 +118,7 @@ export function Sidebar({ onNewTask, onHome, tunnelActive, tunnelLoading, onMobi
110118
sidebarCollapsed, toggleSidebarCollapsed,
111119
sidebarWidth, setSidebarWidth,
112120
addPendingDelete, removePendingDelete,
121+
runningSessionIds,
113122
} = useChatStore();
114123

115124
const [search, setSearch] = useState('');
@@ -403,6 +412,7 @@ export function Sidebar({ onNewTask, onHome, tunnelActive, tunnelLoading, onMobi
403412
key={s.id}
404413
session={s}
405414
isActive={s.id === currentSessionId}
415+
isRunning={runningSessionIds.includes(s.id)}
406416
isPendingDelete={pendingDeleteId === s.id}
407417
isDeleting={deletingId === s.id}
408418
onSelect={handleSelect}
@@ -420,6 +430,7 @@ export function Sidebar({ onNewTask, onHome, tunnelActive, tunnelLoading, onMobi
420430
key={s.id}
421431
session={s}
422432
isActive={s.id === currentSessionId}
433+
isRunning={runningSessionIds.includes(s.id)}
423434
isPendingDelete={pendingDeleteId === s.id}
424435
isDeleting={deletingId === s.id}
425436
onSelect={handleSelect}

0 commit comments

Comments
 (0)