akseljoonas HF Staff Claude Opus 4.6 commited on
Commit
f56fa2e
Β·
1 Parent(s): f4ebc8f

fix: per-session state management for smooth task switching

Browse files

Background sessions now maintain their own state snapshots in agentStore
so streaming, tool calls, and panel updates are not lost when switching
tasks or backgrounding the browser tab.

- Add sessionStates map + updateSession/switchActiveSession/syncSnapshot
- Side-channel callbacks always update per-session state (no isActiveRef guard)
- Global setters (setPanel, setPanelView, etc.) sync to active snapshot
- Re-sync state on browser tab visibility change
- Fix direct state mutation in switchActiveSession

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

frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -123,6 +123,7 @@ export default function AppLayout() {
123
 
124
  const handleSessionDead = useCallback(
125
  (deadSessionId: string) => {
 
126
  deleteSession(deadSessionId);
127
  },
128
  [deleteSession],
 
123
 
124
  const handleSessionDead = useCallback(
125
  (deadSessionId: string) => {
126
+ useAgentStore.getState().clearSessionState(deadSessionId);
127
  deleteSession(deadSessionId);
128
  },
129
  [deleteSession],
frontend/src/components/SessionChat.tsx CHANGED
@@ -5,11 +5,10 @@
5
  * runs β€” processing events β€” but only the active session renders visible
6
  * 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';
@@ -22,7 +21,7 @@ interface SessionChatProps {
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, status, undoLastTurn, approveTools } = useAgentChat({
@@ -33,85 +32,28 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
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
- // When it becomes inactive, clear global state so the next session starts clean.
39
- const prevActiveRef = useRef(isActive);
40
  useEffect(() => {
41
- if (isActive && !prevActiveRef.current) {
42
- // ── Becoming active: restore this session's state ──
43
- const store = useAgentStore.getState();
44
-
45
- // SSE transport has no persistent connection β€” always connected
46
- store.setConnected(true);
47
-
48
- // Check if this session has pending approvals in its messages
49
- const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
50
- const hasPendingApproval = lastAssistant?.parts.some(
51
- (p) => p.type === 'dynamic-tool' && p.state === 'approval-requested'
52
- ) ?? false;
53
- const hasApprovedRunning = lastAssistant?.parts.some(
54
- (p) => p.type === 'dynamic-tool' && p.state === 'approval-responded'
55
- ) ?? false;
56
-
57
- if (hasPendingApproval) {
58
- store.setActivityStatus({ type: 'waiting-approval' });
59
- store.setProcessing(false);
60
 
61
- // Restore panel for the first pending tool
62
- const pendingTool = lastAssistant!.parts.find(
63
- (p) => p.type === 'dynamic-tool' && p.state === 'approval-requested'
64
- );
65
- if (pendingTool && pendingTool.type === 'dynamic-tool') {
66
- const args = pendingTool.input as Record<string, string | undefined>;
67
- if (pendingTool.toolName === 'hf_jobs' && args?.script) {
68
- store.setPanel(
69
- { title: 'Script', script: { content: args.script, language: 'python' }, parameters: pendingTool.input as Record<string, unknown> },
70
- 'script',
71
- true,
72
- );
73
- } else if (pendingTool.toolName === 'hf_repo_files' && args?.content) {
74
- const filename = args.path || 'file';
75
- store.setPanel({
76
- title: filename.split('/').pop() || 'Content',
77
- script: { content: args.content, language: filename.endsWith('.py') ? 'python' : 'text' },
78
- parameters: pendingTool.input as Record<string, unknown>,
79
- });
80
- } else {
81
- store.setPanel({
82
- title: pendingTool.toolName,
83
- output: { content: JSON.stringify(pendingTool.input, null, 2), language: 'json' },
84
- }, 'output');
85
- }
86
- useLayoutStore.getState().setRightPanelOpen(true);
87
- }
88
- } else if (hasApprovedRunning) {
89
- // Tools were approved but still executing β€” show processing state
90
- store.setActivityStatus({ type: 'tool', toolName: 'running' });
91
- store.setProcessing(true);
92
- } else {
93
- // Check if any tools are still running (non-approval tools like bash, read, etc.)
94
- const runningTool = lastAssistant?.parts.find(
95
- (p) => p.type === 'dynamic-tool' && (p.state === 'input-available' || p.state === 'input-streaming')
96
- );
97
- if (runningTool && runningTool.type === 'dynamic-tool') {
98
- const desc = (runningTool.input as Record<string, unknown>)?.description as string | undefined;
99
- store.setActivityStatus({ type: 'tool', toolName: runningTool.toolName, description: desc });
100
- store.setProcessing(true);
101
- } else {
102
- store.setActivityStatus({ type: 'idle' });
103
- store.setProcessing(false);
104
- }
105
  }
106
- } else if (!isActive && prevActiveRef.current) {
107
- // ── Becoming inactive: clear global state so the next session starts clean ──
108
- const store = useAgentStore.getState();
109
- store.setActivityStatus({ type: 'idle' });
110
- store.setProcessing(false);
111
- store.clearPanel();
112
- }
113
- prevActiveRef.current = isActive;
114
- }, [isActive, messages]);
115
 
116
  // SDK status is the ground truth β€” if it's streaming/submitted, agent is busy
117
  const sdkBusy = status === 'streaming' || status === 'submitted';
@@ -121,7 +63,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
121
  async (text: string) => {
122
  if (!text.trim() || busy) return;
123
 
124
- setProcessing(true);
125
  sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
126
 
127
  // Auto-title the session from the first user message
@@ -141,7 +83,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
141
  });
142
  }
143
  },
144
- [sessionId, sendMessage, messages, updateSessionTitle, busy, setProcessing],
145
  );
146
 
147
  // Don't render UI for background sessions β€” hooks still run
 
5
  * runs β€” processing events β€” but only the active session renders visible
6
  * UI (MessageList + ChatInput).
7
  */
8
+ import { useCallback, useEffect } from 'react';
9
  import { useAgentChat } from '@/hooks/useAgentChat';
10
  import { useAgentStore } from '@/store/agentStore';
11
  import { useSessionStore } from '@/store/sessionStore';
 
12
  import MessageList from '@/components/Chat/MessageList';
13
  import ChatInput from '@/components/Chat/ChatInput';
14
  import { apiFetch } from '@/utils/api';
 
21
  }
