akseljoonas HF Staff Claude Opus 4.6 commited on
Commit
854c261
·
1 Parent(s): 3c77a6c

feat: per-session WebSocket architecture for parallel chat sessions

Browse files

Each session now owns its own useAgentChat + WebSocket transport instead
of sharing a single instance. Background sessions process all events
(including approval_required) continuously, fixing the bug where
approvals from non-active sessions were silently dropped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

backend/models.py CHANGED
@@ -61,6 +61,14 @@ class SessionResponse(BaseModel):
61
  ready: bool = True
62
 
63
 
 
 
 
 
 
 
 
 
64
  class SessionInfo(BaseModel):
65
  """Session metadata."""
66
 
@@ -69,6 +77,7 @@ class SessionInfo(BaseModel):
69
  is_active: bool
70
  message_count: int
71
  user_id: str = "dev"
 
72
 
73
 
74
  class HealthResponse(BaseModel):
 
61
  ready: bool = True
62
 
63
 
64
+ class PendingApprovalTool(BaseModel):
65
+ """A tool waiting for user approval."""
66
+
67
+ tool: str
68
+ tool_call_id: str
69
+ arguments: dict[str, Any] = {}
70
+
71
+
72
  class SessionInfo(BaseModel):
73
  """Session metadata."""
74
 
 
77
  is_active: bool
78
  message_count: int
79
  user_id: str = "dev"
80
+ pending_approval: list[PendingApprovalTool] | None = None
81
 
82
 
83
  class HealthResponse(BaseModel):
backend/session_manager.py CHANGED
@@ -344,12 +344,30 @@ class SessionManager:
344
  if not agent_session:
345
  return None
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  return {
348
  "session_id": session_id,
349
  "created_at": agent_session.created_at.isoformat(),
350
  "is_active": agent_session.is_active,
351
  "message_count": len(agent_session.session.context_manager.items),
352
  "user_id": agent_session.user_id,
 
353
  }
354
 
355
  def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
 
344
  if not agent_session:
345
  return None
346
 
347
+ # Extract pending approval tools if any
348
+ pending_approval = None
349
+ pa = agent_session.session.pending_approval
350
+ if pa and pa.get("tool_calls"):
351
+ pending_approval = []
352
+ for tc in pa["tool_calls"]:
353
+ import json
354
+ try:
355
+ args = json.loads(tc.function.arguments)
356
+ except (json.JSONDecodeError, AttributeError):
357
+ args = {}
358
+ pending_approval.append({
359
+ "tool": tc.function.name,
360
+ "tool_call_id": tc.id,
361
+ "arguments": args,
362
+ })
363
+
364
  return {
365
  "session_id": session_id,
366
  "created_at": agent_session.created_at.isoformat(),
367
  "is_active": agent_session.is_active,
368
  "message_count": len(agent_session.session.context_manager.items),
369
  "user_id": agent_session.user_id,
370
+ "pending_approval": pending_approval,
371
  }
372
 
373
  def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -16,32 +16,29 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
16
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
17
  import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
18
  import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
19
- import { logger } from '@/utils/logger';
20
 
21
  import { useSessionStore } from '@/store/sessionStore';
22
  import { useAgentStore } from '@/store/agentStore';
23
  import { useLayoutStore } from '@/store/layoutStore';
24
- import { useAgentChat } from '@/hooks/useAgentChat';
25
  import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
 
26
  import CodePanel from '@/components/CodePanel/CodePanel';
27
- import ChatInput from '@/components/Chat/ChatInput';
28
- import MessageList from '@/components/Chat/MessageList';
29
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
30
  import { apiFetch } from '@/utils/api';
31
 
32
  const DRAWER_WIDTH = 260;
33
 
34
  export default function AppLayout() {
35
- const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
36
- const { isConnected, isProcessing, setProcessing, activityStatus, llmHealthError, setLlmHealthError, user } = useAgentStore();
37
- const {
38
- isLeftSidebarOpen,
39
- isRightPanelOpen,
40
  rightPanelWidth,
41
  themeMode,
42
  setRightPanelWidth,
43
  setLeftSidebarOpen,
44
- toggleLeftSidebar,
45
  toggleTheme,
46
  } = useLayoutStore();
47
 
@@ -85,7 +82,7 @@ export default function AppLayout() {
85
  };
86
  }, [handleMouseMove, stopResizing]);
87
 
