Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Commit Β·
f56fa2e
1
Parent(s): f4ebc8f
fix: per-session state management for smooth task switching
Browse filesBackground 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
|
| 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,
|
| 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,
|
| 37 |
-
//
|
| 38 |
-
//
|
| 39 |
-
const prevActiveRef = useRef(isActive);
|
| 40 |
useEffect(() => {
|
| 41 |
-
if (isActive
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
}
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 71 |
-
|
| 72 |
onClose?.();
|
| 73 |
},
|
| 74 |
-
[switchSession,
|
| 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.
|
| 7 |
-
*
|
| 8 |
-
*
|
| 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 |
-
|
| 47 |
-
const
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
}
|
| 78 |
},
|
| 79 |
onProcessingDone: () => {
|
| 80 |
-
|
| 81 |
-
setProcessing(false);
|
| 82 |
-
}
|
| 83 |
},
|
| 84 |
onUndoComplete: () => {
|
| 85 |
-
|
| 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 |
-
|
| 94 |
-
|
| 95 |
-
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 96 |
-
setRightPanelOpen(true);
|
| 97 |
}
|
| 98 |
},
|
| 99 |
onToolLog: (tool: string, log: string) => {
|
| 100 |
-
|
| 101 |
-
if (tool
|
| 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 |
-
|
|
|
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
} else if (firstTool.tool === 'hf_repo_files' && args.content) {
|
| 139 |
const filename = args.path || 'file';
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
} else {
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
},
|
| 155 |
onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
|
| 156 |
-
if (!isActiveRef.current) return;
|
| 157 |
if (toolName === 'hf_jobs' && args.operation && args.script) {
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
} else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
});
|
| 170 |
-
setRightPanelOpen(true);
|
| 171 |
-
setLeftSidebarOpen(false);
|
| 172 |
}
|
| 173 |
},
|
| 174 |
onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
|
| 175 |
-
|
| 176 |
if (toolName === 'hf_jobs' && output) {
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
},
|
| 181 |
onStreaming: () => {
|
| 182 |
-
|
| 183 |
},
|
| 184 |
onToolRunning: (toolName: string, description?: string) => {
|
| 185 |
-
|
| 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 |
-
|
| 320 |
} catch (e) {
|
| 321 |
logger.error('Undo failed:', e);
|
| 322 |
}
|
| 323 |
-
}, [sessionId,
|
| 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
|
|
|
|
|
|
|
| 347 |
return true;
|
| 348 |
},
|
| 349 |
-
[sessionId, chat,
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}),
|
| 134 |
|
| 135 |
-
setPanelView: (view) => set(
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
setPanelOutput: (output) => set((state) =>
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
updatePanelScript: (content) => set((state) =>
|
| 142 |
-
panelData
|
| 143 |
? { ...state.panelData, script: { ...state.panelData.script, content } }
|
| 144 |
-
: state.panelData
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
lockPanel: () => set(
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
clearPanel: () => set(
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
// ββ Plan ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
|
| 153 |
-
setPlan: (plan) => set(
|
|
|
|
|
|
|
|
|
|
| 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 |
|