22
 
23
  export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) {
24
+ const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
25
  const { updateSessionTitle } = useSessionStore();
26
 
27
  const { messages, sendMessage, stop, status, undoLastTurn, approveTools } = useAgentChat({
 
32
  onSessionDead,
33
  });
34
 
35
+ // When this session becomes active, restore its per-session state to the
36
+ // global flat fields. The per-session state map is kept up-to-date by
37
+ // side-channel callbacks even while the session is in the background.
 
38
  useEffect(() => {
39
+ if (isActive) {
40
+ useAgentStore.getState().switchActiveSession(sessionId);
41
+ useAgentStore.getState().setConnected(true);
42
+ }
43
+ }, [isActive, sessionId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
+ // Re-sync state when the browser tab regains focus (Chrome throttles
46
+ // timers in background tabs which can stall the AI SDK's update flushing).
47
+ useEffect(() => {
48
+ if (!isActive) return;
49
+ const onVisible = () => {
50
+ if (document.visibilityState === 'visible') {
51
+ useAgentStore.getState().switchActiveSession(sessionId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
+ };
54
+ document.addEventListener('visibilitychange', onVisible);
55
+ return () => document.removeEventListener('visibilitychange', onVisible);
56
+ }, [isActive, sessionId]);
 
 
 
 
 
57
 
58
  // SDK status is the ground truth β€” if it's streaming/submitted, agent is busy
59
  const sdkBusy = status === 'streaming' || status === 'submitted';
 
63
  async (text: string) => {
64
  if (!text.trim() || busy) return;
65
 
66
+ updateSession(sessionId, { isProcessing: true });
67
  sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
68
 
69
  // Auto-title the session from the first user message
 
83
  });
84
  }
85
  },
86
+ [sessionId, sendMessage, messages, updateSessionTitle, busy, updateSession],
87
  );
88
 
89
  // Don't render UI for background sessions β€” hooks still run
frontend/src/components/SessionSidebar/SessionSidebar.tsx CHANGED
@@ -54,6 +54,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
54
  const handleDelete = useCallback(
55
  async (sessionId: string, e: React.MouseEvent) => {
56
  e.stopPropagation();
 
57
  try {
58
  await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
59
  deleteSession(sessionId);
@@ -67,11 +68,11 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
67
  const handleSelect = useCallback(
68
  (sessionId: string) => {
69
  switchSession(sessionId);
70
- setPlan([]);
71
- clearPanel();
72
  onClose?.();
73
  },
74
- [switchSession, setPlan, clearPanel, onClose],
75
  );
76
 
77
  const formatTime = (d: string) =>
 
54
  const handleDelete = useCallback(
55
  async (sessionId: string, e: React.MouseEvent) => {
56
  e.stopPropagation();
57
+ useAgentStore.getState().clearSessionState(sessionId);
58
  try {
59
  await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
60
  deleteSession(sessionId);
 
68
  const handleSelect = useCallback(
69
  (sessionId: string) => {
70
  switchSession(sessionId);
71
+ // Per-session state (plan, panel, activity) is restored automatically
72
+ // by SessionChat's useEffect when isActive flips to true.
73
  onClose?.();
74
  },
75
+ [switchSession, onClose],
76
  );
77
 
78
  const formatTime = (d: string) =>
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -3,9 +3,9 @@
3
  * ChatTransport.
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';
@@ -34,87 +34,87 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
34
  const isActiveRef = useRef(isActive);
35
  isActiveRef.current = isActive;
36
 
37
- const {
38
- setProcessing,
39
- setConnected,
40
- setActivityStatus,
41
- setError,
42
- setPanel,
43
- setPanelOutput,
44
- } = useAgentStore();
45
 
46
- const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
47
- const { setSessionActive, setNeedsAttention } = useSessionStore();
48
 
49
  // -- Build side-channel callbacks (stable ref) --------------------------
50
  const sideChannel = useMemo<SideChannelCallbacks>(
51
  () => ({
52
  onReady: () => {
 
53
  if (isActiveRef.current) {
54
- setConnected(true);
55
- setProcessing(false);
56
  }
57
- setSessionActive(sessionId, true);
58
  callbacksRef.current.onReady?.();
59
  },
60
  onShutdown: () => {
 
61
  if (isActiveRef.current) {
62
- setConnected(false);
63
- setProcessing(false);
64
  }
65
  },
66
  onError: (error: string) => {
 
67
  if (isActiveRef.current) {
68
- setError(error);
69
- setProcessing(false);
70
  }
71
  callbacksRef.current.onError?.(error);
72
  },
73
  onProcessing: () => {
74
- if (isActiveRef.current) {
75
- setProcessing(true);
76
- setActivityStatus({ type: 'thinking' });
77
- }
78
  },
79
  onProcessingDone: () => {
80
- if (isActiveRef.current) {
81
- setProcessing(false);
82
- }
83
  },
84
  onUndoComplete: () => {
85
- // Undo is handled client-side in undoLastTurn(). With SSE, undo_complete
86
- // events are discarded (no subscriber listening between turns).
87
- if (isActiveRef.current) setProcessing(false);
88
  },
89
  onCompacted: (oldTokens: number, newTokens: number) => {
90
  logger.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
91
  },
92
  onPlanUpdate: (plan) => {
93
- if (!isActiveRef.current) return;
94
- useAgentStore.getState().setPlan(plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>);
95
- if (!useLayoutStore.getState().isRightPanelOpen) {
96
- setRightPanelOpen(true);
97
  }
98
  },
99
  onToolLog: (tool: string, log: string) => {
100
- if (!isActiveRef.current) return;
101
- if (tool === 'hf_jobs' || tool === 'sandbox') {
102
- const state = useAgentStore.getState();
103
- const existingOutput = state.panelData?.output?.content || '';
104
- const header = tool === 'sandbox' ? '--- Sandbox creation ---' : '--- Job execution started ---';
105
- const newContent = existingOutput
106
- ? existingOutput + '\n' + log
107
- : header + '\n' + log;
108
 
109
- setPanelOutput({ content: newContent, language: 'text' });
 
110
 
111
- if (!useLayoutStore.getState().isRightPanelOpen) {
112
- setRightPanelOpen(true);
113
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
  },
116
  onConnectionChange: (connected: boolean) => {
117
- if (isActiveRef.current) setConnected(connected);
118
  },
119
  onSessionDead: (deadSessionId: string) => {
120
  logger.warn(`Session ${deadSessionId} dead, removing`);
@@ -123,66 +123,105 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
123
  onApprovalRequired: (tools) => {
124
  if (!tools.length) return;
125
  setNeedsAttention(sessionId, true);
126
- if (!isActiveRef.current) return;
127
 
128
- setActivityStatus({ type: 'waiting-approval' });
 
 
129
  const firstTool = tools[0];
130
  const args = firstTool.arguments as Record<string, string | undefined>;
131
 
 
132
  if (firstTool.tool === 'hf_jobs' && args.script) {
133
- setPanel(
134
- { title: 'Script', script: { content: args.script, language: 'python' }, parameters: firstTool.arguments as Record<string, unknown> },
135
- 'script',
136
- true,
137
- );
 
 
 
 
138
  } else if (firstTool.tool === 'hf_repo_files' && args.content) {
139
  const filename = args.path || 'file';
140
- setPanel({
141
- title: filename.split('/').pop() || 'Content',
142
- script: { content: args.content, language: filename.endsWith('.py') ? 'python' : 'text' },
143
- parameters: firstTool.arguments as Record<string, unknown>,
144
- });
 
 
145
  } else {
146
- setPanel({
147
- title: firstTool.tool,
148
- output: { content: JSON.stringify(firstTool.arguments, null, 2), language: 'json' },
149
- }, 'output');
 
 
 
150
  }
 
151
 
152
- setRightPanelOpen(true);
153
- setLeftSidebarOpen(false);
 
 
154
  },
155
  onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
156
- if (!isActiveRef.current) return;
157
  if (toolName === 'hf_jobs' && args.operation && args.script) {
158
- setPanel(
159
- { title: 'Script', script: { content: String(args.script), language: 'python' }, parameters: args },
160
- 'script',
161
- );
162
- setRightPanelOpen(true);
163
- setLeftSidebarOpen(false);
 
 
 
 
 
 
164
  } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
165
- setPanel({
166
- title: `File Upload: ${String(args.path || 'unnamed')}`,
167
- script: { content: String(args.content), language: String(args.path || '').endsWith('.py') ? 'python' : 'text' },
168
- parameters: args,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  });
170
- setRightPanelOpen(true);
171
- setLeftSidebarOpen(false);
172
  }
173
  },
174
  onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
175
- if (!isActiveRef.current) return;
176
  if (toolName === 'hf_jobs' && output) {
177
- setPanelOutput({ content: output, language: 'markdown' });
178
- if (!success) useAgentStore.getState().setPanelView('output');
 
 
 
 
 
 
 
 
179
  }
180
  },
181
  onStreaming: () => {
182
- if (isActiveRef.current) setActivityStatus({ type: 'streaming' });
183
  },
184
  onToolRunning: (toolName: string, description?: string) => {
185
- if (isActiveRef.current) setActivityStatus({ type: 'tool', toolName, description });
186
  },
187
  }),
188
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -232,9 +271,9 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
232
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
233
  onError: (error) => {
234
  logger.error('useChat error:', error);
 
235
  if (isActiveRef.current) {
236
- setError(error.message);
237
- setProcessing(false);
238
  }
239
  },
240
  });
@@ -316,11 +355,11 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
316
  setMsgs(updated);
317
  saveMessages(sessionId, updated);
318
  }
319
- if (isActiveRef.current) setProcessing(false);
320
  } catch (e) {
321
  logger.error('Undo failed:', e);
322
  }
323
- }, [sessionId, setProcessing]);
324
 
325
  // -- Approve tools ------------------------------------------------------
326
  const approveTools = useCallback(
@@ -343,10 +382,12 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
343
 
344
  setNeedsAttention(sessionId, false);
345
  const hasApproved = approvals.some(a => a.approved);
346
- if (hasApproved && isActiveRef.current) setProcessing(true);
 
 
347
  return true;
348
  },
349
- [sessionId, chat, setProcessing, setNeedsAttention],
350
  );
351
 
352
  // -- Stop (abort SSE stream + interrupt backend agent loop) ---------------
 
3
  * ChatTransport.
4
  *
5
  * In the per-session architecture, each session mounts its own instance
6
+ * of this hook. Side-channel callbacks always update the session's own
7
+ * state via `updateSession()`. If the session is currently active, the
8
+ * store automatically mirrors updates to the flat global fields.
9
  */
10
  import { useCallback, useEffect, useMemo, useRef } from 'react';
11
  import { useChat } from '@ai-sdk/react';
 
34
  const isActiveRef = useRef(isActive);
35
  isActiveRef.current = isActive;
36
 
37
+ const { setNeedsAttention } = useSessionStore();
 
 
 
 
 
 
 
38
 
39
+ // Helper: update this session's state (mirrors to globals if active)
40
+ const updateSession = useAgentStore.getState().updateSession;
41
 
42
  // -- Build side-channel callbacks (stable ref) --------------------------
43
  const sideChannel = useMemo<SideChannelCallbacks>(
44
  () => ({
45
  onReady: () => {
46
+ updateSession(sessionId, { isProcessing: false });
47
  if (isActiveRef.current) {
48
+ useAgentStore.getState().setConnected(true);
 
49
  }
50
+ useSessionStore.getState().setSessionActive(sessionId, true);
51
  callbacksRef.current.onReady?.();
52
  },
53
  onShutdown: () => {
54
+ updateSession(sessionId, { isProcessing: false });
55
  if (isActiveRef.current) {
56
+ useAgentStore.getState().setConnected(false);
 
57
  }
58
  },
59
  onError: (error: string) => {
60
+ updateSession(sessionId, { isProcessing: false });
61
  if (isActiveRef.current) {
62
+ useAgentStore.getState().setError(error);
 
63
  }
64
  callbacksRef.current.onError?.(error);
65
  },
66
  onProcessing: () => {
67
+ updateSession(sessionId, {
68
+ isProcessing: true,
69
+ activityStatus: { type: 'thinking' },
70
+ });
71
  },
72
  onProcessingDone: () => {
73
+ updateSession(sessionId, { isProcessing: false });
 
 
74
  },
75
  onUndoComplete: () => {
76
+ updateSession(sessionId, { isProcessing: false });
 
 
77
  },
78
  onCompacted: (oldTokens: number, newTokens: number) => {
79
  logger.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
80
  },
81
  onPlanUpdate: (plan) => {
82
+ const typed = plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>;
83
+ updateSession(sessionId, { plan: typed });
84
+ if (isActiveRef.current && !useLayoutStore.getState().isRightPanelOpen) {
85
+ useLayoutStore.getState().setRightPanelOpen(true);
86
  }
87
  },
88
  onToolLog: (tool: string, log: string) => {
89
+ const STREAMABLE_TOOLS = new Set(['hf_jobs', 'sandbox', 'bash']);
90
+ if (!STREAMABLE_TOOLS.has(tool)) return;
 
 
 
 
 
 
91
 
92
+ const sessState = useAgentStore.getState().getSessionState(sessionId);
93
+ const existingOutput = sessState.panelData?.output?.content || '';
94
 
95
+ const newContent = existingOutput
96
+ ? existingOutput + '\n' + log
97
+ : log;
98
+
99
+ if (!sessState.panelData) {
100
+ const title = tool === 'bash' ? 'Sandbox' : tool === 'sandbox' ? 'Sandbox' : 'Job Output';
101
+ updateSession(sessionId, {
102
+ panelData: { title, output: { content: newContent, language: 'text' } },
103
+ panelView: 'output',
104
+ });
105
+ } else {
106
+ updateSession(sessionId, {
107
+ panelData: { ...sessState.panelData, output: { content: newContent, language: 'text' } },
108
+ panelView: 'output',
109
+ });
110
+ }
111
+
112
+ if (isActiveRef.current && !useLayoutStore.getState().isRightPanelOpen) {
113
+ useLayoutStore.getState().setRightPanelOpen(true);
114
  }
115
  },
116
  onConnectionChange: (connected: boolean) => {
117
+ if (isActiveRef.current) useAgentStore.getState().setConnected(connected);
118
  },
119
  onSessionDead: (deadSessionId: string) => {
120
  logger.warn(`Session ${deadSessionId} dead, removing`);
 
123
  onApprovalRequired: (tools) => {
124
  if (!tools.length) return;
125
  setNeedsAttention(sessionId, true);
 
126
 
127
+ updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
128
+
129
+ // Build panel data for this session's pending approval
130
  const firstTool = tools[0];
131
  const args = firstTool.arguments as Record<string, string | undefined>;
132
 
133
+ let panelUpdate: Partial<import('@/store/agentStore').PerSessionState> | undefined;
134
  if (firstTool.tool === 'hf_jobs' && args.script) {
135
+ panelUpdate = {
136
+ panelData: {
137
+ title: 'Script',
138
+ script: { content: args.script, language: 'python' },
139
+ parameters: firstTool.arguments as Record<string, unknown>,
140
+ },
141
+ panelView: 'script' as const,
142
+ panelEditable: true,
143
+ };
144
  } else if (firstTool.tool === 'hf_repo_files' && args.content) {
145
  const filename = args.path || 'file';
146
+ panelUpdate = {
147
+ panelData: {
148
+ title: filename.split('/').pop() || 'Content',
149
+ script: { content: args.content, language: filename.endsWith('.py') ? 'python' : 'text' },
150
+ parameters: firstTool.arguments as Record<string, unknown>,
151
+ },
152
+ };
153
  } else {
154
+ panelUpdate = {
155
+ panelData: {
156
+ title: firstTool.tool,
157
+ output: { content: JSON.stringify(firstTool.arguments, null, 2), language: 'json' },
158
+ },
159
+ panelView: 'output' as const,
160
+ };
161
  }
162
+ if (panelUpdate) updateSession(sessionId, panelUpdate);
163
 
164
+ if (isActiveRef.current) {
165
+ useLayoutStore.getState().setRightPanelOpen(true);
166
+ useLayoutStore.getState().setLeftSidebarOpen(false);
167
+ }
168
  },
169
  onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
 
170
  if (toolName === 'hf_jobs' && args.operation && args.script) {
171
+ updateSession(sessionId, {
172
+ panelData: {
173
+ title: 'Script',
174
+ script: { content: String(args.script), language: 'python' },
175
+ parameters: args,
176
+ },
177
+ panelView: 'script',
178
+ });
179
+ if (isActiveRef.current) {
180
+ useLayoutStore.getState().setRightPanelOpen(true);
181
+ useLayoutStore.getState().setLeftSidebarOpen(false);
182
+ }
183
  } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
184
+ updateSession(sessionId, {
185
+ panelData: {
186
+ title: `File Upload: ${String(args.path || 'unnamed')}`,
187
+ script: { content: String(args.content), language: String(args.path || '').endsWith('.py') ? 'python' : 'text' },
188
+ parameters: args,
189
+ },
190
+ });
191
+ if (isActiveRef.current) {
192
+ useLayoutStore.getState().setRightPanelOpen(true);
193
+ useLayoutStore.getState().setLeftSidebarOpen(false);
194
+ }
195
+ } else if (toolName === 'bash' && args.command) {
196
+ updateSession(sessionId, {
197
+ panelData: {
198
+ title: 'Sandbox',
199
+ script: { content: String(args.command), language: 'bash' },
200
+ },
201
+ panelView: 'output',
202
  });
 
 
203
  }
204
  },
205
  onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
206
+ const sessState = useAgentStore.getState().getSessionState(sessionId);
207
  if (toolName === 'hf_jobs' && output) {
208
+ updateSession(sessionId, {
209
+ panelData: sessState.panelData
210
+ ? { ...sessState.panelData, output: { content: output, language: 'markdown' } }
211
+ : { title: 'Output', output: { content: output, language: 'markdown' } },
212
+ panelView: !success ? 'output' : sessState.panelView,
213
+ });
214
+ } else if (toolName === 'bash') {
215
+ if (!success) {
216
+ updateSession(sessionId, { panelView: 'output' });
217
+ }
218
  }
219
  },
220
  onStreaming: () => {
221
+ updateSession(sessionId, { activityStatus: { type: 'streaming' } });
222
  },
223
  onToolRunning: (toolName: string, description?: string) => {
224
+ updateSession(sessionId, { activityStatus: { type: 'tool', toolName, description } });
225
  },
226
  }),