88
- // ── LLM health check on mount ───────────────────────────────────
89
  useEffect(() => {
90
  let cancelled = false;
91
  (async () => {
@@ -102,7 +99,7 @@ export default function AppLayout() {
102
  setLlmHealthError(null);
103
  }
104
  } catch {
105
- // Backend unreachable not an LLM issue, ignore
106
  }
107
  })();
108
  return () => { cancelled = true; };
@@ -110,19 +107,9 @@ export default function AppLayout() {
110
 
111
  const hasAnySessions = sessions.length > 0;
112
 
113
- const { messages, sendMessage, stop, undoLastTurn, approveTools, flushMessages } = useAgentChat({
114
- sessionId: activeSessionId,
115
- onReady: () => logger.log('Agent ready'),
116
- onError: (error) => logger.error('Agent error:', error),
117
- onSessionDead: (deadSessionId) => {
118
- logger.log('Removing dead session:', deadSessionId);
119
- deleteSession(deadSessionId);
120
- },
121
- });
122
-
123
- // Debounced "session expired" toast — only fires after 2s of sustained disconnect
124
  useEffect(() => {
125
- if (!isConnected && messages.length > 0 && activeSessionId) {
126
  disconnectTimer.current = setTimeout(() => setShowExpiredToast(true), 2000);
127
  } else {
128
  if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
@@ -132,34 +119,13 @@ export default function AppLayout() {
132
  return () => {
133
  if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
134
  };
135
- }, [isConnected, messages.length, activeSessionId]);
136
-
137
- const handleSendMessage = useCallback(
138
- async (text: string) => {
139
- if (!activeSessionId || !text.trim() || isProcessing) return;
140
-
141
- setProcessing(true);
142
- sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
143
 
144
- // Auto-title the session from the first user message (async, non-blocking)
145
- const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1;
146
- if (isFirstMessage) {
147
- const sessionId = activeSessionId;
148
- apiFetch('/api/title', {
149
- method: 'POST',
150
- body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
151
- })
152
- .then((res) => res.json())
153
- .then((data) => {
154
- if (data.title) updateSessionTitle(sessionId, data.title);
155
- })
156
- .catch(() => {
157
- const raw = text.trim();
158
- updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
159
- });
160
- }
161
  },
162
- [activeSessionId, sendMessage, messages, updateSessionTitle, isProcessing, setProcessing],
163
  );
164
 
165
  // Close sidebar on mobile after selecting a session
@@ -167,7 +133,7 @@ export default function AppLayout() {
167
  if (isMobile) setLeftSidebarOpen(false);
168
  }, [isMobile, setLeftSidebarOpen]);
169
 
170
- // ── LLM error toast helper ──────────────────────────────────────────
171
  const llmErrorTitle = llmHealthError
172
  ? llmHealthError.errorType === 'credits'
173
  ? 'API Credits Exhausted'
@@ -180,7 +146,7 @@ export default function AppLayout() {
180
  : 'LLM Error'
181
  : '';
182
 
183
- // ── Welcome screen: no sessions at all ────────────────────────────
184
  if (!hasAnySessions) {
185
  return (
186
  <Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
@@ -189,14 +155,14 @@ export default function AppLayout() {
189
  );
190
  }
191
 
192
- // ── Sidebar drawer ────────────────────────────────────────────────
193
  const sidebarDrawer = (
194
  <Drawer
195
  variant={isMobile ? 'temporary' : 'persistent'}
196
  anchor="left"
197
  open={isLeftSidebarOpen}
198
  onClose={() => setLeftSidebarOpen(false)}
199
- ModalProps={{ keepMounted: true }} // Better mobile perf
200
  sx={{
201
  '& .MuiDrawer-paper': {
202
  boxSizing: 'border-box',
@@ -209,19 +175,17 @@ export default function AppLayout() {
209
  },
210
  }}
211
  >
212
- <SessionSidebar onClose={handleSidebarClose} onBeforeSwitch={flushMessages} />
213
  </Drawer>
214
  );
215
 
216
- // ── Main chat interface ───────────────────────────────────────────
217
  return (
218
  <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
219
- {/* ── Left Sidebar ─────────────────────────────────────────── */}
220
  {isMobile ? (
221
- // Mobile: temporary overlay drawer (no reserved width)
222
  sidebarDrawer
223
  ) : (
224
- // Desktop: persistent drawer with reserved width
225
  <Box
226
  component="nav"
227
  sx={{
@@ -235,7 +199,7 @@ export default function AppLayout() {
235
  </Box>
236
  )}
237
 
238
- {/* ── Main Content (header + chat + code panel) ────────────── */}
239
  <Box
240
  sx={{
241
  flexGrow: 1,
@@ -247,13 +211,13 @@ export default function AppLayout() {
247
  minWidth: 0,
248
  }}
249
  >
250
- {/* ── Top Header Bar ─────────────────────────────────────── */}
251
- <Box sx={{
252
  height: { xs: 52, md: 60 },
253
- px: { xs: 1, md: 2 },
254
- display: 'flex',
255
- alignItems: 'center',
256
- borderBottom: 1,
257
  borderColor: 'divider',
258
  bgcolor: 'background.default',
259
  zIndex: 1200,
@@ -262,7 +226,7 @@ export default function AppLayout() {
262
  <IconButton onClick={toggleLeftSidebar} size="small">
263
  {isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
264
  </IconButton>
265
-
266
  <Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
267
  <Box
268
  component="img"
@@ -318,7 +282,7 @@ export default function AppLayout() {
318
  </Box>
319
  </Box>
320
 
321
- {/* ── Chat + Code Panel ──────────────────────────────────── */}
322
  <Box
323
  sx={{
324
  flexGrow: 1,
@@ -341,16 +305,16 @@ export default function AppLayout() {
341
  }}
342
  >
343
  {activeSessionId ? (
344
- <>
345
- <MessageList messages={messages} isProcessing={isProcessing} approveTools={approveTools} onUndoLastTurn={undoLastTurn} />
346
- <ChatInput
347
- onSend={handleSendMessage}
348
- onStop={stop}
349
- isProcessing={isProcessing}
350
- disabled={!isConnected || activityStatus.type === 'waiting-approval'}
351
- placeholder={activityStatus.type === 'waiting-approval' ? 'Approve or reject pending tools first...' : undefined}
352
  />
353
- </>
354
  ) : (
355
  <Box
356
  sx={{
@@ -373,7 +337,7 @@ export default function AppLayout() {
373
  )}
374
  </Box>
375
 
376
- {/* Code panel inline on desktop, overlay drawer on mobile */}
377
  {isRightPanelOpen && !isMobile && (
378
  <>
379
  <Box
@@ -390,8 +354,8 @@ export default function AppLayout() {
390
  '&:hover': { bgcolor: 'primary.main' },
391
  }}
392
  >
393
- <DragIndicatorIcon
394
- sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
395
  />
396
  </Box>
397
  <Box
@@ -412,7 +376,7 @@ export default function AppLayout() {
412
  </Box>
413
  </Box>
414
 
415
- {/* Code panel drawer overlay on mobile */}
416
  {isMobile && (
417
  <Drawer
418
  anchor="bottom"
 
16
  import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
17
  import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
18
  import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
 
19
 
20
  import { useSessionStore } from '@/store/sessionStore';
21
  import { useAgentStore } from '@/store/agentStore';
22
  import { useLayoutStore } from '@/store/layoutStore';
 
23
  import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
24
+ import SessionChat from '@/components/SessionChat';
25
  import CodePanel from '@/components/CodePanel/CodePanel';
 
 
26
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
27
  import { apiFetch } from '@/utils/api';
28
 
29
  const DRAWER_WIDTH = 260;
30
 
31
  export default function AppLayout() {
32
+ const { sessions, activeSessionId, deleteSession } = useSessionStore();
33
+ const { isConnected, llmHealthError, setLlmHealthError, user } = useAgentStore();
34
+ const {
35
+ isLeftSidebarOpen,
36
+ isRightPanelOpen,
37
  rightPanelWidth,
38
  themeMode,
39
  setRightPanelWidth,
40
  setLeftSidebarOpen,
41
+ toggleLeftSidebar,
42
  toggleTheme,
43
  } = useLayoutStore();
44
 
 
82
  };
83
  }, [handleMouseMove, stopResizing]);
84
 
85
+ // -- LLM health check on mount -----------------------------------------
86
  useEffect(() => {
87
  let cancelled = false;
88
  (async () => {
 
99
  setLlmHealthError(null);
100
  }
101
  } catch {
102
+ // Backend unreachable -- not an LLM issue, ignore
103
  }
104
  })();
105
  return () => { cancelled = true; };
 
107
 
108
  const hasAnySessions = sessions.length > 0;
109
 
110
+ // Debounced "session expired" toast
 
 
 
 
 
 
 
 
 
 
111
  useEffect(() => {
112
+ if (!isConnected && activeSessionId) {
113
  disconnectTimer.current = setTimeout(() => setShowExpiredToast(true), 2000);
114
  } else {
115
  if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
 
119
  return () => {
120
  if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
121
  };
122
+ }, [isConnected, activeSessionId]);
 
 
 
 
 
 
 
123
 
124
+ const handleSessionDead = useCallback(
125
+ (deadSessionId: string) => {
126
+ deleteSession(deadSessionId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  },
128
+ [deleteSession],
129
  );
130
 
131
  // Close sidebar on mobile after selecting a session
 
133
  if (isMobile) setLeftSidebarOpen(false);
134
  }, [isMobile, setLeftSidebarOpen]);
135
 
136
+ // -- LLM error toast helper --------------------------------------------
137
  const llmErrorTitle = llmHealthError
138
  ? llmHealthError.errorType === 'credits'
139
  ? 'API Credits Exhausted'
 
146
  : 'LLM Error'
147
  : '';
148
 
149
+ // -- Welcome screen: no sessions at all ---------------------------------
150
  if (!hasAnySessions) {
151
  return (
152
  <Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
 
155
  );
156
  }
157
 
158
+ // -- Sidebar drawer -----------------------------------------------------
159
  const sidebarDrawer = (
160
  <Drawer
161
  variant={isMobile ? 'temporary' : 'persistent'}
162
  anchor="left"
163
  open={isLeftSidebarOpen}
164
  onClose={() => setLeftSidebarOpen(false)}
165
+ ModalProps={{ keepMounted: true }}
166
  sx={{
167
  '& .MuiDrawer-paper': {
168
  boxSizing: 'border-box',
 
175
  },
176
  }}
177
  >
178
+ <SessionSidebar onClose={handleSidebarClose} />
179
  </Drawer>
180
  );
181
 
182
+ // -- Main chat interface ------------------------------------------------
183
  return (
184
  <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
185
+ {/* -- Left Sidebar ------------------------------------------------- */}
186
  {isMobile ? (
 
187
  sidebarDrawer
188
  ) : (
 
189
  <Box
190
  component="nav"
191
  sx={{
 
199
  </Box>
200
  )}
201
 
202
+ {/* -- Main Content (header + chat + code panel) -------------------- */}
203
  <Box
204
  sx={{
205
  flexGrow: 1,
 
211
  minWidth: 0,
212
  }}
213
  >
214
+ {/* -- Top Header Bar --------------------------------------------- */}
215
+ <Box sx={{
216
  height: { xs: 52, md: 60 },
217
+ px: { xs: 1, md: 2 },
218
+ display: 'flex',
219
+ alignItems: 'center',
220
+ borderBottom: 1,
221
  borderColor: 'divider',
222
  bgcolor: 'background.default',
223
  zIndex: 1200,
 
226
  <IconButton onClick={toggleLeftSidebar} size="small">
227
  {isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
228
  </IconButton>
229
+
230
  <Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
231
  <Box
232
  component="img"
 
282
  </Box>
283
  </Box>
284
 
285
+ {/* -- Chat + Code Panel ------------------------------------------ */}
286
  <Box
287
  sx={{
288
  flexGrow: 1,
 
305
  }}
306
  >
307
  {activeSessionId ? (
308
+ // Render ALL sessions — each owns its own useAgentChat.
309
+ // Only the active one renders visible UI (others return null).
310
+ sessions.map((s) => (
311
+ <SessionChat
312
+ key={s.id}
313
+ sessionId={s.id}
314
+ isActive={s.id === activeSessionId}
315
+ onSessionDead={handleSessionDead}
316
  />
317
+ ))
318
  ) : (
319
  <Box
320
  sx={{
 
337
  )}
338
  </Box>
339
 
340
+ {/* Code panel -- inline on desktop, overlay drawer on mobile */}
341
  {isRightPanelOpen && !isMobile && (
342
  <>
343
  <Box
 
354
  '&:hover': { bgcolor: 'primary.main' },
355
  }}
356
  >
357
+ <DragIndicatorIcon
358
+ sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
359
  />
360
  </Box>
361
  <Box
 
376
  </Box>
377
  </Box>
378
 
379
+ {/* Code panel -- drawer overlay on mobile */}
380
  {isMobile && (
381
  <Drawer
382
  anchor="bottom"
frontend/src/components/SessionChat.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Per-session chat component.
3
+ *
4
+ * Each session renders its own SessionChat. The hook (useAgentChat) always
5
+ * runs — keeping the WebSocket alive and processing events — but only the
6
+ * active session renders visible UI (MessageList + ChatInput).
7
+ */
8
+ import { useCallback, useEffect, useRef } from 'react';
9
+ import { useAgentChat } from '@/hooks/useAgentChat';
10
+ import { useAgentStore } from '@/store/agentStore';
11
+ import { useSessionStore } from '@/store/sessionStore';
12
+ import { useLayoutStore } from '@/store/layoutStore';
13
+ import MessageList from '@/components/Chat/MessageList';
14
+ import ChatInput from '@/components/Chat/ChatInput';
15
+ import { apiFetch } from '@/utils/api';
16
+ import { logger } from '@/utils/logger';
17
+
18
+ interface SessionChatProps {
19
+ sessionId: string;
20
+ isActive: boolean;
21
+ onSessionDead: (sessionId: string) => void;
22
+ }
23
+
24
+ export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) {
25
+ const { isConnected, isProcessing, setProcessing, activityStatus } = useAgentStore();
26
+ const { updateSessionTitle } = useSessionStore();
27
+
28
+ const { messages, sendMessage, stop, undoLastTurn, approveTools, transport } = useAgentChat({
29
+ sessionId,
30
+ isActive,
31
+ onReady: () => logger.log(`Session ${sessionId} ready`),
32
+ onError: (error) => logger.error(`Session ${sessionId} error:`, error),
33
+ onSessionDead,
34
+ });
35
+
36
+ // When this session becomes active, sync ALL global agentStore state
37
+ // so the UI correctly reflects this session's current state.
38
+ const prevActiveRef = useRef(isActive);
39
+ useEffect(() => {
40
+ if (isActive && !prevActiveRef.current) {
41
+ const store = useAgentStore.getState();
42
+
43
+ // Sync WebSocket connection state
44
+ const wsConnected = transport?.isWebSocketConnected() ?? false;
45
+ store.setConnected(wsConnected);
46
+
47
+ // Check if this session has pending approvals in its messages
48
+ const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
49
+ const hasPendingApproval = lastAssistant?.parts.some(
50
+ (p) => p.type === 'dynamic-tool' && p.state === 'approval-requested'
51
+ ) ?? false;
52
+
53
+ if (hasPendingApproval) {
54
+ store.setActivityStatus({ type: 'waiting-approval' });
55
+ store.setProcessing(false);
56
+
57
+ // Restore panel for the first pending tool
58
+ const pendingTool = lastAssistant!.parts.find(
59
+ (p) => p.type === 'dynamic-tool' && p.state === 'approval-requested'
60
+ );
61
+ if (pendingTool && pendingTool.type === 'dynamic-tool') {
62
+ const args = pendingTool.input as Record<string, string | undefined>;
63
+ if (pendingTool.toolName === 'hf_jobs' && args?.script) {
64
+ store.setPanel(
65
+ { title: 'Script', script: { content: args.script, language: 'python' }, parameters: pendingTool.input as Record<string, unknown> },
66
+ 'script',
67
+ true,
68
+ );
69
+ } else if (pendingTool.toolName === 'hf_repo_files' && args?.content) {
70
+ const filename = args.path || 'file';
71
+ store.setPanel({
72
+ title: filename.split('/').pop() || 'Content',
73
+ script: { content: args.content, language: filename.endsWith('.py') ? 'python' : 'text' },
74
+ parameters: pendingTool.input as Record<string, unknown>,
75
+ });
76
+ } else {
77
+ store.setPanel({
78
+ title: pendingTool.toolName,
79
+ output: { content: JSON.stringify(pendingTool.input, null, 2), language: 'json' },
80
+ }, 'output');
81
+ }
82
+ useLayoutStore.getState().setRightPanelOpen(true);
83
+ }
84
+ } else {
85
+ // No pending approval — reset to idle
86
+ store.setActivityStatus({ type: 'idle' });
87
+ store.setProcessing(false);
88
+ }
89
+ }
90
+ prevActiveRef.current = isActive;
91
+ }, [isActive, messages, transport]);
92
+
93
+ const handleSendMessage = useCallback(
94
+ async (text: string) => {
95
+ if (!text.trim() || isProcessing) return;
96
+
97
+ setProcessing(true);
98
+ sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
99
+
100
+ // Auto-title the session from the first user message
101
+ const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1;
102
+ if (isFirstMessage) {
103
+ apiFetch('/api/title', {
104
+ method: 'POST',
105
+ body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
106
+ })
107
+ .then((res) => res.json())
108
+ .then((data) => {
109
+ if (data.title) updateSessionTitle(sessionId, data.title);
110
+ })
111
+ .catch(() => {
112
+ const raw = text.trim();
113
+ updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw);
114
+ });
115
+ }
116
+ },
117
+ [sessionId, sendMessage, messages, updateSessionTitle, isProcessing, setProcessing],
118
+ );
119
+
120
+ // Don't render UI for background sessions — hooks still run
121
+ if (!isActive) return null;
122
+
123
+ return (
124
+ <>
125
+ <MessageList
126
+ messages={messages}
127
+ isProcessing={isProcessing}
128
+ approveTools={approveTools}
129
+ onUndoLastTurn={undoLastTurn}
130
+ />
131
+ <ChatInput
132
+ onSend={handleSendMessage}
133
+ onStop={stop}
134
+ isProcessing={isProcessing}
135
+ disabled={!isConnected || activityStatus.type === 'waiting-approval'}
136
+ placeholder={activityStatus.type === 'waiting-approval' ? 'Approve or reject pending tools first...' : undefined}
137
+ />
138
+ </>
139
+ );
140
+ }
frontend/src/components/SessionSidebar/SessionSidebar.tsx CHANGED
@@ -16,7 +16,6 @@ import { apiFetch } from '@/utils/api';
16
 
17
  interface SessionSidebarProps {
18
  onClose?: () => void;
19
- onBeforeSwitch?: () => void;
20
  }
21
 
22
  /** Small coloured dot for connection status */
@@ -33,7 +32,7 @@ const StatusDot = ({ connected }: { connected: boolean }) => (
33
  />
34
  );
35
 
36
- export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSidebarProps) {
37
  const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
38
  useSessionStore();
39
  const { isConnected, setPlan, clearPanel } =
@@ -41,11 +40,10 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
41
  const [isCreatingSession, setIsCreatingSession] = useState(false);
42
  const [capacityError, setCapacityError] = useState<string | null>(null);
43
 
44
- // ── Handlers ──────────────────────────────────────────────────────
45
 
46
  const handleNewSession = useCallback(async () => {
47
  if (isCreatingSession) return;
48
- onBeforeSwitch?.();
49
  setIsCreatingSession(true);
50
  setCapacityError(null);
51
  try {
@@ -65,7 +63,7 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
65
  } finally {
66
  setIsCreatingSession(false);
67
  }
68
- }, [isCreatingSession, onBeforeSwitch, createSession, setPlan, clearPanel, onClose]);
69
 
70
  const handleDelete = useCallback(
71
  async (sessionId: string, e: React.MouseEvent) => {
@@ -74,7 +72,6 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
74
  await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
75
  deleteSession(sessionId);
76
  } catch {
77
- // Delete locally even if backend fails (session may already be gone)
78
  deleteSession(sessionId);
79
  }
80
  },
@@ -83,19 +80,18 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
83
 
84
  const handleSelect = useCallback(
85
  (sessionId: string) => {
86
- onBeforeSwitch?.();
87
  switchSession(sessionId);
88
  setPlan([]);
89
  clearPanel();
90
  onClose?.();
91
  },
92
- [onBeforeSwitch, switchSession, setPlan, clearPanel, onClose],
93
  );
94
 
95
  const formatTime = (d: string) =>
96
  new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
97
 
98
- // ── Render ────────────────────────────────────────────────────────
99
 
100
  return (
101
  <Box
@@ -106,7 +102,7 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
106
  bgcolor: 'var(--panel)',
107
  }}
108
  >
109
- {/* ── Header ─────────────────────────────────────────────────── */}
110
  <Box sx={{ px: 1.75, pt: 2, pb: 0 }}>
111
  <Typography
112
  variant="caption"
@@ -122,7 +118,7 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
122
  </Typography>
123
  </Box>
124
 
125
- {/* ── Capacity error ─────────────────────────────────────────── */}
126
  {capacityError && (
127
  <Alert
128
  severity="warning"
@@ -141,13 +137,12 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
141
  </Alert>
142
  )}
143
 
144
- {/* ── Session list ───────────────────────────────────────────── */}
145
  <Box
146
  sx={{
147
  flex: 1,
148
  overflow: 'auto',
149
  py: 1,
150
- // Thinner scrollbar
151
  '&::-webkit-scrollbar': { width: 4 },
152
  '&::-webkit-scrollbar-thumb': {
153
  bgcolor: 'var(--scrollbar-thumb)',
@@ -253,6 +248,24 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
253
  </Typography>
254
  </Box>
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  <IconButton
257
  className="delete-btn"
258
  size="small"
@@ -273,7 +286,7 @@ export default function SessionSidebar({ onClose, onBeforeSwitch }: SessionSideb
273
  )}
274
  </Box>
275
 
276
- {/* ── Footer: New Task + status ──────────────────────────── */}
277
  <Divider sx={{ opacity: 0.5 }} />
278
  <Box
279
  sx={{
 
16
 
17
  interface SessionSidebarProps {
18
  onClose?: () => void;
 
19
  }
20
 
21
  /** Small coloured dot for connection status */
 
32
  />
33
  );
34
 
35
+ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
36
  const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
37
  useSessionStore();
38
  const { isConnected, setPlan, clearPanel } =
 
40
  const [isCreatingSession, setIsCreatingSession] = useState(false);
41
  const [capacityError, setCapacityError] = useState<string | null>(null);
42
 
43
+ // -- Handlers -----------------------------------------------------------
44
 
45
  const handleNewSession = useCallback(async () => {
46
  if (isCreatingSession) return;
 
47
  setIsCreatingSession(true);
48
  setCapacityError(null);
49
  try {
 
63
  } finally {
64
  setIsCreatingSession(false);
65
  }
66
+ }, [isCreatingSession, createSession, setPlan, clearPanel, onClose]);
67
 
68
  const handleDelete = useCallback(
69
  async (sessionId: string, e: React.MouseEvent) => {
 
72
  await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
73
  deleteSession(sessionId);
74
  } catch {
 
75
  deleteSession(sessionId);
76
  }
77
  },
 
80
 
81
  const handleSelect = useCallback(
82
  (sessionId: string) => {
 
83
  switchSession(sessionId);
84
  setPlan([]);
85
  clearPanel();
86
  onClose?.();
87
  },
88
+ [switchSession, setPlan, clearPanel, onClose],
89
  );
90
 
91
  const formatTime = (d: string) =>
92
  new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
93
 
94
+ // -- Render -------------------------------------------------------------
95
 
96
  return (
97
  <Box
 
102
  bgcolor: 'var(--panel)',
103
  }}
104
  >
105
+ {/* -- Header -------------------------------------------------------- */}
106
  <Box sx={{ px: 1.75, pt: 2, pb: 0 }}>
107
  <Typography
108
  variant="caption"
 
118
  </Typography>
119
  </Box>
120
 
121
+ {/* -- Capacity error ------------------------------------------------ */}
122
  {capacityError && (
123
  <Alert
124
  severity="warning"
 
137
  </Alert>
138
  )}
139
 
140
+ {/* -- Session list -------------------------------------------------- */}
141
  <Box
142
  sx={{
143
  flex: 1,
144
  overflow: 'auto',
145
  py: 1,
 
146
  '&::-webkit-scrollbar': { width: 4 },
147
  '&::-webkit-scrollbar-thumb': {
148
  bgcolor: 'var(--scrollbar-thumb)',
 
248
  </Typography>
249
  </Box>
250
 
251
+ {/* Attention badge — pulsing dot when background session needs approval */}
252
+ {session.needsAttention && !isSelected && (
253
+ <Box
254
+ sx={{
255
+ width: 8,
256
+ height: 8,
257
+ borderRadius: '50%',
258
+ bgcolor: 'var(--accent-yellow)',
259
+ flexShrink: 0,
260
+ animation: 'pulse 2s ease-in-out infinite',
261
+ '@keyframes pulse': {
262
+ '0%, 100%': { opacity: 1, transform: 'scale(1)' },
263
+ '50%': { opacity: 0.5, transform: 'scale(0.8)' },
264
+ },
265
+ }}
266
+ />
267
+ )}
268
+
269
  <IconButton
270
  className="delete-btn"
271
  size="small"
 
286
  )}
287
  </Box>
288
 
289
+ {/* -- Footer: New Task + status ------------------------------------- */}
290
  <Divider sx={{ opacity: 0.5 }} />
291
  <Box
292
  sx={{
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -1,7 +1,11 @@
1
  /**
2
  * Central hook wiring the Vercel AI SDK's useChat with our custom
3
- * WebSocketChatTransport. Replaces the old useAgentWebSocket + agentStore
4
- * message management.
 
 
 
 
5
  */
6
  import { useCallback, useEffect, useMemo, useRef } from 'react';
7
  import { useChat } from '@ai-sdk/react';
@@ -16,16 +20,20 @@ import { useLayoutStore } from '@/store/layoutStore';
16
  import { logger } from '@/utils/logger';
17
 
18
  interface UseAgentChatOptions {
19
- sessionId: string | null;
 
20
  onReady?: () => void;
21
  onError?: (error: string) => void;
22
  onSessionDead?: (sessionId: string) => void;
23
  }
24
 
25
- export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: UseAgentChatOptions) {
26
  const callbacksRef = useRef({ onReady, onError, onSessionDead });
27
  callbacksRef.current = { onReady, onError, onSessionDead };
28
 
 
 
 
29
  const {
30
  setProcessing,
31
  setConnected,
@@ -36,35 +44,46 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
36
  } = useAgentStore();
37
 
38
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
39
- const { setSessionActive } = useSessionStore();
40
 
41
- // ── Build side-channel callbacks (stable ref) ────────────────────
 
42
  const sideChannel = useMemo<SideChannelCallbacks>(
43
  () => ({
44
  onReady: () => {
45
- setConnected(true);
46
- setProcessing(false);
47
- if (sessionId) setSessionActive(sessionId, true);
 
 
48
  callbacksRef.current.onReady?.();
49
  },
50
  onShutdown: () => {
51
- setConnected(false);
52
- setProcessing(false);
 
 
53
  },
54
  onError: (error: string) => {
55
- setError(error);
56
- setProcessing(false);
 
 
57
  callbacksRef.current.onError?.(error);
58
  },
59
  onProcessing: () => {
60
- setProcessing(true);
61
- setActivityStatus({ type: 'thinking' });
 
 
62
  },
63
  onProcessingDone: () => {
64
- setProcessing(false);
 
 
65
  },
66
  onUndoComplete: () => {
67
- setProcessing(false);
68
  // Remove the last turn (user msg + assistant response) from useChat state
69
  const setMsgs = chatActionsRef.current.setMessages;
70
  const msgs = chatActionsRef.current.messages;
@@ -75,19 +94,21 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
75
  }
76
  const updated = lastUserIdx > 0 ? msgs.slice(0, lastUserIdx) : [];
77
  setMsgs(updated);
78
- if (sessionId) saveMessages(sessionId, updated);
79
  }
80
  },
81
  onCompacted: (oldTokens: number, newTokens: number) => {
82
- logger.log(`Context compacted: ${oldTokens} ${newTokens} tokens`);
83
  },
84
  onPlanUpdate: (plan) => {
 
85
  useAgentStore.getState().setPlan(plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>);
86
  if (!useLayoutStore.getState().isRightPanelOpen) {
87
  setRightPanelOpen(true);
88
  }
89
  },
90
  onToolLog: (tool: string, log: string) => {
 
91
  if (tool === 'hf_jobs') {
92
  const state = useAgentStore.getState();
93
  const existingOutput = state.panelData?.output?.content || '';
@@ -103,7 +124,7 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
103
  }
104
  },
105
  onConnectionChange: (connected: boolean) => {
106
- setConnected(connected);
107
  },
108
  onSessionDead: (deadSessionId: string) => {
109
  logger.warn(`Session ${deadSessionId} dead, removing`);
@@ -111,6 +132,13 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
111
  },
112
  onApprovalRequired: (tools) => {
113
  if (!tools.length) return;
 
 
 
 
 
 
 
114
  setActivityStatus({ type: 'waiting-approval' });
115
  const firstTool = tools[0];
116
  const args = firstTool.arguments as Record<string, string | undefined>;
@@ -139,6 +167,7 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
139
  setLeftSidebarOpen(false);
140
  },
141
  onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
 
142
  if (toolName === 'hf_jobs' && args.operation && args.script) {
143
  setPanel(
144
  { title: 'Script', script: { content: String(args.script), language: 'python' }, parameters: args },
@@ -157,35 +186,36 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
157
  }
158
  },
159
  onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
 
160
  if (toolName === 'hf_jobs' && output) {
161
  setPanelOutput({ content: output, language: 'markdown' });
162
  if (!success) useAgentStore.getState().setPanelView('output');
163
  }
164
  },
165
  onStreaming: () => {
166
- setActivityStatus({ type: 'streaming' });
167
  },
168
  onToolRunning: (toolName: string) => {
169
- setActivityStatus({ type: 'tool', toolName });
170
  },
171
  }),
172
- // Zustand setters are stable
173
  // eslint-disable-next-line react-hooks/exhaustive-deps
174
  [sessionId],
175
  );
176
 
177
- // ── Create transport (single stable instance for the lifetime of this hook) ──
178
  const transportRef = useRef<WebSocketChatTransport | null>(null);
179
  if (!transportRef.current) {
180
  transportRef.current = new WebSocketChatTransport({ sideChannel });
181
  }
182
 
183
- // Keep side-channel callbacks in sync (they capture sessionId)
184
  useEffect(() => {
185
  transportRef.current?.updateSideChannel(sideChannel);
186
  }, [sideChannel]);
187
 
188
- // Connect / disconnect WebSocket when session changes
189
  useEffect(() => {
190
  transportRef.current?.connectToSession(sessionId);
191
  return () => {
@@ -193,28 +223,38 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
193
  };
194
  }, [sessionId]);
195
 
196
- // ── Restore persisted messages for this session ─────────────────
 
 
 
 
 
 
 
 
197
  const initialMessages = useMemo(
198
- () => (sessionId ? loadMessages(sessionId) : []),
199
  [sessionId],
200
  );
201
 
202
- // ── Ref for chat actions (used by sideChannel callbacks created before chat) ──
203
  const chatActionsRef = useRef<{
204
  setMessages: ((msgs: UIMessage[]) => void) | null;
205
  messages: UIMessage[];
206
  }>({ setMessages: null, messages: [] });
207
 
208
- // ── useChat from Vercel AI SDK ───────────────────────────────────
209
  const chat = useChat({
210
- id: sessionId || '__no_session__',
211
  messages: initialMessages,
212
  transport: transportRef.current!,
213
  experimental_throttle: 80,
214
  onError: (error) => {
215
  logger.error('useChat error:', error);
216
- setError(error.message);
217
- setProcessing(false);
 
 
218
  },
219
  });
220
 
@@ -222,75 +262,87 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
222
  chatActionsRef.current.setMessages = chat.setMessages;
223
  chatActionsRef.current.messages = chat.messages;
224
 
225
- // ── Hydrate from backend when switching to a session ──────────────
226
  useEffect(() => {
227
- if (!sessionId) return;
228
  let cancelled = false;
229
- apiFetch(`/api/session/${sessionId}/messages`)
230
- .then((res) => (res.ok ? res.json() : null))
231
- .then((data) => {
232
- if (cancelled || !data || !Array.isArray(data) || data.length === 0) return;
233
- const uiMsgs = llmMessagesToUIMessages(data);
234
- if (uiMsgs.length > 0) {
235
- chat.setMessages(uiMsgs);
236
- saveMessages(sessionId, uiMsgs);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  }
238
- })
239
- .catch(() => { /* backend unreachable — localStorage fallback is fine */ });
 
 
 
 
 
 
 
 
 
 
 
 
240
  return () => { cancelled = true; };
241
  }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
242
 
243
- // ── Persist messages ──────────────────────────────────────────────
244
- const flushRef = useRef<{ sid: string | null; msgs: UIMessage[] }>({ sid: null, msgs: [] });
245
- flushRef.current.sid = sessionId;
246
- flushRef.current.msgs = chat.messages;
247
-
248
- // Save whenever message count changes (covers user sends + new assistant msgs)
249
  const prevLenRef = useRef(initialMessages.length);
250
  useEffect(() => {
251
- if (!sessionId || chat.messages.length === 0) return;
252
  if (chat.messages.length !== prevLenRef.current) {
253
  prevLenRef.current = chat.messages.length;
254
  saveMessages(sessionId, chat.messages);
255
  }
256
  }, [sessionId, chat.messages]);
257
 
258
- // ── Undo last turn (calls backend + syncs useChat + localStorage) ──
259
  const undoLastTurn = useCallback(async () => {
260
- if (!sessionId) return;
261
  try {
262
  const res = await apiFetch(`/api/undo/${sessionId}`, { method: 'POST' });
263
  if (!res.ok) {
264
  logger.error('Undo API returned', res.status);
265
- return;
266
  }
267
  } catch (e) {
268
  logger.error('Undo failed:', e);
269
  }
270
- // Backend will also send undo_complete, but we apply optimistically
271
- // so the UI updates immediately.
272
  }, [sessionId]);
273
 
274
- // ── Convenience: approve tools via transport ─────────────────────
275
  const approveTools = useCallback(
276
  async (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => {
277
- if (!sessionId || !transportRef.current) return false;
278
  const ok = await transportRef.current.approveTools(sessionId, approvals);
279
  if (ok) {
 
 
280
  const hasApproved = approvals.some(a => a.approved);
281
- if (hasApproved) setProcessing(true);
282
  }
283
  return ok;
284
  },
285
- [sessionId, setProcessing],
286
  );
287
 
288
- // ── Flush current messages to localStorage (call before switching sessions) ──
289
- const flushMessages = useCallback(() => {
290
- const { sid, msgs } = flushRef.current;
291
- if (sid && msgs.length > 0) saveMessages(sid, msgs);
292
- }, []);
293
-
294
  return {
295
  messages: chat.messages,
296
  sendMessage: chat.sendMessage,
@@ -298,7 +350,6 @@ export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: Use
298
  status: chat.status,
299
  undoLastTurn,
300
  approveTools,
301
- flushMessages,
302
  transport: transportRef.current,
303
  };
304
  }
 
1
  /**
2
  * Central hook wiring the Vercel AI SDK's useChat with our custom
3
+ * WebSocketChatTransport.
4
+ *
5
+ * In the per-session architecture, each session mounts its own instance
6
+ * of this hook. The `isActive` flag controls whether side-channel
7
+ * callbacks update the global UI stores (agentStore / layoutStore) or
8
+ * only per-session metadata (sessionStore.needsAttention).
9
  */
10
  import { useCallback, useEffect, useMemo, useRef } from 'react';
11
  import { useChat } from '@ai-sdk/react';
 
20
  import { logger } from '@/utils/logger';
21
 
22
  interface UseAgentChatOptions {
23
+ sessionId: string;
24
+ isActive: boolean;
25
  onReady?: () => void;
26
  onError?: (error: string) => void;
27
  onSessionDead?: (sessionId: string) => void;
28
  }
29
 
30
+ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionDead }: UseAgentChatOptions) {
31
  const callbacksRef = useRef({ onReady, onError, onSessionDead });
32
  callbacksRef.current = { onReady, onError, onSessionDead };
33
 
34
+ const isActiveRef = useRef(isActive);
35
+ isActiveRef.current = isActive;
36
+
37
  const {
38
  setProcessing,
39
  setConnected,
 
44
  } = useAgentStore();
45
 
46
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
47
+ const { setSessionActive, setNeedsAttention } = useSessionStore();
48
 
49
+ // -- Build side-channel callbacks (stable ref) --------------------------
50
+ // These check isActiveRef to decide whether to update global UI state.
51
  const sideChannel = useMemo<SideChannelCallbacks>(
52
  () => ({
53
  onReady: () => {
54
+ if (isActiveRef.current) {
55
+ setConnected(true);
56
+ setProcessing(false);
57
+ }
58
+ setSessionActive(sessionId, true);
59
  callbacksRef.current.onReady?.();
60
  },
61
  onShutdown: () => {
62
+ if (isActiveRef.current) {
63
+ setConnected(false);
64
+ setProcessing(false);
65
+ }
66
  },
67
  onError: (error: string) => {
68
+ if (isActiveRef.current) {
69
+ setError(error);
70
+ setProcessing(false);
71
+ }
72
  callbacksRef.current.onError?.(error);
73
  },
74
  onProcessing: () => {
75
+ if (isActiveRef.current) {
76
+ setProcessing(true);
77
+ setActivityStatus({ type: 'thinking' });
78
+ }
79
  },
80
  onProcessingDone: () => {
81
+ if (isActiveRef.current) {
82
+ setProcessing(false);
83
+ }
84
  },
85
  onUndoComplete: () => {
86
+ if (isActiveRef.current) setProcessing(false);
87
  // Remove the last turn (user msg + assistant response) from useChat state
88
  const setMsgs = chatActionsRef.current.setMessages;
89
  const msgs = chatActionsRef.current.messages;
 
94
  }
95
  const updated = lastUserIdx > 0 ? msgs.slice(0, lastUserIdx) : [];
96
  setMsgs(updated);
97
+ saveMessages(sessionId, updated);
98
  }
99
  },
100
  onCompacted: (oldTokens: number, newTokens: number) => {
101
+ logger.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
102
  },
103
  onPlanUpdate: (plan) => {
104
+ if (!isActiveRef.current) return;
105
  useAgentStore.getState().setPlan(plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>);
106
  if (!useLayoutStore.getState().isRightPanelOpen) {
107
  setRightPanelOpen(true);
108
  }
109
  },
110
  onToolLog: (tool: string, log: string) => {
111
+ if (!isActiveRef.current) return;
112
  if (tool === 'hf_jobs') {
113
  const state = useAgentStore.getState();
114
  const existingOutput = state.panelData?.output?.content || '';
 
124
  }
125
  },
126
  onConnectionChange: (connected: boolean) => {
127
+ if (isActiveRef.current) setConnected(connected);
128
  },
129
  onSessionDead: (deadSessionId: string) => {
130
  logger.warn(`Session ${deadSessionId} dead, removing`);
 
132
  },
133
  onApprovalRequired: (tools) => {
134
  if (!tools.length) return;
135
+
136
+ // Always mark the session as needing attention
137
+ setNeedsAttention(sessionId, true);
138
+
139
+ // Only update global UI if this is the active session
140
+ if (!isActiveRef.current) return;
141
+
142
  setActivityStatus({ type: 'waiting-approval' });
143
  const firstTool = tools[0];
144
  const args = firstTool.arguments as Record<string, string | undefined>;
 
167
  setLeftSidebarOpen(false);
168
  },
169
  onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
170
+ if (!isActiveRef.current) return;
171
  if (toolName === 'hf_jobs' && args.operation && args.script) {
172
  setPanel(
173
  { title: 'Script', script: { content: String(args.script), language: 'python' }, parameters: args },
 
186
  }
187
  },
188
  onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
189
+ if (!isActiveRef.current) return;
190
  if (toolName === 'hf_jobs' && output) {
191
  setPanelOutput({ content: output, language: 'markdown' });
192
  if (!success) useAgentStore.getState().setPanelView('output');
193
  }
194
  },
195
  onStreaming: () => {
196
+ if (isActiveRef.current) setActivityStatus({ type: 'streaming' });
197
  },
198
  onToolRunning: (toolName: string) => {
199
+ if (isActiveRef.current) setActivityStatus({ type: 'tool', toolName });
200
  },
201
  }),
202
+ // sessionId is the only real dependency — Zustand setters are stable
203
  // eslint-disable-next-line react-hooks/exhaustive-deps
204
  [sessionId],
205
  );
206
 
207
+ // -- Create transport (one per session, stable for lifetime) ------------
208
  const transportRef = useRef<WebSocketChatTransport | null>(null);
209
  if (!transportRef.current) {
210
  transportRef.current = new WebSocketChatTransport({ sideChannel });
211
  }
212
 
213
+ // Keep side-channel callbacks in sync (they capture isActiveRef)
214
  useEffect(() => {
215
  transportRef.current?.updateSideChannel(sideChannel);
216
  }, [sideChannel]);
217
 
218
+ // Connect WebSocket on mount, disconnect on unmount
219
  useEffect(() => {
220
  transportRef.current?.connectToSession(sessionId);
221
  return () => {
 
223
  };
224
  }, [sessionId]);
225
 
226
+ // Destroy transport on unmount
227
+ useEffect(() => {
228
+ return () => {
229
+ transportRef.current?.destroy();
230
+ transportRef.current = null;
231
+ };
232
+ }, []);
233
+
234
+ // -- Restore persisted messages for this session ------------------------
235
  const initialMessages = useMemo(
236
+ () => loadMessages(sessionId),
237
  [sessionId],
238
  );
239
 
240
+ // -- Ref for chat actions (used by sideChannel callbacks) ---------------
241
  const chatActionsRef = useRef<{
242
  setMessages: ((msgs: UIMessage[]) => void) | null;
243
  messages: UIMessage[];
244
  }>({ setMessages: null, messages: [] });
245
 
246
+ // -- useChat from Vercel AI SDK -----------------------------------------
247
  const chat = useChat({
248
+ id: sessionId,
249
  messages: initialMessages,
250
  transport: transportRef.current!,
251
  experimental_throttle: 80,
252
  onError: (error) => {
253
  logger.error('useChat error:', error);
254
+ if (isActiveRef.current) {
255
+ setError(error.message);
256
+ setProcessing(false);
257
+ }
258
  },
259
  });
260
 
 
262
  chatActionsRef.current.setMessages = chat.setMessages;
263
  chatActionsRef.current.messages = chat.messages;
264
 
265
+ // -- Hydrate from backend on mount (page refresh recovery) --------------
266
  useEffect(() => {
 
267
  let cancelled = false;
268
+ (async () => {
269
+ try {
270
+ // Fetch messages and session info (for pending approval) in parallel
271
+ const [msgsRes, infoRes] = await Promise.all([
272
+ apiFetch(`/api/session/${sessionId}/messages`),
273
+ apiFetch(`/api/session/${sessionId}`),
274
+ ]);
275
+
276
+ if (cancelled) return;
277
+
278
+ // Extract pending approval tool IDs from session info
279
+ let pendingIds: Set<string> | undefined;
280
+ if (infoRes.ok) {
281
+ const info = await infoRes.json();
282
+ if (info.pending_approval && Array.isArray(info.pending_approval)) {
283
+ pendingIds = new Set(
284
+ info.pending_approval.map((t: { tool_call_id: string }) => t.tool_call_id)
285
+ );
286
+ if (pendingIds.size > 0) {
287
+ setNeedsAttention(sessionId, true);
288
+ }
289
+ }
290
  }
291
+
292
+ if (msgsRes.ok) {
293
+ const data = await msgsRes.json();
294
+ if (cancelled || !Array.isArray(data) || data.length === 0) return;
295
+ const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
296
+ if (uiMsgs.length > 0) {
297
+ chat.setMessages(uiMsgs);
298
+ saveMessages(sessionId, uiMsgs);
299
+ }
300
+ }
301
+ } catch {
302
+ /* backend unreachable -- localStorage fallback is fine */
303
+ }
304
+ })();
305
  return () => { cancelled = true; };
306
  }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
307
 
308
+ // -- Persist messages ---------------------------------------------------
 
 
 
 
 
309
  const prevLenRef = useRef(initialMessages.length);
310
  useEffect(() => {
311
+ if (chat.messages.length === 0) return;
312
  if (chat.messages.length !== prevLenRef.current) {
313
  prevLenRef.current = chat.messages.length;
314
  saveMessages(sessionId, chat.messages);
315
  }
316
  }, [sessionId, chat.messages]);
317
 
318
+ // -- Undo last turn -----------------------------------------------------
319
  const undoLastTurn = useCallback(async () => {
 
320
  try {
321
  const res = await apiFetch(`/api/undo/${sessionId}`, { method: 'POST' });
322
  if (!res.ok) {
323
  logger.error('Undo API returned', res.status);
 
324
  }
325
  } catch (e) {
326
  logger.error('Undo failed:', e);
327
  }
 
 
328
  }, [sessionId]);
329
 
330
+ // -- Approve tools via transport ----------------------------------------
331
  const approveTools = useCallback(
332
  async (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => {
333
+ if (!transportRef.current) return false;
334
  const ok = await transportRef.current.approveTools(sessionId, approvals);
335
  if (ok) {
336
+ // Clear needsAttention since user has responded
337
+ setNeedsAttention(sessionId, false);
338
  const hasApproved = approvals.some(a => a.approved);
339
+ if (hasApproved && isActiveRef.current) setProcessing(true);
340
  }
341
  return ok;
342
  },
343
+ [sessionId, setProcessing, setNeedsAttention],
344
  );
345
 
 
 
 
 
 
 
346
  return {
347
  messages: chat.messages,
348
  sendMessage: chat.sendMessage,
 
350
  status: chat.status,
351
  undoLastTurn,
352
  approveTools,
 
353
  transport: transportRef.current,
354
  };
355
  }
frontend/src/lib/convert-llm-messages.ts CHANGED
@@ -21,7 +21,15 @@ function nextId(): string {
21
  return `msg-${Date.now()}-${++idCounter}`;
22
  }
23
 
24
- export function llmMessagesToUIMessages(messages: LLMMessage[]): UIMessage[] {
 
 
 
 
 
 
 
 
25
  // Build a map of tool_call_id -> tool result for pairing
26
  const toolResults = new Map<string, { output: string; isError: boolean }>();
27
  for (const msg of messages) {
@@ -72,6 +80,15 @@ export function llmMessagesToUIMessages(messages: LLMMessage[]): UIMessage[] {
72
  input,
73
  output: result.output,
74
  });
 
 
 
 
 
 
 
 
 
75
  } else {
76
  parts.push({
77
  type: 'dynamic-tool',
 
21
  return `msg-${Date.now()}-${++idCounter}`;
22
  }
23
 
24
+ /**
25
+ * @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
26
+ * When provided, matching tool calls without results will get state
27
+ * 'approval-requested' instead of 'input-available'.
28
+ */
29
+ export function llmMessagesToUIMessages(
30
+ messages: LLMMessage[],
31
+ pendingApprovalIds?: Set<string>,
32
+ ): UIMessage[] {
33
  // Build a map of tool_call_id -> tool result for pairing
34
  const toolResults = new Map<string, { output: string; isError: boolean }>();
35
  for (const msg of messages) {
 
80
  input,
81
  output: result.output,
82
  });
83
+ } else if (pendingApprovalIds?.has(tc.id)) {
84
+ parts.push({
85
+ type: 'dynamic-tool',
86
+ toolCallId: tc.id,
87
+ toolName: tc.function.name,
88
+ state: 'approval-requested',
89
+ input,
90
+ approval: { id: `approval-${tc.id}` },
91
+ });
92
  } else {
93
  parts.push({
94
  type: 'dynamic-tool',
frontend/src/lib/ws-chat-transport.ts CHANGED
@@ -2,8 +2,8 @@
2
  * Custom ChatTransport that bridges our WebSocket-based backend protocol
3
  * to the Vercel AI SDK's UIMessageChunk streaming interface.
4
  *
5
- * The backend stays unchanged this adapter translates WebSocket events
6
- * into the chunk types that useChat() expects.
7
  */
8
  import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from 'ai';
9
  import { apiFetch, getWebSocketUrl } from '@/utils/api';
@@ -66,9 +66,6 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
66
  private currentSessionId: string | null = null;
67
  private sideChannel: SideChannelCallbacks;
68
 
69
- /** Background WebSockets kept alive so the backend agent keeps running. */
70
- private backgroundSockets: Map<string, WebSocket> = new Map();
71
-
72
  private streamController: ReadableStreamDefaultController<UIMessageChunk> | null = null;
73
  private streamGeneration = 0;
74
  private abortedGeneration = 0;
@@ -81,7 +78,6 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
81
  private retries = 0;
82
  private pingInterval: ReturnType<typeof setInterval> | null = null;
83
  private boundVisibilityHandler: (() => void) | null = null;
84
- private wasHidden = false;
85
 
86
  constructor({ sideChannel }: WebSocketChatTransportOptions) {
87
  this.sideChannel = sideChannel;
@@ -91,7 +87,6 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
91
  private setupVisibilityHandler(): void {
92
  this.boundVisibilityHandler = () => {
93
  if (document.visibilityState === 'hidden') {
94
- this.wasHidden = true;
95
  return;
96
  }
97
 
@@ -102,30 +97,25 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
102
  this.retries = 0;
103
  this.reconnectDelay = WS_RECONNECT_DELAY;
104
  this.createWebSocket(this.currentSessionId);
105
-
106
- if (this.wasHidden) {
107
- const store = useAgentStore.getState();
108
- if (store.isProcessing) {
109
- logger.log('Tab visible after WS drop: resetting stale processing state');
110
- store.setProcessing(false);
111
- this.closeActiveStream();
112
- }
113
- }
114
  } else if (wsState === WebSocket.OPEN) {
115
  this.ws!.send(JSON.stringify({ type: 'ping' }));
116
  }
117
- this.wasHidden = false;
118
  }
119
  };
120
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
121
  }
122
 
123
- /** Update side-channel callbacks (e.g. when sessionId changes). */
124
  updateSideChannel(sideChannel: SideChannelCallbacks): void {
125
  this.sideChannel = sideChannel;
126
  }
127
 
128
- // ── Public API ──────────────────────────────────────────────────────
 
 
 
 
 
129
 
130
  /** Connect (or reconnect) to a session's WebSocket. */
131
  connectToSession(sessionId: string | null): void {
@@ -134,55 +124,15 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
134
  this.connectTimeout = null;
135
  }
136
 
137
- // Move current WS to background instead of closing it
138
- if (this.ws && this.currentSessionId && this.currentSessionId !== sessionId) {
139
- const oldId = this.currentSessionId;
140
- const oldWs = this.ws;
141
- this.backgroundSockets.set(oldId, oldWs);
142
- // Replace handler: background sockets only need ping/pong
143
- oldWs.onmessage = (evt) => {
144
- try {
145
- const raw = JSON.parse(evt.data);
146
- if (raw.type === 'pong') return;
147
- // Silently discard — backend keeps running, we'll load results from localStorage
148
- } catch { /* ignore */ }
149
- };
150
- oldWs.onclose = () => {
151
- this.backgroundSockets.delete(oldId);
152
- };
153
- this.ws = null;
154
- this.stopPing();
155
- } else {
156
- this.disconnectWebSocket();
157
  }
158
 
 
159
  this.currentSessionId = sessionId;
160
- if (sessionId) {
161
- // Promote background socket if one exists for this session
162
- const bg = this.backgroundSockets.get(sessionId);
163
- if (bg && (bg.readyState === WebSocket.OPEN || bg.readyState === WebSocket.CONNECTING)) {
164
- this.backgroundSockets.delete(sessionId);
165
- this.ws = bg;
166
- // Restore full event handling
167
- bg.onmessage = (evt) => {
168
- try {
169
- const raw = JSON.parse(evt.data);
170
- if (raw.type === 'pong') return;
171
- this.handleEvent(raw as AgentEvent);
172
- } catch (e) {
173
- logger.error('WS parse error:', e);
174
- }
175
- };
176
- bg.onclose = (evt) => {
177
- logger.log('WS closed', evt.code, evt.reason);
178
- this.sideChannel.onConnectionChange(false);
179
- this.stopPing();
180
- };
181
- this.sideChannel.onConnectionChange(true);
182
- this.startPing();
183
- return;
184
- }
185
 
 
186
  this.retries = 0;
187
  this.reconnectDelay = WS_RECONNECT_DELAY;
188
  this.connectTimeout = setTimeout(() => {
@@ -221,14 +171,11 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
221
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
222
  this.boundVisibilityHandler = null;
223
  }
224
- // Close all background sockets
225
- for (const ws of this.backgroundSockets.values()) ws.close();
226
- this.backgroundSockets.clear();
227
  this.disconnectWebSocket();
228
  this.closeActiveStream();
229
  }
230
 
231
- // ── ChatTransport interface ─────────────────────────────────────────
232
 
233
  async sendMessages(
234
  options: {
@@ -322,7 +269,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
322
  );
323
  }
324
 
325
- // ── WebSocket lifecycle ─────────────────────────────────────────────
326
 
327
  private createWebSocket(sessionId: string): void {
328
  if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
@@ -409,7 +356,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
409
  }
410
  }
411
 
412
- // ── Stream helpers ──────────────────────────────────────────────────
413
 
414
  private closeActiveStream(): void {
415
  if (this.streamController) {
@@ -438,7 +385,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
438
  }
439
  }
440
 
441
- // ── Event UIMessageChunk mapping ──────────────────────────────────
442
 
443
  private static readonly STREAM_EVENTS = new Set([
444
  'assistant_chunk', 'assistant_stream_end', 'assistant_message',
@@ -454,7 +401,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
454
  }
455
 
456
  switch (event.event_type) {
457
- // ── Side-channel only events ────────────────────────────────
458
  case 'ready':
459
  this.sideChannel.onReady();
460
  break;
@@ -465,10 +412,6 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
465
  break;
466
 
467
  case 'interrupted':
468
- // Don't close the stream here — the abort handler already did, and
469
- // a new stream for the next user message may already exist.
470
- // Closing here would destroy the NEWER stream, causing the next
471
- // response to be silently dropped.
472
  this.sideChannel.onProcessingDone();
473
  break;
474
 
@@ -498,7 +441,7 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
498
  );
499
  break;
500
 
501
- // ── Chat stream events ──────────────────────────────────────
502
  case 'processing':
503
  if (this.awaitingProcessing) {
504
  if (this.streamGeneration <= this.abortedGeneration) {
 
2
  * Custom ChatTransport that bridges our WebSocket-based backend protocol
3
  * to the Vercel AI SDK's UIMessageChunk streaming interface.
4
  *
5
+ * Each instance manages a single session's WebSocket connection.
6
+ * In the per-session architecture, every session owns its own transport.
7
  */
8
  import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from 'ai';
9
  import { apiFetch, getWebSocketUrl } from '@/utils/api';
 
66
  private currentSessionId: string | null = null;
67
  private sideChannel: SideChannelCallbacks;
68
 
 
 
 
69
  private streamController: ReadableStreamDefaultController<UIMessageChunk> | null = null;
70
  private streamGeneration = 0;
71
  private abortedGeneration = 0;
 
78
  private retries = 0;
79
  private pingInterval: ReturnType<typeof setInterval> | null = null;
80
  private boundVisibilityHandler: (() => void) | null = null;
 
81
 
82
  constructor({ sideChannel }: WebSocketChatTransportOptions) {
83
  this.sideChannel = sideChannel;
 
87
  private setupVisibilityHandler(): void {
88
  this.boundVisibilityHandler = () => {
89
  if (document.visibilityState === 'hidden') {
 
90
  return;
91
  }
92
 
 
97
  this.retries = 0;
98
  this.reconnectDelay = WS_RECONNECT_DELAY;
99
  this.createWebSocket(this.currentSessionId);
 
 
 
 
 
 
 
 
 
100
  } else if (wsState === WebSocket.OPEN) {
101
  this.ws!.send(JSON.stringify({ type: 'ping' }));
102
  }
 
103
  }
104
  };
105
  document.addEventListener('visibilitychange', this.boundVisibilityHandler);
106
  }
107
 
108
+ /** Update side-channel callbacks (e.g. when isActive changes). */
109
  updateSideChannel(sideChannel: SideChannelCallbacks): void {
110
  this.sideChannel = sideChannel;
111
  }
112
 
113
+ /** Check if the WebSocket is currently connected. */
114
+ isWebSocketConnected(): boolean {
115
+ return this.ws?.readyState === WebSocket.OPEN;
116
+ }
117
+
118
+ // -- Public API ----------------------------------------------------------
119
 
120
  /** Connect (or reconnect) to a session's WebSocket. */
121
  connectToSession(sessionId: string | null): void {
 
124
  this.connectTimeout = null;
125
  }
126
 
127
+ // Same session no-op
128
+ if (sessionId === this.currentSessionId && this.ws?.readyState === WebSocket.OPEN) {
129
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
 
132
+ this.disconnectWebSocket();
133
  this.currentSessionId = sessionId;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ if (sessionId) {
136
  this.retries = 0;
137
  this.reconnectDelay = WS_RECONNECT_DELAY;
138
  this.connectTimeout = setTimeout(() => {
 
171
  document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
172
  this.boundVisibilityHandler = null;
173
  }
 
 
 
174
  this.disconnectWebSocket();
175
  this.closeActiveStream();
176
  }
177
 
178
+ // -- ChatTransport interface ---------------------------------------------
179
 
180
  async sendMessages(
181
  options: {
 
269
  );
270
  }
271
 
272
+ // -- WebSocket lifecycle -------------------------------------------------
273
 
274
  private createWebSocket(sessionId: string): void {
275
  if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
 
356
  }
357
  }
358
 
359
+ // -- Stream helpers ------------------------------------------------------
360
 
361
  private closeActiveStream(): void {
362
  if (this.streamController) {
 
385
  }
386
  }
387
 
388
+ // -- Event -> UIMessageChunk mapping ------------------------------------
389
 
390
  private static readonly STREAM_EVENTS = new Set([
391
  'assistant_chunk', 'assistant_stream_end', 'assistant_message',
 
401
  }
402
 
403
  switch (event.event_type) {
404
+ // -- Side-channel only events ----------------------------------------
405
  case 'ready':
406
  this.sideChannel.onReady();
407
  break;
 
412
  break;
413
 
414
  case 'interrupted':
 
 
 
 
415
  this.sideChannel.onProcessingDone();
416
  break;
417
 
 
441
  );
442
  break;
443
 
444
+ // -- Chat stream events ----------------------------------------------
445
  case 'processing':
446
  if (this.awaitingProcessing) {
447
  if (this.streamGeneration <= this.abortedGeneration) {
frontend/src/store/sessionStore.ts CHANGED
@@ -13,6 +13,7 @@ interface SessionStore {
13
  switchSession: (id: string) => void;
14
  setSessionActive: (id: string, isActive: boolean) => void;
15
  updateSessionTitle: (id: string, title: string) => void;
 
16
  }
17
 
18
  export const useSessionStore = create<SessionStore>()(
@@ -27,6 +28,7 @@ export const useSessionStore = create<SessionStore>()(
27
  title: `Chat ${get().sessions.length + 1}`,
28
  createdAt: new Date().toISOString(),
29
  isActive: true,
 
30
  };
31
  set((state) => ({
32
  sessions: [...state.sessions, newSession],
@@ -50,7 +52,12 @@ export const useSessionStore = create<SessionStore>()(
50
  },
51
 
52
  switchSession: (id: string) => {
53
- set({ activeSessionId: id });
 
 
 
 
 
54
  },
55
 
56
  setSessionActive: (id: string, isActive: boolean) => {
@@ -68,6 +75,14 @@ export const useSessionStore = create<SessionStore>()(
68
  ),
69
  }));
70
  },
 
 
 
 
 
 
 
 
71
  }),
72
  {
73
  name: 'hf-agent-sessions',
 
13
  switchSession: (id: string) => void;
14
  setSessionActive: (id: string, isActive: boolean) => void;
15
  updateSessionTitle: (id: string, title: string) => void;
16
+ setNeedsAttention: (id: string, needs: boolean) => void;
17
  }
18
 
19
  export const useSessionStore = create<SessionStore>()(
 
28
  title: `Chat ${get().sessions.length + 1}`,
29
  createdAt: new Date().toISOString(),
30
  isActive: true,
31
+ needsAttention: false,
32
  };
33
  set((state) => ({
34
  sessions: [...state.sessions, newSession],
 
52
  },
53
 
54
  switchSession: (id: string) => {
55
+ set((state) => ({
56
+ activeSessionId: id,
57
+ sessions: state.sessions.map((s) =>
58
+ s.id === id ? { ...s, needsAttention: false } : s
59
+ ),
60
+ }));
61
  },
62
 
63
  setSessionActive: (id: string, isActive: boolean) => {
 
75
  ),
76
  }));
77
  },
78
+
79
+ setNeedsAttention: (id: string, needs: boolean) => {
80
+ set((state) => ({
81
+ sessions: state.sessions.map((s) =>
82
+ s.id === id ? { ...s, needsAttention: needs } : s
83
+ ),
84
+ }));
85
+ },
86
  }),
87
  {
88
  name: 'hf-agent-sessions',
frontend/src/types/agent.ts CHANGED
@@ -15,6 +15,7 @@ export interface SessionMeta {
15
  title: string;
16
  createdAt: string;
17
  isActive: boolean;
 
18
  }
19
 
20
  export interface ToolApproval {
 
15
  title: string;
16
  createdAt: string;
17
  isActive: boolean;
18
+ needsAttention: boolean;
19
  }
20
 
21
  export interface ToolApproval {