Spaces:
Running
Running
Commit ·
b29799c
1
Parent(s): fc14c05
Improve job approval UX with tabs, job URL, and message ordering
Browse files- Add tabbed panel for switching between Script and Logs views
- Show specific job URL from tool output (not generic jobs page link)
- Fix message ordering so final assistant response appears below approval box
- Persist logs in approval messages so users can access previous job logs
- Add View Script/View Logs buttons on completed approval cards
- Show job status and failure indicators in approval flow
- Panel tabs only clear on new job, not during approval continuation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
frontend/src/components/Chat/ApprovalFlow.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import { useState, useCallback, useEffect } from 'react';
|
| 2 |
-
import { Box, Typography, Button, TextField, IconButton } from '@mui/material';
|
| 3 |
import SendIcon from '@mui/icons-material/Send';
|
| 4 |
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 5 |
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
| 6 |
import CancelIcon from '@mui/icons-material/Cancel';
|
|
|
|
| 7 |
import { useAgentStore } from '@/store/agentStore';
|
| 8 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 9 |
import { useSessionStore } from '@/store/sessionStore';
|
|
@@ -14,7 +15,7 @@ interface ApprovalFlowProps {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
| 17 |
-
const { setPanelContent, updateMessage } = useAgentStore();
|
| 18 |
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 19 |
const { activeSessionId } = useSessionStore();
|
| 20 |
const [currentIndex, setCurrentIndex] = useState(0);
|
|
@@ -27,19 +28,45 @@ export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
|
| 27 |
|
| 28 |
const { batch, status } = approvalData;
|
| 29 |
|
| 30 |
-
//
|
| 31 |
let logsContent = '';
|
| 32 |
let showLogsButton = false;
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const logsPart = parts[1].trim();
|
| 38 |
const codeBlockMatch = logsPart.match(/```([\s\S]*?)```/);
|
| 39 |
if (codeBlockMatch) {
|
| 40 |
-
|
| 41 |
-
|
| 42 |
}
|
|
|
|
| 43 |
}
|
| 44 |
}
|
| 45 |
|
|
@@ -125,11 +152,13 @@ export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
|
| 125 |
const getToolDescription = (toolName: string, args: any) => {
|
| 126 |
if (toolName === 'hf_jobs') {
|
| 127 |
return (
|
| 128 |
-
<
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
);
|
| 134 |
}
|
| 135 |
return (
|
|
@@ -142,33 +171,60 @@ export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
|
| 142 |
const showCode = () => {
|
| 143 |
const args = currentTool.arguments as any;
|
| 144 |
if (currentTool.tool === 'hf_jobs' && args.script) {
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
});
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
} else {
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
}
|
| 163 |
};
|
| 164 |
|
| 165 |
const handleViewLogs = (e: React.MouseEvent) => {
|
| 166 |
e.stopPropagation();
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
});
|
|
|
|
| 172 |
setRightPanelOpen(true);
|
| 173 |
setLeftSidebarOpen(false);
|
| 174 |
};
|
|
@@ -221,31 +277,124 @@ export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
|
| 221 |
<OpenInNewIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.7 }} />
|
| 222 |
</Box>
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
{containsPushToHub && (
|
| 225 |
<Typography variant="caption" sx={{ color: 'var(--accent-green)', fontSize: '0.75rem', opacity: 0.8, px: 0.5 }}>
|
| 226 |
We've detected the result will be pushed to hub.
|
| 227 |
</Typography>
|
| 228 |
)}
|
| 229 |
|
| 230 |
-
{
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
textTransform: 'none',
|
| 239 |
borderColor: 'rgba(255,255,255,0.1)',
|
| 240 |
color: 'var(--accent-primary)',
|
|
|
|
|
|
|
| 241 |
'&:hover': {
|
| 242 |
-
|
| 243 |
-
|
| 244 |
}
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
)}
|
| 250 |
|
| 251 |
{status === 'pending' && (
|
|
|
|
| 1 |
import { useState, useCallback, useEffect } from 'react';
|
| 2 |
+
import { Box, Typography, Button, TextField, IconButton, Link } from '@mui/material';
|
| 3 |
import SendIcon from '@mui/icons-material/Send';
|
| 4 |
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 5 |
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
| 6 |
import CancelIcon from '@mui/icons-material/Cancel';
|
| 7 |
+
import LaunchIcon from '@mui/icons-material/Launch';
|
| 8 |
import { useAgentStore } from '@/store/agentStore';
|
| 9 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 10 |
import { useSessionStore } from '@/store/sessionStore';
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
| 18 |
+
const { setPanelContent, setPanelTab, setActivePanelTab, clearPanelTabs, updateMessage } = useAgentStore();
|
| 19 |
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 20 |
const { activeSessionId } = useSessionStore();
|
| 21 |
const [currentIndex, setCurrentIndex] = useState(0);
|
|
|
|
| 28 |
|
| 29 |
const { batch, status } = approvalData;
|
| 30 |
|
| 31 |
+
// Parse toolOutput to extract job info (URL, status, logs)
|
| 32 |
let logsContent = '';
|
| 33 |
let showLogsButton = false;
|
| 34 |
+
let jobUrl = '';
|
| 35 |
+
let jobId = '';
|
| 36 |
+
let jobStatus = '';
|
| 37 |
+
let jobFailed = false;
|
| 38 |
+
|
| 39 |
+
if (message.toolOutput) {
|
| 40 |
+
// Extract job URL: **View at:** https://...
|
| 41 |
+
const urlMatch = message.toolOutput.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
|
| 42 |
+
if (urlMatch) {
|
| 43 |
+
jobUrl = urlMatch[1];
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Extract job ID: **Job ID:** ...
|
| 47 |
+
const idMatch = message.toolOutput.match(/\*\*Job ID:\*\*\s*([^\s\n]+)/);
|
| 48 |
+
if (idMatch) {
|
| 49 |
+
jobId = idMatch[1];
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Extract job status: **Final Status:** ...
|
| 53 |
+
const statusMatch = message.toolOutput.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
|
| 54 |
+
if (statusMatch) {
|
| 55 |
+
jobStatus = statusMatch[1].trim();
|
| 56 |
+
jobFailed = jobStatus.toLowerCase().includes('error') || jobStatus.toLowerCase().includes('failed');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Extract logs
|
| 60 |
+
if (message.toolOutput.includes('**Logs:**')) {
|
| 61 |
+
const parts = message.toolOutput.split('**Logs:**');
|
| 62 |
+
if (parts.length > 1) {
|
| 63 |
const logsPart = parts[1].trim();
|
| 64 |
const codeBlockMatch = logsPart.match(/```([\s\S]*?)```/);
|
| 65 |
if (codeBlockMatch) {
|
| 66 |
+
logsContent = codeBlockMatch[1].trim();
|
| 67 |
+
showLogsButton = true;
|
| 68 |
}
|
| 69 |
+
}
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
|
|
|
| 152 |
const getToolDescription = (toolName: string, args: any) => {
|
| 153 |
if (toolName === 'hf_jobs') {
|
| 154 |
return (
|
| 155 |
+
<Box sx={{ flex: 1 }}>
|
| 156 |
+
<Typography variant="body2" sx={{ color: 'var(--muted-text)' }}>
|
| 157 |
+
The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>hf_jobs</Box> on{' '}
|
| 158 |
+
<Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.hardware_flavor || 'default'}</Box> with a timeout of{' '}
|
| 159 |
+
<Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.timeout || '30m'}</Box>
|
| 160 |
+
</Typography>
|
| 161 |
+
</Box>
|
| 162 |
);
|
| 163 |
}
|
| 164 |
return (
|
|
|
|
| 171 |
const showCode = () => {
|
| 172 |
const args = currentTool.arguments as any;
|
| 173 |
if (currentTool.tool === 'hf_jobs' && args.script) {
|
| 174 |
+
// Clear existing tabs and set up script tab (and logs if available)
|
| 175 |
+
clearPanelTabs();
|
| 176 |
+
setPanelTab({
|
| 177 |
+
id: 'script',
|
| 178 |
+
title: 'Script',
|
| 179 |
+
content: args.script,
|
| 180 |
+
language: 'python',
|
| 181 |
+
parameters: args
|
| 182 |
+
});
|
| 183 |
+
// If logs are available (job completed), also add logs tab
|
| 184 |
+
if (logsContent) {
|
| 185 |
+
setPanelTab({
|
| 186 |
+
id: 'logs',
|
| 187 |
+
title: 'Logs',
|
| 188 |
+
content: logsContent,
|
| 189 |
+
language: 'text'
|
| 190 |
});
|
| 191 |
+
}
|
| 192 |
+
setActivePanelTab('script');
|
| 193 |
+
setRightPanelOpen(true);
|
| 194 |
+
setLeftSidebarOpen(false);
|
| 195 |
} else {
|
| 196 |
+
setPanelContent({
|
| 197 |
+
title: `Tool: ${currentTool.tool}`,
|
| 198 |
+
content: JSON.stringify(args, null, 2),
|
| 199 |
+
language: 'json',
|
| 200 |
+
parameters: args
|
| 201 |
+
});
|
| 202 |
+
setRightPanelOpen(true);
|
| 203 |
+
setLeftSidebarOpen(false);
|
| 204 |
}
|
| 205 |
};
|
| 206 |
|
| 207 |
const handleViewLogs = (e: React.MouseEvent) => {
|
| 208 |
e.stopPropagation();
|
| 209 |
+
const args = currentTool.arguments as any;
|
| 210 |
+
// Set up both tabs so user can switch between script and logs
|
| 211 |
+
clearPanelTabs();
|
| 212 |
+
if (currentTool.tool === 'hf_jobs' && args.script) {
|
| 213 |
+
setPanelTab({
|
| 214 |
+
id: 'script',
|
| 215 |
+
title: 'Script',
|
| 216 |
+
content: args.script,
|
| 217 |
+
language: 'python',
|
| 218 |
+
parameters: args
|
| 219 |
+
});
|
| 220 |
+
}
|
| 221 |
+
setPanelTab({
|
| 222 |
+
id: 'logs',
|
| 223 |
+
title: 'Logs',
|
| 224 |
+
content: logsContent,
|
| 225 |
+
language: 'text'
|
| 226 |
});
|
| 227 |
+
setActivePanelTab('logs');
|
| 228 |
setRightPanelOpen(true);
|
| 229 |
setLeftSidebarOpen(false);
|
| 230 |
};
|
|
|
|
| 277 |
<OpenInNewIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.7 }} />
|
| 278 |
</Box>
|
| 279 |
|
| 280 |
+
{currentTool.tool === 'hf_jobs' && (
|
| 281 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
| 282 |
+
{/* Show specific job URL if available (after execution), otherwise generic link */}
|
| 283 |
+
{jobUrl ? (
|
| 284 |
+
<Link
|
| 285 |
+
href={jobUrl}
|
| 286 |
+
target="_blank"
|
| 287 |
+
rel="noopener noreferrer"
|
| 288 |
+
sx={{
|
| 289 |
+
display: 'flex',
|
| 290 |
+
alignItems: 'center',
|
| 291 |
+
gap: 0.5,
|
| 292 |
+
color: 'var(--accent-primary)',
|
| 293 |
+
fontSize: '0.8rem',
|
| 294 |
+
textDecoration: 'none',
|
| 295 |
+
opacity: 0.9,
|
| 296 |
+
'&:hover': {
|
| 297 |
+
opacity: 1,
|
| 298 |
+
textDecoration: 'underline',
|
| 299 |
+
}
|
| 300 |
+
}}
|
| 301 |
+
>
|
| 302 |
+
<LaunchIcon sx={{ fontSize: 14 }} />
|
| 303 |
+
View job{jobId ? ` (${jobId.substring(0, 8)}...)` : ''} on Hugging Face
|
| 304 |
+
</Link>
|
| 305 |
+
) : (
|
| 306 |
+
<Link
|
| 307 |
+
href="https://huggingface.co/settings/jobs"
|
| 308 |
+
target="_blank"
|
| 309 |
+
rel="noopener noreferrer"
|
| 310 |
+
sx={{
|
| 311 |
+
display: 'flex',
|
| 312 |
+
alignItems: 'center',
|
| 313 |
+
gap: 0.5,
|
| 314 |
+
color: 'var(--muted-text)',
|
| 315 |
+
fontSize: '0.8rem',
|
| 316 |
+
textDecoration: 'none',
|
| 317 |
+
opacity: 0.7,
|
| 318 |
+
'&:hover': {
|
| 319 |
+
opacity: 1,
|
| 320 |
+
textDecoration: 'underline',
|
| 321 |
+
}
|
| 322 |
+
}}
|
| 323 |
+
>
|
| 324 |
+
<LaunchIcon sx={{ fontSize: 14 }} />
|
| 325 |
+
View all jobs on Hugging Face
|
| 326 |
+
</Link>
|
| 327 |
+
)}
|
| 328 |
+
|
| 329 |
+
{/* Show job status if available */}
|
| 330 |
+
{jobStatus && (
|
| 331 |
+
<Typography
|
| 332 |
+
variant="caption"
|
| 333 |
+
sx={{
|
| 334 |
+
color: jobFailed ? 'var(--accent-red)' : 'var(--accent-green)',
|
| 335 |
+
fontSize: '0.75rem',
|
| 336 |
+
fontWeight: 500,
|
| 337 |
+
}}
|
| 338 |
+
>
|
| 339 |
+
Status: {jobStatus}
|
| 340 |
+
</Typography>
|
| 341 |
+
)}
|
| 342 |
+
</Box>
|
| 343 |
+
)}
|
| 344 |
+
|
| 345 |
{containsPushToHub && (
|
| 346 |
<Typography variant="caption" sx={{ color: 'var(--accent-green)', fontSize: '0.75rem', opacity: 0.8, px: 0.5 }}>
|
| 347 |
We've detected the result will be pushed to hub.
|
| 348 |
</Typography>
|
| 349 |
)}
|
| 350 |
|
| 351 |
+
{/* Show script/logs buttons for completed jobs */}
|
| 352 |
+
{status !== 'pending' && currentTool.tool === 'hf_jobs' && (args.script || showLogsButton) && (
|
| 353 |
+
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
| 354 |
+
{args.script && (
|
| 355 |
+
<Button
|
| 356 |
+
variant="outlined"
|
| 357 |
+
size="small"
|
| 358 |
+
startIcon={<OpenInNewIcon />}
|
| 359 |
+
onClick={showCode}
|
| 360 |
+
sx={{
|
| 361 |
+
textTransform: 'none',
|
| 362 |
+
borderColor: 'rgba(255,255,255,0.1)',
|
| 363 |
+
color: 'var(--muted-text)',
|
| 364 |
+
fontSize: '0.75rem',
|
| 365 |
+
py: 0.5,
|
| 366 |
+
'&:hover': {
|
| 367 |
+
borderColor: 'var(--accent-primary)',
|
| 368 |
+
color: 'var(--accent-primary)',
|
| 369 |
+
bgcolor: 'rgba(255,255,255,0.03)'
|
| 370 |
+
}
|
| 371 |
+
}}
|
| 372 |
+
>
|
| 373 |
+
View Script
|
| 374 |
+
</Button>
|
| 375 |
+
)}
|
| 376 |
+
{showLogsButton && (
|
| 377 |
+
<Button
|
| 378 |
+
variant="outlined"
|
| 379 |
+
size="small"
|
| 380 |
+
startIcon={<OpenInNewIcon />}
|
| 381 |
+
onClick={handleViewLogs}
|
| 382 |
+
sx={{
|
| 383 |
textTransform: 'none',
|
| 384 |
borderColor: 'rgba(255,255,255,0.1)',
|
| 385 |
color: 'var(--accent-primary)',
|
| 386 |
+
fontSize: '0.75rem',
|
| 387 |
+
py: 0.5,
|
| 388 |
'&:hover': {
|
| 389 |
+
borderColor: 'var(--accent-primary)',
|
| 390 |
+
bgcolor: 'rgba(255,255,255,0.03)'
|
| 391 |
}
|
| 392 |
+
}}
|
| 393 |
+
>
|
| 394 |
+
View Logs
|
| 395 |
+
</Button>
|
| 396 |
+
)}
|
| 397 |
+
</Box>
|
| 398 |
)}
|
| 399 |
|
| 400 |
{status === 'pending' && (
|
frontend/src/components/CodePanel/CodePanel.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
import { useRef, useEffect, useMemo } from 'react';
|
| 2 |
-
import { Box, Typography, IconButton } from '@mui/material';
|
| 3 |
import CloseIcon from '@mui/icons-material/Close';
|
| 4 |
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
| 5 |
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
| 6 |
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
|
|
|
|
|
|
| 7 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 8 |
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 9 |
import { useAgentStore } from '@/store/agentStore';
|
|
@@ -11,39 +13,83 @@ import { useLayoutStore } from '@/store/layoutStore';
|
|
| 11 |
import { processLogs } from '@/utils/logProcessor';
|
| 12 |
|
| 13 |
export default function CodePanel() {
|
| 14 |
-
const { panelContent, plan } = useAgentStore();
|
| 15 |
const { setRightPanelOpen } = useLayoutStore();
|
| 16 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const displayContent = useMemo(() => {
|
| 19 |
-
if (!
|
| 20 |
// Apply log processing only for text/logs, not for code/json
|
| 21 |
-
if (!
|
| 22 |
-
|
| 23 |
}
|
| 24 |
-
return
|
| 25 |
-
}, [
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
-
|
|
|
|
| 29 |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 30 |
}
|
| 31 |
-
}, [displayContent]);
|
|
|
|
|
|
|
| 32 |
|
| 33 |
return (
|
| 34 |
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
|
| 35 |
{/* Header - Fixed 60px to align */}
|
| 36 |
-
<Box sx={{
|
| 37 |
-
height: '60px',
|
| 38 |
-
display: 'flex',
|
| 39 |
-
alignItems: 'center',
|
| 40 |
-
justifyContent: 'space-between',
|
| 41 |
px: 2,
|
| 42 |
borderBottom: '1px solid rgba(255,255,255,0.03)'
|
| 43 |
}}>
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
<IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
|
| 48 |
<CloseIcon fontSize="small" />
|
| 49 |
</IconButton>
|
|
@@ -51,66 +97,66 @@ export default function CodePanel() {
|
|
| 51 |
|
| 52 |
{/* Main Content Area */}
|
| 53 |
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
| 54 |
-
{!
|
| 55 |
-
|
| 56 |
<Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
|
| 57 |
-
|
| 58 |
</Typography>
|
| 59 |
-
|
| 60 |
) : (
|
| 61 |
-
|
| 62 |
-
<Box
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
>
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
) : (
|
| 95 |
-
<Box component="pre" sx={{
|
| 96 |
-
m: 0,
|
| 97 |
-
fontFamily: 'inherit',
|
| 98 |
-
color: 'var(--text)',
|
| 99 |
-
whiteSpace: 'pre-wrap',
|
| 100 |
-
wordBreak: 'break-all'
|
| 101 |
-
}}>
|
| 102 |
-
<code>{displayContent}</code>
|
| 103 |
-
</Box>
|
| 104 |
-
)
|
| 105 |
) : (
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</Box>
|
|
|
|
| 114 |
)}
|
| 115 |
</Box>
|
| 116 |
|
|
|
|
| 1 |
import { useRef, useEffect, useMemo } from 'react';
|
| 2 |
+
import { Box, Typography, IconButton, Tabs, Tab } from '@mui/material';
|
| 3 |
import CloseIcon from '@mui/icons-material/Close';
|
| 4 |
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
| 5 |
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
| 6 |
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
| 7 |
+
import CodeIcon from '@mui/icons-material/Code';
|
| 8 |
+
import TerminalIcon from '@mui/icons-material/Terminal';
|
| 9 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 10 |
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 11 |
import { useAgentStore } from '@/store/agentStore';
|
|
|
|
| 13 |
import { processLogs } from '@/utils/logProcessor';
|
| 14 |
|
| 15 |
export default function CodePanel() {
|
| 16 |
+
const { panelContent, panelTabs, activePanelTab, setActivePanelTab, plan } = useAgentStore();
|
| 17 |
const { setRightPanelOpen } = useLayoutStore();
|
| 18 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 19 |
|
| 20 |
+
// Get the active tab content, or fall back to panelContent for backwards compatibility
|
| 21 |
+
const activeTab = panelTabs.find(t => t.id === activePanelTab);
|
| 22 |
+
const currentContent = activeTab || panelContent;
|
| 23 |
+
|
| 24 |
const displayContent = useMemo(() => {
|
| 25 |
+
if (!currentContent?.content) return '';
|
| 26 |
// Apply log processing only for text/logs, not for code/json
|
| 27 |
+
if (!currentContent.language || currentContent.language === 'text') {
|
| 28 |
+
return processLogs(currentContent.content);
|
| 29 |
}
|
| 30 |
+
return currentContent.content;
|
| 31 |
+
}, [currentContent?.content, currentContent?.language]);
|
| 32 |
|
| 33 |
useEffect(() => {
|
| 34 |
+
// Auto-scroll only for logs tab
|
| 35 |
+
if (scrollRef.current && activePanelTab === 'logs') {
|
| 36 |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 37 |
}
|
| 38 |
+
}, [displayContent, activePanelTab]);
|
| 39 |
+
|
| 40 |
+
const hasTabs = panelTabs.length > 0;
|
| 41 |
|
| 42 |
return (
|
| 43 |
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
|
| 44 |
{/* Header - Fixed 60px to align */}
|
| 45 |
+
<Box sx={{
|
| 46 |
+
height: '60px',
|
| 47 |
+
display: 'flex',
|
| 48 |
+
alignItems: 'center',
|
| 49 |
+
justifyContent: 'space-between',
|
| 50 |
px: 2,
|
| 51 |
borderBottom: '1px solid rgba(255,255,255,0.03)'
|
| 52 |
}}>
|
| 53 |
+
{hasTabs ? (
|
| 54 |
+
<Tabs
|
| 55 |
+
value={activePanelTab || panelTabs[0]?.id}
|
| 56 |
+
onChange={(_, newValue) => setActivePanelTab(newValue)}
|
| 57 |
+
sx={{
|
| 58 |
+
minHeight: 36,
|
| 59 |
+
'& .MuiTabs-indicator': {
|
| 60 |
+
backgroundColor: 'var(--accent-primary)',
|
| 61 |
+
},
|
| 62 |
+
'& .MuiTab-root': {
|
| 63 |
+
minHeight: 36,
|
| 64 |
+
minWidth: 'auto',
|
| 65 |
+
px: 2,
|
| 66 |
+
py: 0.5,
|
| 67 |
+
fontSize: '0.75rem',
|
| 68 |
+
fontWeight: 600,
|
| 69 |
+
textTransform: 'uppercase',
|
| 70 |
+
letterSpacing: '0.05em',
|
| 71 |
+
color: 'var(--muted-text)',
|
| 72 |
+
'&.Mui-selected': {
|
| 73 |
+
color: 'var(--text)',
|
| 74 |
+
},
|
| 75 |
+
},
|
| 76 |
+
}}
|
| 77 |
+
>
|
| 78 |
+
{panelTabs.map((tab) => (
|
| 79 |
+
<Tab
|
| 80 |
+
key={tab.id}
|
| 81 |
+
value={tab.id}
|
| 82 |
+
label={tab.title}
|
| 83 |
+
icon={tab.id === 'script' ? <CodeIcon sx={{ fontSize: 16 }} /> : <TerminalIcon sx={{ fontSize: 16 }} />}
|
| 84 |
+
iconPosition="start"
|
| 85 |
+
/>
|
| 86 |
+
))}
|
| 87 |
+
</Tabs>
|
| 88 |
+
) : (
|
| 89 |
+
<Typography variant="caption" sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
| 90 |
+
{currentContent?.title || 'Code Panel'}
|
| 91 |
+
</Typography>
|
| 92 |
+
)}
|
| 93 |
<IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
|
| 94 |
<CloseIcon fontSize="small" />
|
| 95 |
</IconButton>
|
|
|
|
| 97 |
|
| 98 |
{/* Main Content Area */}
|
| 99 |
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
| 100 |
+
{!currentContent ? (
|
| 101 |
+
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
|
| 102 |
<Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
|
| 103 |
+
NO DATA LOADED
|
| 104 |
</Typography>
|
| 105 |
+
</Box>
|
| 106 |
) : (
|
| 107 |
+
<Box sx={{ flex: 1, overflow: 'hidden', p: 2 }}>
|
| 108 |
+
<Box
|
| 109 |
+
ref={scrollRef}
|
| 110 |
+
className="code-panel"
|
| 111 |
+
sx={{
|
| 112 |
+
background: '#0A0B0C',
|
| 113 |
+
borderRadius: 'var(--radius-md)',
|
| 114 |
+
padding: '18px',
|
| 115 |
+
border: '1px solid rgba(255,255,255,0.03)',
|
| 116 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
|
| 117 |
+
fontSize: '13px',
|
| 118 |
+
lineHeight: 1.55,
|
| 119 |
+
height: '100%',
|
| 120 |
+
overflow: 'auto',
|
| 121 |
+
}}
|
| 122 |
>
|
| 123 |
+
{currentContent.content ? (
|
| 124 |
+
currentContent.language === 'python' ? (
|
| 125 |
+
<SyntaxHighlighter
|
| 126 |
+
language="python"
|
| 127 |
+
style={vscDarkPlus}
|
| 128 |
+
customStyle={{
|
| 129 |
+
margin: 0,
|
| 130 |
+
padding: 0,
|
| 131 |
+
background: 'transparent',
|
| 132 |
+
fontSize: '13px',
|
| 133 |
+
fontFamily: 'inherit',
|
| 134 |
+
}}
|
| 135 |
+
wrapLines={true}
|
| 136 |
+
wrapLongLines={true}
|
| 137 |
+
>
|
| 138 |
+
{displayContent}
|
| 139 |
+
</SyntaxHighlighter>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
) : (
|
| 141 |
+
<Box component="pre" sx={{
|
| 142 |
+
m: 0,
|
| 143 |
+
fontFamily: 'inherit',
|
| 144 |
+
color: 'var(--text)',
|
| 145 |
+
whiteSpace: 'pre-wrap',
|
| 146 |
+
wordBreak: 'break-all'
|
| 147 |
+
}}>
|
| 148 |
+
<code>{displayContent}</code>
|
| 149 |
+
</Box>
|
| 150 |
+
)
|
| 151 |
+
) : (
|
| 152 |
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
|
| 153 |
+
<Typography variant="caption">
|
| 154 |
+
NO CONTENT TO DISPLAY
|
| 155 |
+
</Typography>
|
| 156 |
+
</Box>
|
| 157 |
+
)}
|
| 158 |
</Box>
|
| 159 |
+
</Box>
|
| 160 |
)}
|
| 161 |
</Box>
|
| 162 |
|
frontend/src/hooks/useAgentWebSocket.ts
CHANGED
|
@@ -34,6 +34,9 @@ export function useAgentWebSocket({
|
|
| 34 |
updateTraceLog,
|
| 35 |
clearTraceLogs,
|
| 36 |
setPanelContent,
|
|
|
|
|
|
|
|
|
|
| 37 |
setPlan,
|
| 38 |
setCurrentTurnMessageId,
|
| 39 |
updateCurrentTurnTrace,
|
|
@@ -58,6 +61,8 @@ export function useAgentWebSocket({
|
|
| 58 |
case 'processing':
|
| 59 |
setProcessing(true);
|
| 60 |
clearTraceLogs();
|
|
|
|
|
|
|
| 61 |
setCurrentTurnMessageId(null); // Start a new turn
|
| 62 |
break;
|
| 63 |
|
|
@@ -115,23 +120,29 @@ export function useAgentWebSocket({
|
|
| 115 |
|
| 116 |
// Auto-expand Right Panel for specific tools
|
| 117 |
if (toolName === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
content: args.script,
|
| 121 |
-
language: 'python'
|
|
|
|
| 122 |
});
|
|
|
|
| 123 |
setRightPanelOpen(true);
|
| 124 |
setLeftSidebarOpen(false);
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
|
| 136 |
console.log('Tool call:', toolName, args);
|
| 137 |
break;
|
|
@@ -175,29 +186,26 @@ export function useAgentWebSocket({
|
|
| 175 |
const log = (event.data?.log as string) || '';
|
| 176 |
|
| 177 |
if (toolName === 'hf_jobs') {
|
| 178 |
-
const
|
| 179 |
-
|
| 180 |
-
// If we are already showing logs, append
|
| 181 |
-
// If we are showing "Compute Job Script", overwrite/switch to logs
|
| 182 |
-
// Otherwise, initialize
|
| 183 |
-
|
| 184 |
-
let newContent = log;
|
| 185 |
-
if (currentPanel?.title === 'Job Logs') {
|
| 186 |
-
newContent = currentPanel.content + '\n' + log;
|
| 187 |
-
} else if (currentPanel?.title === 'Compute Job Script') {
|
| 188 |
-
// We were showing the script, now logs start.
|
| 189 |
-
// Maybe we want to clear and start showing logs.
|
| 190 |
-
newContent = '--- Starting execution ---\n' + log;
|
| 191 |
-
}
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
content: newContent,
|
| 196 |
language: 'text'
|
| 197 |
});
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
| 199 |
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 200 |
-
|
| 201 |
}
|
| 202 |
}
|
| 203 |
break;
|
|
@@ -219,7 +227,7 @@ export function useAgentWebSocket({
|
|
| 219 |
tool_call_id: string;
|
| 220 |
}>;
|
| 221 |
const count = (event.data?.count as number) || 0;
|
| 222 |
-
|
| 223 |
// Create a persistent message for the approval request
|
| 224 |
const message: Message = {
|
| 225 |
id: `msg_approval_${Date.now()}`,
|
|
@@ -232,7 +240,10 @@ export function useAgentWebSocket({
|
|
| 232 |
}
|
| 233 |
};
|
| 234 |
addMessage(sessionId, message);
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
// We don't set pendingApprovals in the global store anymore as the message handles the UI
|
| 237 |
setPendingApprovals(null);
|
| 238 |
setProcessing(false);
|
|
|
|
| 34 |
updateTraceLog,
|
| 35 |
clearTraceLogs,
|
| 36 |
setPanelContent,
|
| 37 |
+
setPanelTab,
|
| 38 |
+
setActivePanelTab,
|
| 39 |
+
clearPanelTabs,
|
| 40 |
setPlan,
|
| 41 |
setCurrentTurnMessageId,
|
| 42 |
updateCurrentTurnTrace,
|
|
|
|
| 61 |
case 'processing':
|
| 62 |
setProcessing(true);
|
| 63 |
clearTraceLogs();
|
| 64 |
+
// Don't clear panel tabs here - they should persist during approval flow
|
| 65 |
+
// Tabs will be cleared when a new tool_call sets up new content
|
| 66 |
setCurrentTurnMessageId(null); // Start a new turn
|
| 67 |
break;
|
| 68 |
|
|
|
|
| 120 |
|
| 121 |
// Auto-expand Right Panel for specific tools
|
| 122 |
if (toolName === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
|
| 123 |
+
// Clear any existing tabs from previous jobs before setting new script
|
| 124 |
+
clearPanelTabs();
|
| 125 |
+
// Use tab system for jobs - add script tab immediately
|
| 126 |
+
setPanelTab({
|
| 127 |
+
id: 'script',
|
| 128 |
+
title: 'Script',
|
| 129 |
content: args.script,
|
| 130 |
+
language: 'python',
|
| 131 |
+
parameters: args
|
| 132 |
});
|
| 133 |
+
setActivePanelTab('script');
|
| 134 |
setRightPanelOpen(true);
|
| 135 |
setLeftSidebarOpen(false);
|
| 136 |
+
} else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
|
| 137 |
+
setPanelContent({
|
| 138 |
+
title: `File Upload: ${args.path || 'unnamed'}`,
|
| 139 |
+
content: args.content,
|
| 140 |
+
parameters: args,
|
| 141 |
+
language: args.path?.endsWith('.py') ? 'python' : undefined
|
| 142 |
+
});
|
| 143 |
+
setRightPanelOpen(true);
|
| 144 |
+
setLeftSidebarOpen(false);
|
| 145 |
+
}
|
| 146 |
|
| 147 |
console.log('Tool call:', toolName, args);
|
| 148 |
break;
|
|
|
|
| 186 |
const log = (event.data?.log as string) || '';
|
| 187 |
|
| 188 |
if (toolName === 'hf_jobs') {
|
| 189 |
+
const currentTabs = useAgentStore.getState().panelTabs;
|
| 190 |
+
const logsTab = currentTabs.find(t => t.id === 'logs');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
// Append to existing logs tab or create new one
|
| 193 |
+
const newContent = logsTab
|
| 194 |
+
? logsTab.content + '\n' + log
|
| 195 |
+
: '--- Job execution started ---\n' + log;
|
| 196 |
+
|
| 197 |
+
setPanelTab({
|
| 198 |
+
id: 'logs',
|
| 199 |
+
title: 'Logs',
|
| 200 |
content: newContent,
|
| 201 |
language: 'text'
|
| 202 |
});
|
| 203 |
+
|
| 204 |
+
// Auto-switch to logs tab when logs start streaming
|
| 205 |
+
setActivePanelTab('logs');
|
| 206 |
+
|
| 207 |
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 208 |
+
setRightPanelOpen(true);
|
| 209 |
}
|
| 210 |
}
|
| 211 |
break;
|
|
|
|
| 227 |
tool_call_id: string;
|
| 228 |
}>;
|
| 229 |
const count = (event.data?.count as number) || 0;
|
| 230 |
+
|
| 231 |
// Create a persistent message for the approval request
|
| 232 |
const message: Message = {
|
| 233 |
id: `msg_approval_${Date.now()}`,
|
|
|
|
| 240 |
}
|
| 241 |
};
|
| 242 |
addMessage(sessionId, message);
|
| 243 |
+
|
| 244 |
+
// Clear currentTurnMessageId so subsequent assistant_message events create a new message below the approval
|
| 245 |
+
setCurrentTurnMessageId(null);
|
| 246 |
+
|
| 247 |
// We don't set pendingApprovals in the global store anymore as the message handles the UI
|
| 248 |
setPendingApprovals(null);
|
| 249 |
setProcessing(false);
|
frontend/src/store/agentStore.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface PlanItem {
|
|
| 7 |
status: 'pending' | 'in_progress' | 'completed';
|
| 8 |
}
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
interface AgentStore {
|
| 11 |
// State per session (keyed by session ID)
|
| 12 |
messagesBySession: Record<string, Message[]>;
|
|
@@ -17,6 +25,8 @@ interface AgentStore {
|
|
| 17 |
error: string | null;
|
| 18 |
traceLogs: TraceLog[];
|
| 19 |
panelContent: { title: string; content: string; language?: string; parameters?: any } | null;
|
|
|
|
|
|
|
| 20 |
plan: PlanItem[];
|
| 21 |
currentTurnMessageId: string | null; // Track the current turn's assistant message
|
| 22 |
|
|
@@ -34,6 +44,9 @@ interface AgentStore {
|
|
| 34 |
updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => void;
|
| 35 |
clearTraceLogs: () => void;
|
| 36 |
setPanelContent: (content: { title: string; content: string; language?: string; parameters?: any } | null) => void;
|
|
|
|
|
|
|
|
|
|
| 37 |
setPlan: (plan: PlanItem[]) => void;
|
| 38 |
setCurrentTurnMessageId: (id: string | null) => void;
|
| 39 |
updateCurrentTurnTrace: (sessionId: string) => void;
|
|
@@ -48,6 +61,8 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|
| 48 |
error: null,
|
| 49 |
traceLogs: [],
|
| 50 |
panelContent: null,
|
|
|
|
|
|
|
| 51 |
plan: [],
|
| 52 |
currentTurnMessageId: null,
|
| 53 |
|
|
@@ -139,6 +154,33 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|
| 139 |
set({ panelContent: content });
|
| 140 |
},
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
setPlan: (plan: PlanItem[]) => {
|
| 143 |
set({ plan });
|
| 144 |
},
|
|
|
|
| 7 |
status: 'pending' | 'in_progress' | 'completed';
|
| 8 |
}
|
| 9 |
|
| 10 |
+
interface PanelTab {
|
| 11 |
+
id: string;
|
| 12 |
+
title: string;
|
| 13 |
+
content: string;
|
| 14 |
+
language?: string;
|
| 15 |
+
parameters?: any;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
interface AgentStore {
|
| 19 |
// State per session (keyed by session ID)
|
| 20 |
messagesBySession: Record<string, Message[]>;
|
|
|
|
| 25 |
error: string | null;
|
| 26 |
traceLogs: TraceLog[];
|
| 27 |
panelContent: { title: string; content: string; language?: string; parameters?: any } | null;
|
| 28 |
+
panelTabs: PanelTab[];
|
| 29 |
+
activePanelTab: string | null;
|
| 30 |
plan: PlanItem[];
|
| 31 |
currentTurnMessageId: string | null; // Track the current turn's assistant message
|
| 32 |
|
|
|
|
| 44 |
updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => void;
|
| 45 |
clearTraceLogs: () => void;
|
| 46 |
setPanelContent: (content: { title: string; content: string; language?: string; parameters?: any } | null) => void;
|
| 47 |
+
setPanelTab: (tab: PanelTab) => void;
|
| 48 |
+
setActivePanelTab: (tabId: string) => void;
|
| 49 |
+
clearPanelTabs: () => void;
|
| 50 |
setPlan: (plan: PlanItem[]) => void;
|
| 51 |
setCurrentTurnMessageId: (id: string | null) => void;
|
| 52 |
updateCurrentTurnTrace: (sessionId: string) => void;
|
|
|
|
| 61 |
error: null,
|
| 62 |
traceLogs: [],
|
| 63 |
panelContent: null,
|
| 64 |
+
panelTabs: [],
|
| 65 |
+
activePanelTab: null,
|
| 66 |
plan: [],
|
| 67 |
currentTurnMessageId: null,
|
| 68 |
|
|
|
|
| 154 |
set({ panelContent: content });
|
| 155 |
},
|
| 156 |
|
| 157 |
+
setPanelTab: (tab: PanelTab) => {
|
| 158 |
+
set((state) => {
|
| 159 |
+
const existingIndex = state.panelTabs.findIndex(t => t.id === tab.id);
|
| 160 |
+
let newTabs: PanelTab[];
|
| 161 |
+
if (existingIndex >= 0) {
|
| 162 |
+
// Update existing tab
|
| 163 |
+
newTabs = [...state.panelTabs];
|
| 164 |
+
newTabs[existingIndex] = tab;
|
| 165 |
+
} else {
|
| 166 |
+
// Add new tab
|
| 167 |
+
newTabs = [...state.panelTabs, tab];
|
| 168 |
+
}
|
| 169 |
+
return {
|
| 170 |
+
panelTabs: newTabs,
|
| 171 |
+
activePanelTab: state.activePanelTab || tab.id, // Auto-select first tab
|
| 172 |
+
};
|
| 173 |
+
});
|
| 174 |
+
},
|
| 175 |
+
|
| 176 |
+
setActivePanelTab: (tabId: string) => {
|
| 177 |
+
set({ activePanelTab: tabId });
|
| 178 |
+
},
|
| 179 |
+
|
| 180 |
+
clearPanelTabs: () => {
|
| 181 |
+
set({ panelTabs: [], activePanelTab: null });
|
| 182 |
+
},
|
| 183 |
+
|
| 184 |
setPlan: (plan: PlanItem[]) => {
|
| 185 |
set({ plan });
|
| 186 |
},
|