227
  // eslint-disable-next-line react-hooks/exhaustive-deps
 
271
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
272
  onError: (error) => {
273
  logger.error('useChat error:', error);
274
+ updateSession(sessionId, { isProcessing: false });
275
  if (isActiveRef.current) {
276
+ useAgentStore.getState().setError(error.message);
 
277
  }
278
  },
279
  });
 
355
  setMsgs(updated);
356
  saveMessages(sessionId, updated);
357
  }
358
+ updateSession(sessionId, { isProcessing: false });
359
  } catch (e) {
360
  logger.error('Undo failed:', e);
361
  }
362
+ }, [sessionId, updateSession]);
363
 
364
  // -- Approve tools ------------------------------------------------------
365
  const approveTools = useCallback(
 
382
 
383
  setNeedsAttention(sessionId, false);
384
  const hasApproved = approvals.some(a => a.approved);
385
+ if (hasApproved) {
386
+ updateSession(sessionId, { isProcessing: true });
387
+ }
388
  return true;
389
  },
390
+ [sessionId, chat, updateSession, setNeedsAttention],
391
  );
392
 
393
  // -- Stop (abort SSE stream + interrupt backend agent loop) ---------------
frontend/src/store/agentStore.ts CHANGED
@@ -8,6 +8,12 @@
8
  * - Plan state
9
  * - User info / error banners
10
  * - Edited scripts (for hf_jobs code editing)
 
 
 
 
 
 
11
  */
12
  import { create } from 'zustand';
13
  import type { User } from '@/types/agent';
@@ -46,8 +52,31 @@ export type ActivityStatus =
46
  | { type: 'waiting-approval' }
47
  | { type: 'streaming' };
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  interface AgentStore {
50
- // Global UI flags
 
 
 
 
51
  isProcessing: boolean;
52
  isConnected: boolean;
53
  activityStatus: ActivityStatus;
@@ -69,7 +98,21 @@ interface AgentStore {
69
  // Job URLs (tool_call_id -> job URL) for HF jobs
70
  jobUrls: Record<string, string>;
71
 
72
- // Actions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  setProcessing: (isProcessing: boolean) => void;
74
  setConnected: (isConnected: boolean) => void;
75
  setActivityStatus: (status: ActivityStatus) => void;
@@ -94,7 +137,29 @@ interface AgentStore {
94
  getJobUrl: (toolCallId: string) => string | undefined;
95
  }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  export const useAgentStore = create<AgentStore>()((set, get) => ({
 
 
 
98
  isProcessing: false,
99
  isConnected: false,
100
  activityStatus: { type: 'idle' },
@@ -111,6 +176,89 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
111
  editedScripts: {},
112
  jobUrls: {},
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  // ── Global flags ──────────────────────────────────────────────────
115
 
116
  setProcessing: (isProcessing) => {
@@ -125,32 +273,56 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
125
  setLlmHealthError: (error) => set({ llmHealthError: error }),
126
 
127
  // ── Panel (single-artifact) ───────────────────────────────────────
128
-
129
- setPanel: (data, view, editable) => set({
130
- panelData: data,
131
- panelView: view ?? (data.script ? 'script' : 'output'),
132
- panelEditable: editable ?? false,
 
 
 
 
 
133
  }),
134
 
135
- setPanelView: (view) => set({ panelView: view }),
 
 
 
136
 
137
- setPanelOutput: (output) => set((state) => ({
138
- panelData: state.panelData ? { ...state.panelData, output } : null,
139
- })),
 
 
 
 
140
 
141
- updatePanelScript: (content) => set((state) => ({
142
- panelData: state.panelData?.script
143
  ? { ...state.panelData, script: { ...state.panelData.script, content } }
144
- : state.panelData,
145
- })),
 
 
 
146
 
147
- lockPanel: () => set({ panelEditable: false }),
 
 
 
148
 
149
- clearPanel: () => set({ panelData: null, panelView: 'script', panelEditable: false }),
 
 
 
150
 
151
  // ── Plan ──────────────────────────────────────────────────────────
152
 
153
- setPlan: (plan) => set({ plan }),
 
 
 
154
 
155
  // ── Edited scripts ────────────────────────────────────────────────
156
 
 
8
  * - Plan state
9
  * - User info / error banners
10
  * - Edited scripts (for hf_jobs code editing)
11
+ *
12
+ * Per-session state:
13
+ * Each session maintains its own snapshot of processing/activity/panel/plan
14
+ * state in `sessionStates`. Background sessions keep updating their own
15
+ * snapshot via `updateSession()`. The active session's snapshot is mirrored
16
+ * to the flat top-level fields so the UI reads from a single place.
17
  */
18
  import { create } from 'zustand';
19
  import type { User } from '@/types/agent';
 
52
  | { type: 'waiting-approval' }
53
  | { type: 'streaming' };
54
 
55
+ /** State that is tracked per-session (each session has its own copy). */
56
+ export interface PerSessionState {
57
+ isProcessing: boolean;
58
+ activityStatus: ActivityStatus;
59
+ panelData: PanelData | null;
60
+ panelView: PanelView;
61
+ panelEditable: boolean;
62
+ plan: PlanItem[];
63
+ }
64
+
65
+ const defaultSessionState: PerSessionState = {
66
+ isProcessing: false,
67
+ activityStatus: { type: 'idle' },
68
+ panelData: null,
69
+ panelView: 'script',
70
+ panelEditable: false,
71
+ plan: [],
72
+ };
73
+
74
  interface AgentStore {
75
+ // ── Per-session state map ───────────────────────────────────────────
76
+ sessionStates: Record<string, PerSessionState>;
77
+ activeSessionId: string | null;
78
+
79
+ // ── Flat state (mirrors active session β€” UI reads from here) ────────
80
  isProcessing: boolean;
81
  isConnected: boolean;
82
  activityStatus: ActivityStatus;
 
98
  // Job URLs (tool_call_id -> job URL) for HF jobs
99
  jobUrls: Record<string, string>;
100
 
101
+ // ── Per-session actions ─────────────────────────────────────────────
102
+
103
+ /** Update a session's state. If it's the active session, also update flat state. */
104
+ updateSession: (sessionId: string, updates: Partial<PerSessionState>) => void;
105
+
106
+ /** Get a session's current state (from map, not flat). */
107
+ getSessionState: (sessionId: string) => PerSessionState;
108
+
109
+ /** Switch the active session β€” restores its state to flat fields. */
110
+ switchActiveSession: (sessionId: string) => void;
111
+
112
+ /** Remove a session's state from the map. */
113
+ clearSessionState: (sessionId: string) => void;
114
+
115
+ // ── Global actions (not per-session) ────────────────────────────────
116
  setProcessing: (isProcessing: boolean) => void;
117
  setConnected: (isConnected: boolean) => void;
118
  setActivityStatus: (status: ActivityStatus) => void;
 
137
  getJobUrl: (toolCallId: string) => string | undefined;
138
  }
139
 
140
+ /**
141
+ * Helper: patch the active session's snapshot with partial per-session fields.
142
+ * Returns the `sessionStates` slice to spread into a `set()` call, or `{}`
143
+ * if there's no active session snapshot to update.
144
+ */
145
+ function syncSnapshot(
146
+ state: AgentStore,
147
+ patch: Partial<PerSessionState>,
148
+ ): { sessionStates: Record<string, PerSessionState> } | Record<string, never> {
149
+ const { activeSessionId, sessionStates } = state;
150
+ if (!activeSessionId || !sessionStates[activeSessionId]) return {};
151
+ return {
152
+ sessionStates: {
153
+ ...sessionStates,
154
+ [activeSessionId]: { ...sessionStates[activeSessionId], ...patch },
155
+ },
156
+ };
157
+ }
158
+
159
  export const useAgentStore = create<AgentStore>()((set, get) => ({
160
+ sessionStates: {},
161
+ activeSessionId: null,
162
+
163
  isProcessing: false,
164
  isConnected: false,
165
  activityStatus: { type: 'idle' },
 
176
  editedScripts: {},
177
  jobUrls: {},
178
 
179
+ // ── Per-session state management ──────────────────────────────────
180
+
181
+ updateSession: (sessionId, updates) => {
182
+ const state = get();
183
+ const current = state.sessionStates[sessionId] || { ...defaultSessionState };
184
+ const updated = { ...current, ...updates };
185
+
186
+ // Apply the processing→idle side effect
187
+ const processingCleared = 'isProcessing' in updates && !updates.isProcessing;
188
+ if (processingCleared) {
189
+ if (updated.activityStatus.type !== 'waiting-approval') {
190
+ updated.activityStatus = { type: 'idle' };
191
+ }
192
+ }
193
+
194
+ const isActive = state.activeSessionId === sessionId;
195
+
196
+ // Build flat-state mirror: only the fields explicitly in `updates`
197
+ // (plus activityStatus when the processing→idle side-effect fires).
198
+ // This prevents overwriting flat fields changed by global setters
199
+ // (e.g. setPanelView called from CodePanel) with stale snapshot values.
200
+ let flatMirror: Record<string, unknown> = {};
201
+ if (isActive) {
202
+ for (const key of Object.keys(updates)) {
203
+ flatMirror[key] = updated[key as keyof PerSessionState];
204
+ }
205
+ // Side-effect may have changed activityStatus even if it wasn't in updates
206
+ if (processingCleared) {
207
+ flatMirror.activityStatus = updated.activityStatus;
208
+ }
209
+ }
210
+
211
+ set({
212
+ sessionStates: { ...state.sessionStates, [sessionId]: updated },
213
+ ...flatMirror,
214
+ });
215
+ },
216
+
217
+ getSessionState: (sessionId) => {
218
+ return get().sessionStates[sessionId] || { ...defaultSessionState };
219
+ },
220
+
221
+ switchActiveSession: (sessionId) => {
222
+ const state = get();
223
+
224
+ // Build a new sessionStates map (never mutate the existing object)
225
+ const updatedStates = { ...state.sessionStates };
226
+
227
+ // Save current active session's flat state back to its snapshot
228
+ if (state.activeSessionId && state.activeSessionId !== sessionId) {
229
+ updatedStates[state.activeSessionId] = {
230
+ isProcessing: state.isProcessing,
231
+ activityStatus: state.activityStatus,
232
+ panelData: state.panelData,
233
+ panelView: state.panelView,
234
+ panelEditable: state.panelEditable,
235
+ plan: state.plan,
236
+ };
237
+ }
238
+
239
+ // Restore the new session's state
240
+ const incoming = updatedStates[sessionId] || { ...defaultSessionState };
241
+ set({
242
+ activeSessionId: sessionId,
243
+ sessionStates: updatedStates,
244
+ isProcessing: incoming.isProcessing,
245
+ activityStatus: incoming.activityStatus,
246
+ panelData: incoming.panelData,
247
+ panelView: incoming.panelView,
248
+ panelEditable: incoming.panelEditable,
249
+ plan: incoming.plan,
250
+ // Clear transient error on switch
251
+ error: null,
252
+ });
253
+ },
254
+
255
+ clearSessionState: (sessionId) => {
256
+ set((state) => {
257
+ const { [sessionId]: _, ...rest } = state.sessionStates;
258
+ return { sessionStates: rest };
259
+ });
260
+ },
261
+
262
  // ── Global flags ──────────────────────────────────────────────────
263
 
264
  setProcessing: (isProcessing) => {
 
273
  setLlmHealthError: (error) => set({ llmHealthError: error }),
274
 
275
  // ── Panel (single-artifact) ───────────────────────────────────────
276
+ // Each setter also patches the active session's snapshot so that
277
+ // getSessionState() stays consistent with flat state.
278
+
279
+ setPanel: (data, view, editable) => set((state) => {
280
+ const patch: Partial<PerSessionState> = {
281
+ panelData: data,
282
+ panelView: view ?? (data.script ? 'script' : 'output'),
283
+ panelEditable: editable ?? false,
284
+ };
285
+ return { ...patch, ...syncSnapshot(state, patch) };
286
  }),
287
 
288
+ setPanelView: (view) => set((state) => {
289
+ const patch: Partial<PerSessionState> = { panelView: view };
290
+ return { ...patch, ...syncSnapshot(state, patch) };
291
+ }),
292
 
293
+ setPanelOutput: (output) => set((state) => {
294
+ const panelData = state.panelData
295
+ ? { ...state.panelData, output }
296
+ : { title: 'Output', output };
297
+ const patch: Partial<PerSessionState> = { panelData, panelView: 'output' };
298
+ return { ...patch, ...syncSnapshot(state, patch) };
299
+ }),
300
 
301
+ updatePanelScript: (content) => set((state) => {
302
+ const panelData = state.panelData?.script
303
  ? { ...state.panelData, script: { ...state.panelData.script, content } }
304
+ : state.panelData;
305
+ if (!panelData) return {};
306
+ const patch: Partial<PerSessionState> = { panelData };
307
+ return { ...patch, ...syncSnapshot(state, patch) };
308
+ }),
309
 
310
+ lockPanel: () => set((state) => {
311
+ const patch: Partial<PerSessionState> = { panelEditable: false };
312
+ return { ...patch, ...syncSnapshot(state, patch) };
313
+ }),
314
 
315
+ clearPanel: () => set((state) => {
316
+ const patch: Partial<PerSessionState> = { panelData: null, panelView: 'script', panelEditable: false };
317
+ return { ...patch, ...syncSnapshot(state, patch) };
318
+ }),
319
 
320
  // ── Plan ──────────────────────────────────────────────────────────
321
 
322
+ setPlan: (plan) => set((state) => {
323
+ const patch: Partial<PerSessionState> = { plan };
324
+ return { ...patch, ...syncSnapshot(state, patch) };
325
+ }),
326
 
327
  // ── Edited scripts ────────────────────────────────────────────────
328