diff --git "a/frontend/script.js" "b/frontend/script.js"
deleted file mode 100644--- "a/frontend/script.js"
+++ /dev/null
@@ -1,5460 +0,0 @@
-// ============================================================
-// Multi-user session management
-// ============================================================
-let SESSION_ID = localStorage.getItem('agentui_username') || '';
-
-function apiFetch(url, options = {}) {
- if (SESSION_ID) {
- options.headers = { ...options.headers, 'X-Session-ID': SESSION_ID };
- }
- return fetch(url, options);
-}
-
-function sanitizeUsername(name) {
- return name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '').substring(0, 30);
-}
-
-// ============================================================
-// Agent Type Registry — populated from backend /api/agents at startup
-// To add a new agent type, add an entry in backend/agents.py (single source of truth)
-// ============================================================
-let AGENT_REGISTRY = {};
-// Virtual types used only in timeline rendering (not real agents)
-const VIRTUAL_TYPE_LABELS = { search: 'SEARCH', browse: 'BROWSE' };
-
-// Derived helpers from registry
-function getTypeLabel(type) {
- if (AGENT_REGISTRY[type]) return AGENT_REGISTRY[type].label;
- if (VIRTUAL_TYPE_LABELS[type]) return VIRTUAL_TYPE_LABELS[type];
- return type.toUpperCase();
-}
-function getPlaceholder(type) {
- return AGENT_REGISTRY[type]?.placeholder || 'Enter message...';
-}
-function getDefaultCounters() {
- const counters = {};
- for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
- if (agent.hasCounter) counters[key] = 0;
- }
- return counters;
-}
-
-// State management
-let tabCounter = 1;
-let activeTabId = 0;
-let currentSession = null; // Name of the current session
-const collapsedAgents = new Set(); // Track collapsed agent tab IDs
-let researchQueryTabIds = {}; // queryIndex -> virtual tabId for research timeline
-let showAllTurns = true; // Toggle to show/hide individual assistant dots
-
-// Fetch random isotope name from backend
-async function generateSessionName() {
- try {
- const response = await apiFetch('/api/sessions/random-name');
- const data = await response.json();
- return data.name;
- } catch (error) {
- // Fallback to timestamp if API fails
- return `session-${Date.now()}`;
- }
-}
-
-let settings = {
- // New provider/model structure
- providers: {}, // providerId -> {name, endpoint, token}
- models: {}, // modelId -> {name, providerId, modelId (API model string)}
- agents: {}, // Populated after AGENT_REGISTRY is fetched
- // Service API keys
- e2bKey: '',
- serperKey: '',
- hfToken: '',
- // Image model selections (model IDs from the models list)
- imageGenModel: '',
- imageEditModel: '',
- // Research settings
- researchSubAgentModel: '',
- researchParallelWorkers: null,
- researchMaxWebsites: null,
- // UI settings
- themeColor: 'forest',
- // Schema version for migrations
- settingsVersion: 2
-};
-
-// Track action widgets for result updates (maps tabId -> widget element)
-const actionWidgets = {};
-
-// Track tool call IDs for result updates (maps tabId -> tool_call_id)
-const toolCallIds = {};
-
-// Global figure/image registry populated by sub-agents for cross-agent reference resolution
-// Maps "figure_1" -> {type, data} and "image_1" -> {type: "png", data: base64}
-const globalFigureRegistry = {};
-
-// Debug: per-tab LLM call history (populated by SSE debug_call_input/output events)
-// Maps tabId -> [{call_number, timestamp, input, output, error}]
-const debugHistory = {};
-
-// Track agents by task_id for reuse (maps task_id -> tabId)
-const taskIdToTabId = {};
-
-// Whether command center input is blocked waiting for agents to finish
-let commandInputBlocked = false;
-
-// Count of agent launches that haven't started generating yet (handles race condition)
-let pendingAgentLaunches = 0;
-
-// Track agent counters for each type (derived from registry)
-let agentCounters = getDefaultCounters();
-
-// Debounce timer for workspace saving
-let saveWorkspaceTimer = null;
-
-// Abort controllers for in-flight fetch requests (tabId -> AbortController)
-const activeAbortControllers = {};
-
-// Timeline data structure for sidebar
-// Maps tabId -> { type, title, events: [{type: 'user'|'assistant'|'agent', content, childTabId?}], parentTabId?, isGenerating }
-const timelineData = {
- 0: { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false }
-};
-
-// Reset all local state for session switching (without page reload)
-function resetLocalState() {
- // Reset counters
- tabCounter = 1;
- activeTabId = 0;
- currentSession = null;
- collapsedAgents.clear();
-
- // Clear object maps
- Object.keys(actionWidgets).forEach(k => delete actionWidgets[k]);
- Object.keys(toolCallIds).forEach(k => delete toolCallIds[k]);
- Object.keys(globalFigureRegistry).forEach(k => delete globalFigureRegistry[k]);
- Object.keys(debugHistory).forEach(k => delete debugHistory[k]);
- Object.keys(taskIdToTabId).forEach(k => delete taskIdToTabId[k]);
- researchQueryTabIds = {};
- showAllTurns = true;
- agentCounters = getDefaultCounters();
-
- // Reset sidebar checkboxes
- const compactCb = document.getElementById('compactViewCheckbox');
- if (compactCb) compactCb.checked = false;
- const collapseAgentsCb = document.getElementById('collapseAgentsCheckbox');
- if (collapseAgentsCb) collapseAgentsCb.checked = false;
- const collapseToolsCb = document.getElementById('collapseToolsCheckbox');
- if (collapseToolsCb) collapseToolsCb.checked = false;
-
- // Reset timeline data
- Object.keys(timelineData).forEach(k => delete timelineData[k]);
- timelineData[0] = { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false };
-
- // Clear dynamic tabs from DOM
- const dynamicTabs = document.getElementById('dynamicTabs');
- if (dynamicTabs) dynamicTabs.innerHTML = '';
-
- // Remove all dynamic tab content elements (keep tab-content[data-content-id="0"])
- document.querySelectorAll('.tab-content').forEach(el => {
- if (el.dataset.contentId !== '0') el.remove();
- });
-
- // Clear command center messages
- const commandMessages = document.getElementById('messages-command');
- if (commandMessages) commandMessages.innerHTML = '';
-
- // Close sessions panel if open
- const sessionsPanel = document.getElementById('sessionsPanel');
- const sessionsBtn = document.getElementById('sessionsBtn');
- const appContainer = document.querySelector('.app-container');
- if (sessionsPanel) sessionsPanel.classList.remove('active');
- if (sessionsBtn) sessionsBtn.classList.remove('active');
- if (appContainer) appContainer.classList.remove('sessions-panel-open');
-}
-
-// Add event to timeline
-function addTimelineEvent(tabId, eventType, content, childTabId = null, meta = null) {
- if (!timelineData[tabId]) {
- timelineData[tabId] = { type: 'unknown', title: 'Unknown', events: [], parentTabId: null, isGenerating: false };
- }
-
- // Truncate content for preview (first 80 chars)
- const preview = content.length > 80 ? content.substring(0, 80) + '...' : content;
-
- const eventIndex = timelineData[tabId].events.length;
- timelineData[tabId].events.push({
- type: eventType, // 'user', 'assistant', or 'agent'
- content: preview,
- childTabId: childTabId,
- meta: meta, // optional: { tag: 'SEARCH' } for tool-like entries
- timestamp: Date.now(),
- index: eventIndex,
- });
-
- renderTimeline();
- return eventIndex;
-}
-
-// Register a new agent in timeline
-function registerAgentInTimeline(tabId, type, title, parentTabId = null) {
- timelineData[tabId] = {
- type: type,
- title: title,
- events: [],
- parentTabId: parentTabId,
- isGenerating: false
- };
-
- // If this agent was launched from another, add an agent event to parent
- if (parentTabId !== null && timelineData[parentTabId]) {
- addTimelineEvent(parentTabId, 'agent', title, tabId);
- }
-
- renderTimeline();
-}
-
-// Update generating state
-function setTimelineGenerating(tabId, isGenerating) {
- if (timelineData[tabId]) {
- timelineData[tabId].isGenerating = isGenerating;
- renderTimeline();
- }
-}
-
-// Update agent title in timeline
-function updateTimelineTitle(tabId, title) {
- if (timelineData[tabId]) {
- timelineData[tabId].title = title;
- renderTimeline();
- }
-}
-
-// Remove agent from timeline
-function removeFromTimeline(tabId) {
- // Remove from parent's events if it was a child
- const notebook = timelineData[tabId];
- if (notebook && notebook.parentTabId !== null) {
- const parent = timelineData[notebook.parentTabId];
- if (parent) {
- parent.events = parent.events.filter(e => e.childTabId !== tabId);
- }
- }
- delete timelineData[tabId];
- renderTimeline();
-}
-
-// Open a closed tab or switch to an existing one
-function openOrSwitchToTab(tabId) {
- // Check for actual tab element (not timeline elements which also have data-tab-id)
- const existingTab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
- if (existingTab) {
- // Tab exists, just switch to it
- switchToTab(tabId);
- } else {
- // Tab was closed, need to recreate it with the SAME tabId
- const notebook = timelineData[tabId];
- if (notebook) {
- reopenClosedTab(tabId, notebook);
- }
- }
-}
-
-// Recreate a closed tab using its existing tabId (doesn't create new timeline entry)
-function reopenClosedTab(tabId, notebook) {
- const type = notebook.type;
- const title = notebook.savedTitle || notebook.title;
-
- // Create tab element
- const tab = document.createElement('div');
- tab.className = 'tab';
- tab.dataset.tabId = tabId;
- tab.innerHTML = `
- ${title}
-
- ×
- `;
-
- // Insert into the dynamic tabs container
- const dynamicTabs = document.getElementById('dynamicTabs');
- dynamicTabs.appendChild(tab);
-
- // Create content element - restore saved content if available
- const content = document.createElement('div');
- content.className = 'tab-content';
- content.dataset.contentId = tabId;
-
- if (notebook.savedContent) {
- // Restore the saved content (includes all messages)
- content.innerHTML = notebook.savedContent;
- } else {
- // Fallback: create fresh agent content
- content.innerHTML = createAgentContent(type, tabId, title);
- }
-
- document.querySelector('.main-content').appendChild(content);
-
- // Mark as no longer closed and clear saved content
- notebook.isClosed = false;
- delete notebook.savedContent;
- delete notebook.savedTitle;
-
- // Switch to the reopened tab
- switchToTab(tabId);
-
- // Re-attach event listeners for the restored content
- if (type !== 'command-center') {
- const input = content.querySelector('textarea');
- const sendBtn = content.querySelector('.input-container button');
-
- if (input && sendBtn) {
- sendBtn.addEventListener('click', () => sendMessage(tabId));
-
- // Auto-resize textarea
- input.addEventListener('input', () => {
- input.style.height = 'auto';
- input.style.height = Math.min(input.scrollHeight, 200) + 'px';
- input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
- });
-
- // Enter to send, Shift+Enter for newline
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendMessage(tabId);
- }
- });
- }
-
- // If this is a code agent, start the sandbox proactively
- if (type === 'code') {
- startSandbox(tabId);
- }
- }
-
- // Update timeline to remove "closed" indicator
- renderTimeline();
-
- // Save workspace state
- saveWorkspaceDebounced();
-}
-
-// Render the full timeline widget
-function renderTimeline() {
- const sidebarContent = document.getElementById('sidebarAgents');
- if (!sidebarContent) return;
-
- // Get root agents (those without parents) - always include command center for workspace name
- const rootAgents = Object.entries(timelineData)
- .filter(([id, data]) => data.parentTabId === null);
-
- let html = '';
-
- for (const [tabId, notebook] of rootAgents) {
- html += renderAgentTimeline(parseInt(tabId), notebook);
- }
-
- sidebarContent.innerHTML = html;
-
- // Update timeline line heights
- updateTimelineLines();
-
- // Add click handlers
- sidebarContent.querySelectorAll('.tl-row[data-tab-id]').forEach(row => {
- row.style.cursor = 'pointer';
- row.addEventListener('click', (e) => {
- if (!e.target.closest('.collapse-toggle')) {
- const clickTabId = parseInt(row.dataset.tabId);
- openOrSwitchToTab(clickTabId);
- scrollToTimelineEvent(clickTabId, row.dataset.eventIndex);
- }
- });
- });
-
- sidebarContent.querySelectorAll('.agent-box[data-tab-id]').forEach(box => {
- box.style.cursor = 'pointer';
- box.addEventListener('click', (e) => {
- e.stopPropagation(); // Prevent double-firing from parent tl-row
- const clickTabId = parseInt(box.dataset.tabId);
- openOrSwitchToTab(clickTabId);
- });
- });
-
- // Add click handler to workspace block header
- sidebarContent.querySelectorAll('.tl-widget[data-tab-id]').forEach(widget => {
- const workspaceBlock = widget.querySelector('.workspace-block');
- if (workspaceBlock) {
- workspaceBlock.style.cursor = 'pointer';
- workspaceBlock.addEventListener('click', (e) => {
- e.stopPropagation();
- const clickTabId = parseInt(widget.dataset.tabId);
- openOrSwitchToTab(clickTabId);
- });
- }
- });
-
- sidebarContent.querySelectorAll('.collapse-toggle').forEach(toggle => {
- toggle.addEventListener('click', (e) => {
- e.stopPropagation();
- // Toggle is now in .tl-row.has-agent, find the sibling .tl-nested
- const row = toggle.closest('.tl-row.has-agent');
- if (row) {
- const nested = row.nextElementSibling;
- if (nested && nested.classList.contains('tl-nested')) {
- const childTabId = nested.dataset.childTabId;
- nested.classList.toggle('collapsed');
- toggle.classList.toggle('collapsed');
- // Track collapsed state
- if (childTabId) {
- if (nested.classList.contains('collapsed')) {
- collapsedAgents.add(childTabId);
- } else {
- collapsedAgents.delete(childTabId);
- }
- }
- updateTimelineLines();
- }
- }
- });
- });
-}
-
-// Render a single agent's timeline (recursive for nested)
-function renderAgentTimeline(tabId, notebook, isNested = false) {
- const isActive = activeTabId === tabId;
- const isClosed = notebook.isClosed || false;
- const typeLabel = getTypeLabel(notebook.type);
-
- let html = `
`;
-
- return html;
-}
-
-// Update timeline line heights and return line positions
-function updateTimelineLines() {
- document.querySelectorAll('.tl').forEach(tl => {
- const rows = Array.from(tl.children).filter(el => el.classList.contains('tl-row'));
- if (rows.length < 1) return;
-
- const firstRow = rows[0];
- const firstDot = firstRow.querySelector('.tl-dot');
- if (!firstDot) return;
-
- const lastRow = rows[rows.length - 1];
- const lastDot = lastRow.querySelector('.tl-dot');
- if (!lastDot) return;
-
- const tlRect = tl.getBoundingClientRect();
- const firstDotRect = firstDot.getBoundingClientRect();
- const lastDotRect = lastDot.getBoundingClientRect();
-
- const dotOffset = 2;
- const isNested = tl.closest('.tl-nested') !== null;
- const lineTop = isNested ? -6 : (firstDotRect.top - tlRect.top + dotOffset);
- const lineBottom = lastDotRect.top - tlRect.top + dotOffset;
-
- tl.style.setProperty('--line-top', lineTop + 'px');
- tl.style.setProperty('--line-height', (lineBottom - lineTop) + 'px');
- });
-
- // Position return rows to align with last nested dot or agent-box
- document.querySelectorAll('.tl-nested').forEach(nested => {
- const returnRow = nested.nextElementSibling;
- if (!returnRow || !returnRow.classList.contains('tl-return')) return;
-
- const isCollapsed = nested.classList.contains('collapsed');
- const connector = returnRow.querySelector('.tl-return-connector');
- const returnDot = returnRow.querySelector('.tl-dot');
- if (!returnDot) return;
-
- // Reset position first to get accurate baseline measurement
- returnRow.style.top = '0';
-
- // Find the agent-box in the previous sibling row
- const agentRow = nested.previousElementSibling;
- const agentBox = agentRow?.querySelector('.agent-box');
-
- if (isCollapsed && agentBox) {
- // When collapsed: align return dot with bottom of agent-box
- const agentBoxRect = agentBox.getBoundingClientRect();
- const returnDotRect = returnDot.getBoundingClientRect();
-
- // Align return dot's top with agent-box bottom
- const yOffset = agentBoxRect.bottom - returnDotRect.top - 3;
- returnRow.style.top = yOffset + 'px';
-
- if (connector) {
- // Connector goes from return dot to agent-box
- const connectorWidth = agentBoxRect.left - returnDotRect.right;
- connector.style.width = Math.max(0, connectorWidth) + 'px';
- connector.style.background = 'var(--theme-accent)';
- }
- } else {
- // When expanded: align return dot with last nested dot
- const nestedTl = nested.querySelector('.tl');
- if (!nestedTl) return;
-
- const nestedRows = Array.from(nestedTl.children).filter(el => el.classList.contains('tl-row'));
- if (nestedRows.length === 0) return;
-
- const lastNestedRow = nestedRows[nestedRows.length - 1];
- const lastNestedDot = lastNestedRow.querySelector('.tl-dot');
-
- if (lastNestedDot && returnDot) {
- const lastNestedRect = lastNestedDot.getBoundingClientRect();
- const returnDotRect = returnDot.getBoundingClientRect();
-
- // Calculate offset to align Y positions
- const yOffset = lastNestedRect.top - returnDotRect.top;
- returnRow.style.top = yOffset + 'px';
-
- // Connector width: from return dot to nested timeline's vertical line
- if (connector) {
- const nestedTlRect = nestedTl.getBoundingClientRect();
- // Nested vertical line is at left: 2px from nested .tl
- const nestedLineX = nestedTlRect.left + 2;
- const connectorWidth = nestedLineX - returnDotRect.right;
-
- connector.style.width = Math.max(0, connectorWidth) + 'px';
- connector.style.background = 'var(--border-primary)';
- }
- }
- }
- });
-}
-
-let IS_MULTI_USER = false;
-
-function showUsernameOverlay() {
- return new Promise(resolve => {
- const overlay = document.getElementById('usernameOverlay');
- const input = document.getElementById('usernameInput');
- const submit = document.getElementById('usernameSubmit');
- const warning = document.getElementById('usernameWarning');
- if (!overlay) { resolve(); return; }
- overlay.style.display = 'flex';
- input.value = '';
- warning.style.display = 'none';
- input.focus();
-
- // Check if username exists on input change (debounced)
- let checkTimeout;
- const checkExists = async () => {
- const name = sanitizeUsername(input.value);
- if (!name) { warning.style.display = 'none'; return; }
- try {
- const resp = await fetch(`/api/user/exists/${encodeURIComponent(name)}`);
- const data = await resp.json();
- if (data.exists) {
- warning.textContent = `"${name}" already has a workspace — you'll share it`;
- warning.style.display = 'block';
- } else {
- warning.style.display = 'none';
- }
- } catch { warning.style.display = 'none'; }
- };
- input.oninput = () => { clearTimeout(checkTimeout); checkTimeout = setTimeout(checkExists, 300); };
-
- const doSubmit = () => {
- const name = sanitizeUsername(input.value);
- if (!name) return;
- SESSION_ID = name;
- localStorage.setItem('agentui_username', name);
- overlay.style.display = 'none';
- updateUserIndicator();
- input.oninput = null;
- resolve();
- };
- submit.onclick = doSubmit;
- input.onkeydown = (e) => { if (e.key === 'Enter') doSubmit(); };
- });
-}
-
-function updateUserIndicator() {
- const indicator = document.getElementById('userIndicator');
- const nameEl = document.getElementById('userIndicatorName');
- if (!indicator || !nameEl) return;
- if (IS_MULTI_USER && SESSION_ID) {
- nameEl.textContent = SESSION_ID;
- indicator.title = 'Click to switch user';
- indicator.style.display = 'flex';
- indicator.onclick = async () => {
- await showUsernameOverlay();
- window.location.reload();
- };
- } else {
- indicator.style.display = 'none';
- }
-}
-
-// Initialize
-document.addEventListener('DOMContentLoaded', async () => {
- // Check if multi-user mode is enabled (retry if server not ready yet)
- for (let attempt = 0; attempt < 5; attempt++) {
- try {
- const configResp = await fetch('/api/config');
- const config = await configResp.json();
- IS_MULTI_USER = config.multiUser;
- if (config.multiUser && !SESSION_ID) {
- await showUsernameOverlay();
- }
- break;
- } catch (e) {
- if (attempt < 4) {
- await new Promise(r => setTimeout(r, 1000));
- }
- // After all retries fail, continue in single-user mode
- }
- }
-
- // Fetch agent registry from backend (single source of truth)
- try {
- const agentsResp = await apiFetch('/api/agents');
- const agentsData = await agentsResp.json();
- for (const agent of agentsData.agents) {
- AGENT_REGISTRY[agent.key] = agent;
- }
- } catch (e) {
- console.error('Failed to fetch agent registry, using fallback');
- // Minimal fallback so the app still works if backend is slow
- AGENT_REGISTRY = {
- command: { label: 'MAIN', hasCounter: false, inMenu: false, inLauncher: false, placeholder: 'Enter message...' },
- agent: { label: 'AGENT', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
- code: { label: 'CODE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
- research: { label: 'RESEARCH', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
- image: { label: 'IMAGE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Describe an image or paste a URL...' },
- };
- }
- // Initialize settings.agents with registry keys (before loadSettings merges saved values)
- settings.agents = Object.fromEntries(Object.keys(AGENT_REGISTRY).map(k => [k, '']));
-
- await loadSettings();
- applyTheme(settings.themeColor || 'forest');
- initializeEventListeners();
- initializeSessionListeners();
- updateUserIndicator();
-
- // Always show session selector on load (don't auto-resume last session)
- const sessionsData = await fetchSessions();
- showSessionSelector(sessionsData.sessions);
-});
-
-// ============================================================
-// Dynamic HTML generation from AGENT_REGISTRY
-// ============================================================
-
-function renderNewTabMenu() {
- const menu = document.getElementById('newTabMenu');
- if (!menu) return;
- menu.innerHTML = '';
- for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
- if (!agent.inMenu) continue;
- const item = document.createElement('div');
- item.className = 'menu-item';
- item.dataset.type = key;
- item.textContent = agent.label;
- menu.appendChild(item);
- }
-}
-
-function renderLauncherButtons() {
- const container = document.getElementById('launcherButtons');
- if (!container) return;
- // Insert before the debug button (keep it at the end)
- const debugBtn = container.querySelector('#debugBtn');
- for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
- if (!agent.inLauncher) continue;
- const btn = document.createElement('button');
- btn.className = 'launcher-btn';
- btn.dataset.type = key;
- btn.textContent = agent.label;
- container.insertBefore(btn, debugBtn);
- }
-}
-
-function renderAgentModelSelectors() {
- const grid = document.getElementById('agentModelsGrid');
- if (!grid) return;
- grid.innerHTML = '';
- for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
- const label = document.createElement('label');
- label.textContent = `${agent.label}:`;
- const select = document.createElement('select');
- select.id = `setting-agent-${key}`;
- select.className = 'settings-select';
- grid.appendChild(label);
- grid.appendChild(select);
- }
-}
-
-function initializeEventListeners() {
- // Generate dynamic UI elements from registry
- renderLauncherButtons();
- renderNewTabMenu();
- renderAgentModelSelectors();
-
- // Launcher buttons in command center
- document.querySelectorAll('.launcher-btn').forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- const type = btn.dataset.type;
- createAgentTab(type);
- });
- });
-
- // Command center chat functionality
- const commandInput = document.getElementById('input-command');
- const sendCommand = document.getElementById('sendCommand');
- if (commandInput && sendCommand) {
- sendCommand.addEventListener('click', () => sendMessage(0));
-
- // Auto-resize textarea
- commandInput.addEventListener('input', () => {
- commandInput.style.height = 'auto';
- commandInput.style.height = Math.min(commandInput.scrollHeight, 200) + 'px';
- commandInput.style.overflowY = commandInput.scrollHeight > 200 ? 'auto' : 'hidden';
- });
-
- // Enter to send, Shift+Enter for newline
- commandInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendMessage(0);
- }
- });
- }
-
- // Sidebar checkboxes
- const compactViewCheckbox = document.getElementById('compactViewCheckbox');
- const collapseAgentsCheckbox = document.getElementById('collapseAgentsCheckbox');
- const collapseToolsCheckbox = document.getElementById('collapseToolsCheckbox');
-
- // Compact view: affects timeline only (collapse agents + hide turns)
- if (compactViewCheckbox) {
- compactViewCheckbox.addEventListener('change', () => {
- const compact = compactViewCheckbox.checked;
- if (compact) {
- // Collapse all agent boxes in timeline
- Object.entries(timelineData).forEach(([id, data]) => {
- if (data.parentTabId !== null) {
- collapsedAgents.add(String(id));
- }
- });
- showAllTurns = false;
- } else {
- collapsedAgents.clear();
- showAllTurns = true;
- }
- renderTimeline();
- });
- }
-
- // Chat collapse: agents — affects action-widgets in chat only
- if (collapseAgentsCheckbox) {
- collapseAgentsCheckbox.addEventListener('change', () => {
- const collapse = collapseAgentsCheckbox.checked;
- document.querySelectorAll('.action-widget').forEach(w => {
- const toggle = w.querySelector('.widget-collapse-toggle');
- if (collapse) {
- w.classList.add('collapsed');
- if (toggle) toggle.classList.add('collapsed');
- } else {
- w.classList.remove('collapsed');
- if (toggle) toggle.classList.remove('collapsed');
- }
- });
- });
- }
-
- // Chat collapse: tools — affects tool-cells in chat only
- if (collapseToolsCheckbox) {
- collapseToolsCheckbox.addEventListener('change', () => {
- const collapse = collapseToolsCheckbox.checked;
- document.querySelectorAll('.tool-cell').forEach(w => {
- const toggle = w.querySelector('.widget-collapse-toggle');
- if (collapse) {
- w.classList.add('collapsed');
- if (toggle) toggle.classList.add('collapsed');
- } else {
- w.classList.remove('collapsed');
- if (toggle) toggle.classList.remove('collapsed');
- }
- });
- });
- }
-
- // New tab button - toggle menu
- const newTabBtn = document.getElementById('newTabBtn');
- const newTabMenu = document.getElementById('newTabMenu');
-
- if (newTabBtn && newTabMenu) {
- newTabBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- e.preventDefault();
-
- // Position the menu below the button
- const rect = newTabBtn.getBoundingClientRect();
- newTabMenu.style.top = `${rect.bottom}px`;
- newTabMenu.style.left = `${rect.left}px`;
-
- newTabMenu.classList.toggle('active');
- });
- }
-
- // Close menu when clicking outside
- document.addEventListener('click', (e) => {
- if (!e.target.closest('.new-tab-wrapper')) {
- newTabMenu.classList.remove('active');
- }
- });
-
- // Menu items
- document.querySelectorAll('.menu-item').forEach(item => {
- item.addEventListener('click', () => {
- const type = item.dataset.type;
- createAgentTab(type);
- newTabMenu.classList.remove('active');
- });
- });
-
- // Tab switching
- document.addEventListener('click', (e) => {
- const tab = e.target.closest('.tab');
- if (tab && !e.target.closest('.new-tab-wrapper')) {
- if (e.target.classList.contains('tab-close')) {
- closeTab(parseInt(tab.dataset.tabId));
- } else {
- switchToTab(parseInt(tab.dataset.tabId));
- }
- }
- });
-
- // Settings button - now handled in settings panel section below
- // (removed old openSettings() call)
-
- // Save settings button
- const saveSettingsBtn = document.getElementById('saveSettingsBtn');
- if (saveSettingsBtn) {
- saveSettingsBtn.addEventListener('click', () => {
- saveSettings();
- });
- }
-
- // Cancel settings button
- const cancelSettingsBtn = document.getElementById('cancelSettingsBtn');
- if (cancelSettingsBtn) {
- cancelSettingsBtn.addEventListener('click', () => {
- const settingsPanel = document.getElementById('settingsPanel');
- const settingsBtn = document.getElementById('settingsBtn');
- const appContainer = document.querySelector('.app-container');
- if (settingsPanel) settingsPanel.classList.remove('active');
- if (settingsBtn) settingsBtn.classList.remove('active');
- if (appContainer) appContainer.classList.remove('panel-open');
- });
- }
-
- // Theme color picker
- const themePicker = document.getElementById('theme-color-picker');
- if (themePicker) {
- themePicker.addEventListener('click', (e) => {
- const themeOption = e.target.closest('.theme-option');
- if (themeOption) {
- // Remove selected from all
- themePicker.querySelectorAll('.theme-option').forEach(opt => {
- opt.classList.remove('selected');
- });
- // Add selected to clicked
- themeOption.classList.add('selected');
- // Update hidden input
- const themeValue = themeOption.dataset.theme;
- document.getElementById('setting-theme-color').value = themeValue;
- }
- });
- }
-
- // Double-click on collapse toggle: collapse/uncollapse all widgets in the same chat
- document.addEventListener('dblclick', (e) => {
- const toggle = e.target.closest('.widget-collapse-toggle');
- if (!toggle) return;
- e.stopPropagation();
- const chatContainer = toggle.closest('.chat-container');
- if (!chatContainer) return;
- const widgets = chatContainer.querySelectorAll('.tool-cell, .code-cell, .action-widget');
- if (widgets.length === 0) return;
- // If clicked widget is now collapsed, collapse all; otherwise uncollapse all
- const clickedWidget = toggle.closest('.tool-cell, .code-cell, .action-widget');
- const shouldCollapse = !clickedWidget?.classList.contains('collapsed');
- widgets.forEach(w => {
- w.classList.toggle('collapsed', shouldCollapse);
- const t = w.querySelector('.widget-collapse-toggle');
- if (t) t.classList.toggle('collapsed', shouldCollapse);
- });
- });
-
- // Resizable sidebar
- const sidebar = document.getElementById('agentsSidebar');
- const resizeHandle = document.getElementById('sidebarResizeHandle');
- if (sidebar && resizeHandle) {
- const savedWidth = localStorage.getItem('agentui_sidebar_width');
- if (savedWidth) {
- const w = parseInt(savedWidth);
- if (w >= 140 && w <= 400) {
- sidebar.style.setProperty('--sidebar-width', w + 'px');
- sidebar.style.width = w + 'px';
- }
- }
-
- let dragging = false;
- resizeHandle.addEventListener('mousedown', (e) => {
- e.preventDefault();
- dragging = true;
- resizeHandle.classList.add('dragging');
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- });
- document.addEventListener('mousemove', (e) => {
- if (!dragging) return;
- const w = Math.min(400, Math.max(140, e.clientX));
- sidebar.style.setProperty('--sidebar-width', w + 'px');
- sidebar.style.width = w + 'px';
- });
- document.addEventListener('mouseup', () => {
- if (!dragging) return;
- dragging = false;
- resizeHandle.classList.remove('dragging');
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- const w = parseInt(sidebar.style.width);
- if (w) localStorage.setItem('agentui_sidebar_width', w);
- });
- }
-}
-
-// ============================================
-// Session Management
-// ============================================
-
-async function fetchSessions() {
- try {
- const response = await apiFetch('/api/sessions');
- if (response.ok) {
- return await response.json();
- }
- } catch (e) {
- console.error('Failed to fetch sessions:', e);
- }
- return { sessions: [], current: null };
-}
-
-function showSessionSelector(sessions) {
- const selector = document.getElementById('sessionSelector');
- const welcome = document.getElementById('welcomeMessage');
- const sessionIndicator = document.getElementById('sessionIndicator');
- const inputArea = document.getElementById('commandInputArea');
-
- // Show welcome message and session selector
- if (welcome) welcome.style.display = 'block';
- if (selector) selector.style.display = 'block';
- if (sessionIndicator) sessionIndicator.style.display = 'none';
- if (inputArea) inputArea.style.display = 'none';
-
- // Populate existing sessions dropdown
- const select = document.getElementById('existingSessionSelect');
- if (select) {
- select.innerHTML = '';
- sessions.forEach(session => {
- const option = document.createElement('option');
- option.value = session.name;
- option.textContent = `${session.name} (${formatDate(session.modified)})`;
- select.appendChild(option);
- });
- }
-}
-
-function hideSessionSelector() {
- const selector = document.getElementById('sessionSelector');
- const welcome = document.getElementById('welcomeMessage');
- const sessionIndicator = document.getElementById('sessionIndicator');
- const inputArea = document.getElementById('commandInputArea');
-
- // Hide session selector, keep welcome visible, show session indicator and input
- if (selector) selector.style.display = 'none';
- if (welcome) welcome.style.display = 'block';
- if (sessionIndicator) sessionIndicator.style.display = 'block';
- if (inputArea) inputArea.style.display = 'block';
-}
-
-async function onSessionSelected(sessionName) {
- currentSession = sessionName;
- hideSessionSelector();
-
- // Update session name display
- const nameEl = document.getElementById('currentSessionName');
- if (nameEl) nameEl.textContent = sessionName;
-
- const renameInput = document.getElementById('currentSessionRename');
- if (renameInput) renameInput.value = sessionName;
-
- // Update timeline title to show session name
- if (timelineData[0]) {
- timelineData[0].title = sessionName;
- renderTimeline();
- }
-
- // Load workspace for this session
- await loadWorkspace();
-
- // Refresh sessions panel list
- await refreshSessionsList();
-}
-
-async function createSession(name) {
- try {
- const response = await apiFetch('/api/sessions', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name })
- });
- if (response.ok) {
- const data = await response.json();
- await onSessionSelected(data.name);
- return true;
- } else {
- const error = await response.json();
- alert(error.detail || 'Failed to create session');
- }
- } catch (e) {
- console.error('Failed to create session:', e);
- alert('Failed to create session');
- }
- return false;
-}
-
-async function selectSession(name) {
- try {
- const response = await apiFetch('/api/sessions/select', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name })
- });
- if (response.ok) {
- // Reset local state and load the selected session
- resetLocalState();
- await onSessionSelected(name);
- return true;
- } else {
- const error = await response.json();
- alert(error.detail || 'Failed to select session');
- }
- } catch (e) {
- console.error('Failed to select session:', e);
- alert('Failed to select session');
- }
- return false;
-}
-
-async function renameSession(oldName, newName) {
- try {
- const response = await apiFetch('/api/sessions/rename', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ oldName, newName })
- });
- if (response.ok) {
- const data = await response.json();
- currentSession = data.name;
-
- // Update displays
- const nameEl = document.getElementById('currentSessionName');
- if (nameEl) nameEl.textContent = data.name;
-
- // Update timeline title
- if (timelineData[0]) {
- timelineData[0].title = data.name;
- renderTimeline();
- }
-
- await refreshSessionsList();
- return true;
- } else {
- const error = await response.json();
- alert(error.detail || 'Failed to rename session');
- }
- } catch (e) {
- console.error('Failed to rename session:', e);
- alert('Failed to rename session');
- }
- return false;
-}
-
-function openSessionsPanel() {
- const sessionsBtn = document.getElementById('sessionsBtn');
- if (sessionsBtn) {
- sessionsBtn.click();
- }
-}
-
-async function refreshSessionsList() {
- const sessionsData = await fetchSessions();
- const listEl = document.getElementById('sessionsList');
-
- // Update the current session rename input
- const renameInput = document.getElementById('currentSessionRename');
- if (renameInput && currentSession) {
- renameInput.value = currentSession;
- }
-
- if (!listEl) return;
-
- if (sessionsData.sessions.length === 0) {
- listEl.innerHTML = 'No other sessions
';
- return;
- }
-
- listEl.innerHTML = '';
- sessionsData.sessions.forEach(session => {
- const item = document.createElement('div');
- item.className = 'sessions-list-item' + (session.name === currentSession ? ' current' : '');
-
- const isCurrent = session.name === currentSession;
- item.innerHTML = `
- ${escapeHtml(session.name)}
- ${formatDate(session.modified)}
- ${!isCurrent ? `` : ''}
- `;
-
- if (!isCurrent) {
- // Click on name/date to select
- item.querySelector('.sessions-list-item-name').addEventListener('click', () => selectSession(session.name));
- item.querySelector('.sessions-list-item-date').addEventListener('click', () => selectSession(session.name));
-
- // Click on delete button to delete
- const deleteBtn = item.querySelector('.sessions-delete-btn');
- if (deleteBtn) {
- deleteBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- deleteSession(session.name);
- });
- }
- }
-
- listEl.appendChild(item);
- });
-}
-
-async function deleteSession(sessionName) {
- if (!confirm(`Delete session "${sessionName}"? This cannot be undone.`)) {
- return;
- }
-
- try {
- const response = await apiFetch(`/api/sessions/${encodeURIComponent(sessionName)}`, {
- method: 'DELETE'
- });
-
- if (!response.ok) {
- try {
- const error = await response.json();
- alert(error.detail || 'Failed to delete session');
- } catch {
- alert('Failed to delete session');
- }
- return;
- }
-
- // Refresh the sessions list in the panel
- refreshSessionsList();
-
- // Also refresh the welcome page dropdown
- const sessionsData = await fetchSessions();
- const select = document.getElementById('existingSessionSelect');
- if (select) {
- select.innerHTML = '';
- sessionsData.sessions.forEach(session => {
- const option = document.createElement('option');
- option.value = session.name;
- option.textContent = `${session.name} (${formatDate(session.modified)})`;
- select.appendChild(option);
- });
- }
- } catch (error) {
- console.error('Error deleting session:', error);
- alert('Failed to delete session');
- }
-}
-
-function formatDate(timestamp) {
- const date = new Date(timestamp * 1000);
- const now = new Date();
- const diff = now - date;
-
- if (diff < 60000) return 'just now';
- if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
- if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
- if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
-
- return date.toLocaleDateString();
-}
-
-function initializeSessionListeners() {
- // Welcome page session selector
- const createBtn = document.getElementById('createSessionBtn');
- const newNameInput = document.getElementById('newSessionName');
- const existingSelect = document.getElementById('existingSessionSelect');
-
- // Pre-populate with a cool random name
- if (newNameInput) {
- generateSessionName().then(name => newNameInput.value = name);
- }
-
- // Regenerate button
- const regenerateBtn = document.getElementById('regenerateNameBtn');
- if (regenerateBtn && newNameInput) {
- regenerateBtn.addEventListener('click', async () => {
- newNameInput.value = await generateSessionName();
- });
- }
-
- if (createBtn) {
- createBtn.addEventListener('click', async () => {
- const name = newNameInput?.value.trim();
- if (name) {
- createSession(name);
- } else {
- // Auto-generate name
- createSession(await generateSessionName());
- }
- });
- }
-
- if (newNameInput) {
- newNameInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- createBtn?.click();
- }
- });
- }
-
- if (existingSelect) {
- existingSelect.addEventListener('change', () => {
- const name = existingSelect.value;
- if (name) {
- selectSession(name);
- }
- });
- }
-
- // Sessions panel handlers are set up at the end of the file with other panels
-
- // Panel rename button
- const renameBtn = document.getElementById('renameSessionBtn');
- const renameInput = document.getElementById('currentSessionRename');
-
- if (renameBtn && renameInput) {
- renameBtn.addEventListener('click', () => {
- const newName = renameInput.value.trim();
- if (newName && newName !== currentSession) {
- renameSession(currentSession, newName);
- }
- });
- }
-
- // Panel create new session
- const panelCreateBtn = document.getElementById('panelCreateSessionBtn');
- const panelNewNameInput = document.getElementById('panelNewSessionName');
- const panelRegenerateBtn = document.getElementById('panelRegenerateNameBtn');
-
- // Pre-populate panel input with cool name too
- if (panelNewNameInput) {
- generateSessionName().then(name => panelNewNameInput.value = name);
- }
-
- // Panel regenerate button
- if (panelRegenerateBtn && panelNewNameInput) {
- panelRegenerateBtn.addEventListener('click', async () => {
- panelNewNameInput.value = await generateSessionName();
- });
- }
-
- if (panelCreateBtn) {
- panelCreateBtn.addEventListener('click', async () => {
- const name = panelNewNameInput?.value.trim();
- if (name) {
- resetLocalState();
- await createSession(name);
- // Pre-populate a new name for next time
- if (panelNewNameInput) {
- panelNewNameInput.value = await generateSessionName();
- }
- }
- });
- }
-}
-
-function createAgentTab(type, initialMessage = null, autoSwitch = true, taskId = null, parentTabId = null) {
- const tabId = tabCounter++;
-
- // Use task_id if provided, otherwise generate default title
- let title;
- if (taskId) {
- // Convert dashes to spaces and title case for display
- title = taskId;
- // Register this agent for task_id reuse
- taskIdToTabId[taskId] = tabId;
- } else if (type !== 'command-center') {
- agentCounters[type]++;
- title = `New ${type} task`;
- } else {
- title = getTypeLabel(type);
- }
-
- // Register in timeline
- registerAgentInTimeline(tabId, type, title, parentTabId);
-
- // Create tab
- const tab = document.createElement('div');
- tab.className = 'tab';
- tab.dataset.tabId = tabId;
- tab.innerHTML = `
- ${title}
-
- ×
- `;
-
- // Insert into the dynamic tabs container
- const dynamicTabs = document.getElementById('dynamicTabs');
- dynamicTabs.appendChild(tab);
-
- // Hide tab for subagents until the user clicks on them
- if (!autoSwitch && parentTabId !== null) {
- tab.style.display = 'none';
- }
-
- // Create content
- const content = document.createElement('div');
- content.className = 'tab-content';
- content.dataset.contentId = tabId;
- content.innerHTML = createAgentContent(type, tabId, title);
-
- document.querySelector('.main-content').appendChild(content);
-
- // Only switch to new tab if autoSwitch is true
- if (autoSwitch) {
- switchToTab(tabId);
- }
-
- // Add event listeners for the new content
- if (type !== 'command-center') {
- const input = content.querySelector('textarea');
- const sendBtn = content.querySelector('.input-container button');
-
- if (input && sendBtn) {
- sendBtn.addEventListener('click', () => sendMessage(tabId));
-
- // Auto-resize textarea
- input.addEventListener('input', () => {
- input.style.height = 'auto';
- input.style.height = Math.min(input.scrollHeight, 200) + 'px';
- input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
- });
-
- // Enter to send, Shift+Enter for newline
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendMessage(tabId);
- }
- });
- }
-
- // If this is a code agent, start the sandbox proactively
- if (type === 'code') {
- startSandbox(tabId);
- }
-
- // If there's an initial message, automatically send it
- if (initialMessage && input) {
- input.value = initialMessage;
- // Small delay to ensure everything is set up
- setTimeout(() => {
- sendMessage(tabId);
- }, 100);
- }
- }
-
- // Save workspace state after creating new tab
- saveWorkspaceDebounced();
-
- return tabId; // Return the tabId so we can reference it
-}
-
-function createAgentContent(type, tabId, title = null) {
- if (type === 'command-center') {
- return document.querySelector('[data-content-id="0"]').innerHTML;
- }
-
- // Use unique ID combining type and tabId to ensure unique container IDs
- const uniqueId = `${type}-${tabId}`;
-
- // Display title or default
- const displayTitle = title || `New ${type} task`;
-
- return `
-
- `;
-}
-
-function switchToTab(tabId) {
- // Deactivate all tabs
- document.querySelectorAll('.tab').forEach(tab => {
- tab.classList.remove('active');
- });
- document.querySelectorAll('.tab-content').forEach(content => {
- content.classList.remove('active');
- });
-
- // Activate selected tab (use .tab selector to avoid matching timeline elements)
- const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
- const content = document.querySelector(`.tab-content[data-content-id="${tabId}"]`);
-
- if (content) {
- // For settings, there's no tab, just show the content
- if (tabId === 'settings') {
- content.classList.add('active');
- activeTabId = tabId;
- } else if (tab) {
- tab.style.display = '';
- tab.classList.add('active');
- content.classList.add('active');
- activeTabId = tabId;
-
- // Save workspace state after switching tabs
- saveWorkspaceDebounced();
- }
- }
-
- // Update timeline to reflect active tab
- renderTimeline();
-}
-
-function closeTab(tabId) {
- if (tabId === 0) return; // Can't close command center
-
- const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
-
- if (tab && content) {
- // Check if this is a code agent and stop its sandbox
- const chatContainer = content.querySelector('.chat-container');
- const agentType = chatContainer?.dataset.agentType || 'chat';
-
- if (agentType === 'code') {
- stopSandbox(tabId);
- }
-
- // Save the tab's content BEFORE removing from DOM so we can restore it later
- if (timelineData[tabId]) {
- timelineData[tabId].savedContent = content.innerHTML;
- timelineData[tabId].savedTitle = tab.querySelector('.tab-title')?.textContent;
- }
-
- // Clean up task_id mapping
- for (const [taskId, mappedTabId] of Object.entries(taskIdToTabId)) {
- if (mappedTabId === tabId) {
- delete taskIdToTabId[taskId];
- break;
- }
- }
-
- tab.remove();
- content.remove();
-
- // Mark as closed in timeline (don't remove - allow reopening)
- if (timelineData[tabId]) {
- timelineData[tabId].isClosed = true;
- renderTimeline();
- }
-
- // Switch to command center if closing active tab
- if (activeTabId === tabId) {
- switchToTab(0);
- }
-
- // Save workspace state after closing tab
- saveWorkspaceDebounced();
- }
-}
-
-function showProgressWidget(chatContainer) {
- // Remove any existing progress widget
- hideProgressWidget(chatContainer);
-
- const widget = document.createElement('div');
- widget.className = 'progress-widget';
- widget.innerHTML = `
-
-
-
-
-
- Generating...
- `;
- chatContainer.appendChild(widget);
- scrollChatToBottom(chatContainer, true);
- return widget;
-}
-
-function hideProgressWidget(chatContainer) {
- const widget = chatContainer.querySelector('.progress-widget');
- if (widget) {
- widget.remove();
- }
-}
-
-function scrollChatToBottom(chatContainer, force = false) {
- // The actual scrolling container is .agent-body
- const agentBody = chatContainer.closest('.agent-body');
- if (!agentBody) return;
-
- // If not forced, only scroll if user is already near the bottom
- if (!force) {
- const distanceFromBottom = agentBody.scrollHeight - agentBody.scrollTop - agentBody.clientHeight;
- if (distanceFromBottom > 150) return;
- }
-
- agentBody.scrollTop = agentBody.scrollHeight;
-}
-
-function scrollToTimelineEvent(tabId, eventIndex) {
- if (eventIndex === undefined || eventIndex === null) return;
-
- // Find the tab's content area and chat container
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
- if (!content) return;
- const chatContainer = content.querySelector('.chat-container');
- if (!chatContainer) return;
-
- // Find the chat element tagged with this timeline index
- const target = chatContainer.querySelector(`[data-timeline-index="${eventIndex}"]`);
- if (!target) return;
-
- // Scroll the agent-body so the target is near the top
- const agentBody = chatContainer.closest('.agent-body');
- if (!agentBody) return;
-
- // Use a small delay to ensure tab switch has rendered
- requestAnimationFrame(() => {
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- });
-}
-
-async function abortAgent(tabId) {
- // Abort the frontend fetch
- const controller = activeAbortControllers[tabId];
- if (controller) {
- controller.abort();
- }
-
- // For command center (tab 0): also abort all generating child tabs
- if (tabId === 0) {
- for (const [childId, td] of Object.entries(timelineData)) {
- if (td.parentTabId === 0 && td.isGenerating) {
- // Abort frontend fetch
- const childController = activeAbortControllers[childId];
- if (childController) {
- childController.abort();
- }
- // Abort backend agent
- try {
- await apiFetch('/api/abort', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ agent_id: childId.toString() })
- });
- } catch (e) { /* ignore */ }
- }
- }
- // Unblock command center input and restore SEND button
- commandInputBlocked = false;
- pendingAgentLaunches = 0;
- setCommandCenterStopState(false);
- const commandInput = document.getElementById('input-command');
- if (commandInput) {
- commandInput.disabled = false;
- }
- }
-
- // Tell the backend to set the abort flag for this agent
- try {
- await apiFetch('/api/abort', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ agent_id: tabId.toString() })
- });
- } catch (e) {
- // Ignore abort endpoint errors
- }
-}
-
-async function sendMessage(tabId) {
- // If tab is currently generating, abort instead of sending
- // For command center: also abort if input is blocked (sub-agents still running)
- if (timelineData[tabId]?.isGenerating || (tabId === 0 && commandInputBlocked)) {
- abortAgent(tabId);
- return;
- }
-
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
- if (!content) return;
-
- // Support both textarea and input
- const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
- const chatContainer = content.querySelector('.chat-container');
-
- if (!input || !chatContainer) return;
-
- const message = input.value.trim();
- if (!message) return;
-
- // Remove welcome message if it exists (only on first user message)
- const welcomeMsg = chatContainer.querySelector('.welcome-message');
- const isFirstMessage = welcomeMsg !== null;
- if (welcomeMsg) {
- welcomeMsg.remove();
- }
-
- // Add user message
- const userMsg = document.createElement('div');
- userMsg.className = 'message user';
- userMsg.innerHTML = `${parseMarkdown(message.trim())}
`;
- linkifyFilePaths(userMsg);
- chatContainer.appendChild(userMsg);
-
- // Add to timeline and tag DOM element for scroll-to-turn
- const evIdx = addTimelineEvent(tabId, 'user', message);
- userMsg.dataset.timelineIndex = evIdx;
-
- // Scroll the agent body (the actual scrolling container) to bottom
- const agentBody = chatContainer.closest('.agent-body');
- if (agentBody) {
- agentBody.scrollTop = agentBody.scrollHeight;
- }
-
- // Show progress widget while waiting for response
- showProgressWidget(chatContainer);
-
- // Generate a title for the agent if this is the first message and not command center
- if (isFirstMessage && tabId !== 0) {
- generateAgentTitle(tabId, message);
- }
-
- // Clear input and disable it during processing
- input.value = '';
- input.style.height = 'auto'; // Reset textarea height
- input.disabled = true;
-
- // Set tab to generating state
- setTabGenerating(tabId, true);
-
- // Determine agent type from chat container ID
- const agentType = getAgentTypeFromContainer(chatContainer);
-
- // Send full conversation history for all agent types (stateless backend)
- const messages = getConversationHistory(chatContainer);
-
- // Stream response from backend
- await streamChatResponse(messages, chatContainer, agentType, tabId);
-
- // Re-enable input and mark generation as complete
- setTabGenerating(tabId, false);
-
- if (tabId === 0) {
- // Command center: keep input blocked if launched agents are still running or pending
- const anyChildGenerating = Object.values(timelineData).some(
- td => td.parentTabId === 0 && td.isGenerating
- );
- if (anyChildGenerating || pendingAgentLaunches > 0) {
- commandInputBlocked = true;
- // Keep STOP button visible and input disabled while children run
- input.disabled = true;
- setCommandCenterStopState(true);
- } else {
- input.disabled = false;
- input.focus();
- }
- } else {
- input.disabled = false;
- input.focus();
- }
-
- // Save workspace state after message exchange completes
- saveWorkspaceDebounced();
-}
-
-function setCommandCenterStopState(showStop) {
- const content = document.querySelector('[data-content-id="0"]');
- if (!content) return;
- const sendBtn = content.querySelector('.input-container button');
- if (!sendBtn) return;
- if (showStop) {
- sendBtn.textContent = 'STOP';
- sendBtn.classList.add('stop-btn');
- sendBtn.disabled = false;
- } else {
- sendBtn.textContent = 'SEND';
- sendBtn.classList.remove('stop-btn');
- }
-}
-
-async function continueCommandCenter() {
- // Called when all launched agents finish — re-run command center with actual results in history
- const chatContainer = document.getElementById('messages-command');
- const commandInput = document.getElementById('input-command');
- if (!chatContainer) return;
-
- setTabGenerating(0, true);
-
- const messages = getConversationHistory(chatContainer);
- await streamChatResponse(messages, chatContainer, 'command', 0);
-
- setTabGenerating(0, false);
-
- // Check if new agents were launched (recursive blocking)
- const anyChildGenerating = Object.values(timelineData).some(
- td => td.parentTabId === 0 && td.isGenerating
- );
- if (anyChildGenerating || pendingAgentLaunches > 0) {
- commandInputBlocked = true;
- if (commandInput) commandInput.disabled = true;
- setCommandCenterStopState(true);
- } else if (commandInput) {
- commandInput.disabled = false;
- commandInput.focus();
- }
-
- saveWorkspaceDebounced();
-}
-
-async function generateAgentTitle(tabId, query) {
- const currentSettings = getSettings();
- const backendEndpoint = '/api';
- const llmEndpoint = currentSettings.endpoint || 'https://api.openai.com/v1';
- const modelToUse = currentSettings.model || 'gpt-4';
-
- try {
- const response = await apiFetch(`${backendEndpoint}/generate-title`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- query: query,
- endpoint: llmEndpoint,
- token: currentSettings.token || null,
- model: modelToUse
- })
- });
-
- if (response.ok) {
- const result = await response.json();
- const title = result.title;
-
- // Update the tab title
- const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
- if (tab) {
- const titleEl = tab.querySelector('.tab-title');
- if (titleEl) {
- titleEl.textContent = title.toUpperCase();
- // Save workspace after title update
- saveWorkspaceDebounced();
- // Update timeline to reflect new title
- updateTimelineTitle(tabId, title.toUpperCase());
- }
- }
- }
- } catch (error) {
- console.error('Failed to generate title:', error);
- // Don't show error to user, just keep the default title
- }
-}
-
-function getAgentTypeFromContainer(chatContainer) {
- // Try to get type from data attribute first (for dynamically created agents)
- const typeFromData = chatContainer.dataset.agentType;
- if (typeFromData) {
- return typeFromData;
- }
-
- // Fallback: Extract agent type from the container ID (e.g., "messages-command" -> "command")
- const containerId = chatContainer.id;
- if (containerId && containerId.startsWith('messages-')) {
- const type = containerId.replace('messages-', '');
- // Map to agent type
- if (type === 'command') return 'command';
- if (type.startsWith('agent')) return 'agent';
- if (type.startsWith('code')) return 'code';
- if (type.startsWith('research')) return 'research';
- if (type.startsWith('chat')) return 'chat';
- }
- return 'command'; // Default fallback
-}
-
-function getConversationHistory(chatContainer) {
- const messages = [];
- const messageElements = chatContainer.querySelectorAll('.message');
-
- messageElements.forEach(msg => {
- if (msg.classList.contains('user')) {
- // User messages use .message-content
- const contentEl = msg.querySelector('.message-content');
- const content = contentEl?.textContent.trim() || msg.textContent.trim();
- if (content) {
- messages.push({ role: 'user', content: content });
- }
- } else if (msg.classList.contains('assistant')) {
- // Assistant messages use .message-content
- const contentEl = msg.querySelector('.message-content');
- const content = contentEl?.textContent.trim();
-
- // Check if this message has a tool call
- const toolCallData = msg.getAttribute('data-tool-call');
- if (toolCallData) {
- const toolCall = JSON.parse(toolCallData);
- let funcName, funcArgs;
-
- if (toolCall.function_name) {
- // Agent-style tool call (web_search, read_url, etc.)
- funcName = toolCall.function_name;
- funcArgs = toolCall.arguments;
- } else {
- // Command center-style tool call (launch_*_agent)
- funcName = `launch_${toolCall.agent_type}_agent`;
- funcArgs = JSON.stringify({
- task: toolCall.message,
- topic: toolCall.message,
- message: toolCall.message
- });
- }
-
- messages.push({
- role: 'assistant',
- content: toolCall.thinking || content || '',
- tool_calls: [{
- id: toolCall.tool_call_id || 'tool_' + Date.now(),
- type: 'function',
- function: {
- name: funcName,
- arguments: funcArgs
- }
- }]
- });
- } else if (content && !content.includes('msg-') && !content.includes('→ Launched')) {
- // Regular assistant message (exclude launch notifications)
- messages.push({ role: 'assistant', content: content });
- }
- } else if (msg.classList.contains('tool')) {
- // Tool response message
- const toolResponseData = msg.getAttribute('data-tool-response');
- if (toolResponseData) {
- const toolResponse = JSON.parse(toolResponseData);
- messages.push({
- role: 'tool',
- tool_call_id: toolResponse.tool_call_id,
- content: toolResponse.content
- });
- }
- }
- });
-
- return messages;
-}
-
-async function streamChatResponse(messages, chatContainer, agentType, tabId) {
- const currentSettings = getSettings();
- const backendEndpoint = '/api';
-
- // Resolve model configuration for this agent type
- let modelConfig = resolveModelConfig(agentType);
- if (!modelConfig) {
- modelConfig = getDefaultModelConfig();
- }
-
- if (!modelConfig) {
- // No models configured - show error
- const errorDiv = document.createElement('div');
- errorDiv.className = 'message assistant error';
- errorDiv.innerHTML = `No models configured. Please open Settings and add a provider and model.
`;
- chatContainer.appendChild(errorDiv);
- return;
- }
-
- // Resolve research sub-agent model if configured
- let researchSubAgentConfig = null;
- if (currentSettings.researchSubAgentModel) {
- const subAgentModel = currentSettings.models?.[currentSettings.researchSubAgentModel];
- if (subAgentModel) {
- const subAgentProvider = currentSettings.providers?.[subAgentModel.providerId];
- if (subAgentProvider) {
- researchSubAgentConfig = {
- endpoint: subAgentProvider.endpoint,
- token: subAgentProvider.token,
- model: subAgentModel.modelId
- };
- }
- }
- }
-
- // Resolve image model selections to HF model ID strings
- const imageGenModelId = currentSettings.imageGenModel
- ? currentSettings.models?.[currentSettings.imageGenModel]?.modelId || null
- : null;
- const imageEditModelId = currentSettings.imageEditModel
- ? currentSettings.models?.[currentSettings.imageEditModel]?.modelId || null
- : null;
-
- // Set up AbortController for this request
- const abortController = new AbortController();
- if (tabId !== undefined) {
- activeAbortControllers[tabId] = abortController;
- }
-
- try {
- // Determine parent agent ID for abort propagation
- const parentAgentId = timelineData[tabId]?.parentTabId != null
- ? timelineData[tabId].parentTabId.toString()
- : null;
-
- const response = await apiFetch(`${backendEndpoint}/chat/stream`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- signal: abortController.signal,
- body: JSON.stringify({
- messages: messages,
- agent_type: agentType,
- stream: true,
- endpoint: modelConfig.endpoint,
- token: modelConfig.token || null,
- model: modelConfig.model,
- extra_params: modelConfig.extraParams || null,
- multimodal: modelConfig.multimodal || false,
- e2b_key: currentSettings.e2bKey || null,
- serper_key: currentSettings.serperKey || null,
- hf_token: currentSettings.hfToken || null,
- image_gen_model: imageGenModelId,
- image_edit_model: imageEditModelId,
- research_sub_agent_model: researchSubAgentConfig?.model || null,
- research_sub_agent_endpoint: researchSubAgentConfig?.endpoint || null,
- research_sub_agent_token: researchSubAgentConfig?.token || null,
- research_sub_agent_extra_params: researchSubAgentConfig?.extraParams || null,
- research_parallel_workers: currentSettings.researchParallelWorkers || null,
- research_max_websites: currentSettings.researchMaxWebsites || null,
- agent_id: tabId.toString(), // Send unique tab ID for sandbox sessions
- parent_agent_id: parentAgentId, // Parent agent ID for abort propagation
- frontend_context: getFrontendContext() // Dynamic context for system prompts
- })
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- let fullResponse = '';
- let currentMessageEl = null;
- let progressHidden = false;
-
- // Flush any accumulated assistant text to the timeline
- // Call before adding tool/code/report timeline events to preserve ordering
- function flushResponseToTimeline() {
- if (fullResponse && tabId !== undefined) {
- const evIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
- if (currentMessageEl) currentMessageEl.dataset.timelineIndex = evIdx;
- fullResponse = '';
- }
- }
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() || '';
-
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- const data = JSON.parse(line.slice(6));
-
- // Hide progress widget on first meaningful response
- if (!progressHidden && data.type !== 'generating' && data.type !== 'retry' && !data.type.startsWith('debug_')) {
- hideProgressWidget(chatContainer);
- progressHidden = true;
- }
-
- // Handle different message types from backend
- if (data.type === 'thinking') {
- // Assistant thinking - create message if not exists
- if (!currentMessageEl) {
- currentMessageEl = createAssistantMessage(chatContainer);
- }
- fullResponse += data.content;
- appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'code') {
- // Code execution result - update the last code cell
- updateLastCodeCell(chatContainer, data.output, data.error, data.images);
- currentMessageEl = null; // Reset for next thinking
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'code_start') {
- // Code cell starting execution - show with spinner
- flushResponseToTimeline();
- createCodeCell(chatContainer, data.code, null, false, true);
- currentMessageEl = null;
- scrollChatToBottom(chatContainer);
- // Add to timeline - show code snippet preview
- const codePreview = data.code ? data.code.split('\n')[0] : 'code execution';
- const codeEvIdx = addTimelineEvent(tabId, 'assistant', codePreview, null, { tag: 'CODE' });
- chatContainer.lastElementChild.dataset.timelineIndex = codeEvIdx;
-
- } else if (data.type === 'upload') {
- // File upload notification
- flushResponseToTimeline();
- createUploadMessage(chatContainer, data.paths, data.output);
- currentMessageEl = null;
- scrollChatToBottom(chatContainer);
- // Add to timeline
- const upEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'UPLOAD' });
- chatContainer.lastElementChild.dataset.timelineIndex = upEvIdx;
-
- } else if (data.type === 'download') {
- // File download notification
- flushResponseToTimeline();
- createDownloadMessage(chatContainer, data.paths, data.output);
- currentMessageEl = null;
- scrollChatToBottom(chatContainer);
- // Add to timeline
- const dlEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'DOWNLOAD' });
- chatContainer.lastElementChild.dataset.timelineIndex = dlEvIdx;
-
- } else if (data.type === 'generating') {
- // Still generating - no action needed
-
- } else if (data.type === 'result') {
- // Populate global figure/image registry only for items referenced in result content
- const resultText = data.content || '';
- if (data.figures) {
- for (const [name, figData] of Object.entries(data.figures)) {
- if (new RegExp(`?${name}>`, 'i').test(resultText)) {
- globalFigureRegistry[name] = figData;
- }
- }
- }
- if (data.images) {
- for (const [name, imgBase64] of Object.entries(data.images)) {
- if (new RegExp(`?${name}>`, 'i').test(resultText)) {
- globalFigureRegistry[name] = { type: 'png', data: imgBase64 };
- }
- }
- }
- // Agent result - update command center widget
- updateActionWidgetWithResult(tabId, data.content, data.figures, data.images);
-
- } else if (data.type === 'result_preview') {
- // Show result preview
- flushResponseToTimeline();
- // Replace tags with placeholders BEFORE markdown processing
- let previewContent = data.content;
- const figurePlaceholders = {};
-
- if (data.figures) {
- for (const [figureName, figureData] of Object.entries(data.figures)) {
- // Use %%% delimiters to avoid markdown interpretation
- const placeholderId = `%%%FIGURE_${figureName}%%%`;
- figurePlaceholders[placeholderId] = figureData;
-
- // Handle both self-closing and pairs
- // First replace paired tags, preserving them as block elements
- const pairedTag = new RegExp(`<${figureName}>${figureName}>`, 'gi');
- previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
-
- // Then replace remaining self-closing tags or orphaned closing tags
- const singleTag = new RegExp(`?${figureName}>`, 'gi');
- previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
- }
- }
-
- // Handle references from image agent
- if (data.images) {
- for (const [imageName, imageBase64] of Object.entries(data.images)) {
- const placeholderId = `%%%IMAGE_${imageName}%%%`;
- figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64, isGenerated: true };
-
- const pairedTag = new RegExp(`<${imageName}>${imageName}>`, 'gi');
- previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
- const singleTag = new RegExp(`?${imageName}>`, 'gi');
- previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
- }
- }
-
- // Process markdown
- let html = parseMarkdown(previewContent);
-
- // Replace placeholders with actual images AFTER markdown processing
- for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
- let imageHtml = '';
- if (figureData.type === 'png' || figureData.type === 'jpeg') {
- imageHtml = `
`;
- } else if (figureData.type === 'svg') {
- imageHtml = `${atob(figureData.data)}
`;
- }
- // Replace both the placeholder and any paragraph-wrapped version
- html = html.replace(new RegExp(`${placeholderId}
`, 'g'), imageHtml);
- html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
- }
-
- // Create result block
- const resultDiv = document.createElement('div');
- resultDiv.className = 'agent-result';
- resultDiv.innerHTML = `
-
- ${html}
- `;
- chatContainer.appendChild(resultDiv);
- scrollChatToBottom(chatContainer);
- // Add result dot to parent research timeline
- const figCount = data.figures ? Object.keys(data.figures).length : 0;
- const reportDesc = figCount > 0 ? `${figCount} figures` : (data.content?.replace(/<[^>]+>/g, '').trim().substring(0, 60) || 'done');
- const resEvIdx = addTimelineEvent(tabId, 'assistant', reportDesc, null, { tag: 'RESULT' });
- resultDiv.dataset.timelineIndex = resEvIdx;
-
- } else if (data.type === 'status') {
- // Research status update
- createStatusMessage(chatContainer, data.message, data.iteration, data.total_iterations);
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'queries') {
- // Research queries generated
- createQueriesMessage(chatContainer, data.queries, data.iteration);
- scrollChatToBottom(chatContainer);
- // Register each query as a virtual sub-agent in the timeline
- const startIdx = Object.keys(researchQueryTabIds).length;
- for (let qi = 0; qi < data.queries.length; qi++) {
- const globalIdx = startIdx + qi;
- const virtualId = `research-${tabId}-q${globalIdx}`;
- researchQueryTabIds[globalIdx] = virtualId;
- registerAgentInTimeline(virtualId, 'search', data.queries[qi], tabId);
- setTimelineGenerating(virtualId, true);
- }
-
- } else if (data.type === 'progress') {
- // Research progress
- updateProgress(chatContainer, data.message, data.websites_visited, data.max_websites);
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'source') {
- // Research source found - now includes query grouping
- createSourceMessage(chatContainer, data);
- scrollChatToBottom(chatContainer);
- // Add source as dot in virtual sub-agent timeline
- const sourceVirtualId = researchQueryTabIds[data.query_index];
- if (sourceVirtualId) {
- addTimelineEvent(sourceVirtualId, 'assistant', data.title || data.url || 'source');
- } else if (data.query_index === -1) {
- // Browse result — create a virtual browse entry if needed
- const browseId = `research-${tabId}-browse-${Date.now()}`;
- registerAgentInTimeline(browseId, 'browse', data.url || 'webpage', tabId);
- addTimelineEvent(browseId, 'assistant', data.title || data.url || 'page');
- setTimelineGenerating(browseId, false);
- }
-
- } else if (data.type === 'query_stats') {
- // Update query statistics
- updateQueryStats(data.query_index, {
- relevant: data.relevant_count,
- irrelevant: data.irrelevant_count,
- error: data.error_count
- });
- // Mark search sub-agent as done
- const statsVirtualId = researchQueryTabIds[data.query_index];
- if (statsVirtualId) {
- setTimelineGenerating(statsVirtualId, false);
- }
-
- } else if (data.type === 'assessment') {
- // Research completeness assessment
- createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count, data.reasoning);
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'report') {
- // Final research report
- flushResponseToTimeline();
- createReportMessage(chatContainer, data.content, data.sources_count, data.websites_visited);
- scrollChatToBottom(chatContainer);
- // Add to timeline
- const rptEvIdx = addTimelineEvent(tabId, 'assistant', `${data.sources_count || 0} sources, ${data.websites_visited || 0} sites`, null, { tag: 'RESULT' });
- chatContainer.lastElementChild.dataset.timelineIndex = rptEvIdx;
-
- } else if (data.type === 'tool_start') {
- // Agent tool execution starting — create a tool-cell box (like code cells)
- flushResponseToTimeline();
- currentMessageEl = null;
-
- const toolLabels = {
- 'web_search': 'SEARCH',
- 'read_url': 'READ',
- 'screenshot_url': 'SCREENSHOT',
- 'generate_image': 'GENERATE',
- 'edit_image': 'EDIT',
- 'read_image_url': 'LOAD IMAGE',
- 'read_image': 'LOAD IMAGE',
- 'show_html': 'HTML'
- };
- const toolDescriptions = {
- 'web_search': data.args?.query || '',
- 'read_url': data.args?.url || '',
- 'screenshot_url': data.args?.url || '',
- 'generate_image': data.args?.prompt || '',
- 'edit_image': `${data.args?.prompt || ''} (from ${data.args?.source || ''})`,
- 'read_image_url': data.args?.url || '',
- 'read_image': data.args?.source || '',
- 'show_html': data.args?.source?.substring(0, 80) || ''
- };
- const label = toolLabels[data.tool] || data.tool.toUpperCase();
- const description = toolDescriptions[data.tool] || '';
-
- // Store tool call in DOM for history reconstruction
- // Reuse currentMessageEl (from thinking) if it exists, like launch events do
- let toolCallMsg = currentMessageEl;
- if (!toolCallMsg) {
- toolCallMsg = document.createElement('div');
- toolCallMsg.className = 'message assistant';
- toolCallMsg.style.display = 'none';
- chatContainer.appendChild(toolCallMsg);
- }
- toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
- tool_call_id: data.tool_call_id,
- function_name: data.tool,
- arguments: data.arguments,
- thinking: data.thinking || ''
- }));
-
- // Create tool-cell box (similar to code-cell)
- const toolCell = document.createElement('div');
- toolCell.className = 'tool-cell';
- if (document.getElementById('collapseToolsCheckbox')?.checked) {
- toolCell.classList.add('collapsed');
- }
- toolCell.setAttribute('data-tool-name', data.tool);
- const descHtml = description ? `${escapeHtml(description)}` : '';
- toolCell.innerHTML = `
-
- ${escapeHtml(description)}
- `;
- toolCell.querySelector('.tool-cell-label').addEventListener('click', () => {
- toolCell.classList.toggle('collapsed');
- toolCell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
- });
- chatContainer.appendChild(toolCell);
- scrollChatToBottom(chatContainer);
- const toolEvIdx = addTimelineEvent(tabId, 'assistant', description, null, { tag: label });
- toolCell.dataset.timelineIndex = toolEvIdx;
-
- } else if (data.type === 'tool_result') {
- // Agent tool result — populate the last tool-cell with output
- const lastToolCell = chatContainer.querySelector('.tool-cell:last-of-type');
-
- // Remove spinner
- if (lastToolCell) {
- const spinner = lastToolCell.querySelector('.tool-spinner');
- if (spinner) spinner.remove();
- }
-
- // Store tool response in DOM for history reconstruction
- const toolResponseMsg = document.createElement('div');
- toolResponseMsg.className = 'message tool';
- toolResponseMsg.style.display = 'none';
- toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
- tool_call_id: data.tool_call_id,
- content: data.response || ''
- }));
- chatContainer.appendChild(toolResponseMsg);
-
- // Build output HTML based on tool type
- let outputHtml = '';
-
- if (data.tool === 'web_search' && data.result?.results) {
- try {
- const results = typeof data.result.results === 'string' ? JSON.parse(data.result.results) : data.result.results;
- if (Array.isArray(results)) {
- outputHtml = '' +
- results.map(r =>
- `
`
- ).join('') + '
';
- }
- } catch(e) { /* ignore parse errors */ }
- } else if (data.tool === 'read_url') {
- const len = data.result?.length || 0;
- const markdown = data.result?.markdown || '';
- const summaryText = len > 0 ? `Extracted ${(len / 1000).toFixed(1)}k chars` : 'No content extracted';
- if (markdown) {
- const toggleId = `read-content-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
- outputHtml = `${summaryText}
${parseMarkdown(markdown)}
`;
- } else {
- outputHtml = `${summaryText}
`;
- }
- } else if (data.tool === 'screenshot_url' && data.image) {
- outputHtml = `
`;
- } else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && data.image) {
- const imgName = data.image_name || 'image';
- outputHtml = `
`;
- } else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && !data.image) {
- const errMsg = data.response || 'Failed to process image';
- outputHtml = `${escapeHtml(errMsg)}
`;
- } else if (data.tool === 'show_html' && data.result?.html) {
- // Create iframe programmatically to avoid escaping issues with srcdoc
- if (lastToolCell) {
- const outputEl = document.createElement('div');
- outputEl.className = 'tool-cell-output';
- const iframe = document.createElement('iframe');
- iframe.className = 'show-html-iframe';
- iframe.sandbox = 'allow-scripts allow-same-origin';
- iframe.srcdoc = data.result.html;
- outputEl.appendChild(iframe);
- lastToolCell.appendChild(outputEl);
- }
- } else if (data.tool === 'show_html' && !data.result?.html) {
- const errMsg = data.response || 'Failed to load HTML';
- outputHtml = `${escapeHtml(errMsg)}
`;
- }
-
- if (outputHtml && lastToolCell) {
- const outputEl = document.createElement('div');
- outputEl.className = 'tool-cell-output';
- outputEl.innerHTML = outputHtml;
- lastToolCell.appendChild(outputEl);
- }
-
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'content') {
- // Regular streaming content (non-code agents)
- if (!currentMessageEl) {
- currentMessageEl = createAssistantMessage(chatContainer);
- }
- fullResponse += data.content;
- appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'launch') {
- // Tool-based agent launch from command center
- pendingAgentLaunches++;
- const agentType = data.agent_type;
- const initialMessage = data.initial_message;
- const taskId = data.task_id;
- const toolCallId = data.tool_call_id;
-
- // Add tool call data to existing message (from thinking) or create new one
- // This keeps content + tool_calls together for proper history reconstruction
- let toolCallMsg = currentMessageEl;
- if (!toolCallMsg) {
- toolCallMsg = document.createElement('div');
- toolCallMsg.className = 'message assistant';
- toolCallMsg.innerHTML = '';
- chatContainer.appendChild(toolCallMsg);
- }
- toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
- agent_type: agentType,
- message: initialMessage,
- tool_call_id: toolCallId
- }));
-
- // Add tool response so LLM knows the tool was executed
- const toolResponseMsg = document.createElement('div');
- toolResponseMsg.className = 'message tool';
- toolResponseMsg.style.display = 'none';
- toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
- tool_call_id: toolCallId,
- content: `Launched ${agentType} agent with task: ${initialMessage}`
- }));
- chatContainer.appendChild(toolResponseMsg);
-
- // The action widget will show the launch visually
- handleActionToken(agentType, initialMessage, (targetTabId) => {
- showActionWidget(chatContainer, agentType, initialMessage, targetTabId, taskId);
- // Store tool call ID for this agent tab so we can send result back
- toolCallIds[targetTabId] = toolCallId;
- }, taskId, tabId);
-
- // Reset current message element so any subsequent thinking starts fresh
- currentMessageEl = null;
-
- } else if (data.type === 'debug_call_input') {
- // Debug: LLM call input (before API call)
- if (!debugHistory[tabId]) debugHistory[tabId] = [];
- debugHistory[tabId].push({
- call_number: data.call_number,
- timestamp: new Date().toLocaleTimeString(),
- input: data.messages,
- output: null,
- error: null
- });
- if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
-
- } else if (data.type === 'debug_call_output') {
- // Debug: LLM call output (after API call)
- // Match the last pending call (call_numbers reset per streaming request)
- const calls = debugHistory[tabId] || [];
- const call = calls.findLast(c => c.output === null && c.error === null);
- if (call) {
- call.output = data.response || null;
- call.error = data.error || null;
- }
- if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
-
- } else if (data.type === 'aborted') {
- // Agent was aborted by user
- hideProgressWidget(chatContainer);
- removeRetryIndicator(chatContainer);
-
- // Mark the action widget as aborted (show × instead of ✓)
- const widget = actionWidgets[tabId];
- if (widget) {
- const orbitIndicator = widget.querySelector('.orbit-indicator');
- if (orbitIndicator) {
- const abortedIndicator = document.createElement('div');
- abortedIndicator.className = 'done-indicator aborted';
- orbitIndicator.replaceWith(abortedIndicator);
- }
- }
-
- // For timeline agent boxes, mark as aborted
- if (timelineData[tabId]) {
- timelineData[tabId].aborted = true;
- }
-
- break; // Stop reading stream
-
- } else if (data.type === 'done') {
- // Remove retry indicator on success
- removeRetryIndicator(chatContainer);
-
- // Reset research state when research agent completes
- if (agentType === 'research' && typeof resetResearchState === 'function') {
- // Mark all research virtual sub-agents as done
- for (const virtualId of Object.values(researchQueryTabIds)) {
- setTimelineGenerating(virtualId, false);
- }
- researchQueryTabIds = {};
- resetResearchState();
- }
-
- // Check for action tokens in regular agents (legacy support)
- if (fullResponse) {
- const actionMatch = fullResponse.match(/([\s\S]*?)<\/action>/i);
- if (actionMatch) {
- const action = actionMatch[1].toLowerCase();
- const actionMessage = actionMatch[2].trim();
-
- // Remove action token from fullResponse
- const cleanedResponse = fullResponse.replace(/[\s\S]*?<\/action>/i, '').trim();
-
- // Update the display to remove action tags
- if (cleanedResponse.length === 0 && currentMessageEl) {
- currentMessageEl.remove();
- } else if (currentMessageEl) {
- // Clear and replace the entire message content
- const messageContent = currentMessageEl.querySelector('.message-content');
- if (messageContent) {
- messageContent.innerHTML = parseMarkdown(cleanedResponse);
- linkifyFilePaths(messageContent);
- }
- }
-
- handleActionToken(action, actionMessage, (targetTabId) => {
- showActionWidget(chatContainer, action, actionMessage, targetTabId);
- }, null, tabId);
- }
- }
-
- } else if (data.type === 'info') {
- // Info message (e.g., sandbox restart)
- const infoDiv = document.createElement('div');
- infoDiv.className = 'system-message';
- infoDiv.innerHTML = `${escapeHtml(data.content)}`;
- infoDiv.style.color = 'var(--theme-accent)';
- chatContainer.appendChild(infoDiv);
- scrollChatToBottom(chatContainer);
-
- } else if (data.type === 'retry') {
- // Show retry indicator
- showRetryIndicator(chatContainer, data);
-
- } else if (data.type === 'error') {
- // Remove any retry indicator before showing error
- removeRetryIndicator(chatContainer);
- const errorDiv = document.createElement('div');
- errorDiv.className = 'message assistant';
- errorDiv.innerHTML = `Error: ${escapeHtml(data.content)}
`;
- chatContainer.appendChild(errorDiv);
- scrollChatToBottom(chatContainer);
-
- // Propagate error to parent action widget
- updateActionWidgetWithResult(tabId, `Error: ${data.content}`, {}, {});
- const errorWidget = actionWidgets[tabId];
- if (errorWidget) {
- const doneIndicator = errorWidget.querySelector('.done-indicator');
- if (doneIndicator) {
- doneIndicator.classList.add('errored');
- }
- }
- }
- }
- }
- }
-
- // Add assistant response to timeline
- if (fullResponse && tabId !== undefined) {
- const finalEvIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
- if (currentMessageEl) currentMessageEl.dataset.timelineIndex = finalEvIdx;
- }
- } catch (error) {
- hideProgressWidget(chatContainer);
- if (error.name === 'AbortError') {
- // User-initiated abort — show as a result block
- const abortResultText = 'Generation aborted by user.';
- const resultDiv = document.createElement('div');
- resultDiv.className = 'agent-result';
- resultDiv.innerHTML = `
-
-
- `;
- chatContainer.appendChild(resultDiv);
-
- // Send abort result to parent action widget (so command center knows it was aborted)
- updateActionWidgetWithResult(tabId, abortResultText, {}, {});
-
- // Override the done indicator to show × instead of ✓
- const widget = actionWidgets[tabId];
- if (widget) {
- const doneIndicator = widget.querySelector('.done-indicator');
- if (doneIndicator) {
- doneIndicator.classList.add('aborted');
- }
- }
-
- // Mark timeline data as aborted
- if (timelineData[tabId]) {
- timelineData[tabId].aborted = true;
- }
- } else {
- const errorDiv = document.createElement('div');
- errorDiv.className = 'message assistant';
- errorDiv.innerHTML = `Connection error: ${escapeHtml(error.message)}
`;
- chatContainer.appendChild(errorDiv);
- }
- if (tabId) {
- setTabGenerating(tabId, false);
- }
- } finally {
- // Clean up abort controller
- delete activeAbortControllers[tabId];
- }
-}
-
-function createAssistantMessage(chatContainer) {
- const msg = document.createElement('div');
- msg.className = 'message assistant';
- msg.innerHTML = '';
- chatContainer.appendChild(msg);
- return msg;
-}
-
-function appendToMessage(messageEl, content) {
- const contentEl = messageEl.querySelector('.message-content');
- if (contentEl) {
- contentEl.innerHTML = content;
- linkifyFilePaths(contentEl);
- }
-}
-
-function createSpinnerHtml() {
- return `
`;
-}
-
-function createCodeCell(chatContainer, code, output, isError, isExecuting = false) {
- const codeCell = document.createElement('div');
- codeCell.className = 'code-cell';
-
- let outputHtml = '';
- const cleanedOutput = cleanCodeOutput(output);
- if (cleanedOutput && !isExecuting) {
- outputHtml = `${escapeHtml(cleanedOutput)}
`;
- }
-
- const spinnerHtml = isExecuting ? createSpinnerHtml() : '';
-
- codeCell.innerHTML = `
-
-
- ${outputHtml}
- `;
- codeCell.querySelector('.code-cell-label').addEventListener('click', () => {
- codeCell.classList.toggle('collapsed');
- codeCell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
- });
- chatContainer.appendChild(codeCell);
-
- // Apply syntax highlighting
- if (typeof Prism !== 'undefined') {
- const codeBlock = codeCell.querySelector('code.language-python');
- if (codeBlock) {
- Prism.highlightElement(codeBlock);
- }
- }
-}
-
-function createFileTransferCell(chatContainer, type, paths, output, isExecuting = false) {
- const cell = document.createElement('div');
- cell.className = 'action-widget';
-
- const label = type === 'upload' ? 'UPLOAD' : 'DOWNLOAD';
- const hasError = output && output.includes('Error:');
-
- // Indicator: spinner while executing, checkmark when done
- const indicatorHtml = isExecuting
- ? `
`
- : ``;
-
- // Format paths as list items (make local paths clickable for downloads)
- const pathsList = paths.map(p => {
- if (type === 'download') {
- const arrowIdx = p.indexOf(' -> ');
- if (arrowIdx !== -1) {
- const localPath = p.substring(arrowIdx + 4);
- return `${escapeHtml(p.substring(0, arrowIdx + 4))}${escapeHtml(localPath)}`;
- }
- }
- return `${escapeHtml(p)}`;
- }).join('');
-
- let outputHtml = '';
- if (output && !isExecuting) {
- const outputClass = hasError ? 'transfer-output error' : 'transfer-output';
- outputHtml = `${escapeHtml(output)}
`;
- }
-
- cell.innerHTML = `
-
-
- `;
- cell.querySelector('.action-widget-header').style.cursor = 'pointer';
- cell.querySelector('.action-widget-header').addEventListener('click', () => {
- cell.classList.toggle('collapsed');
- cell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
- });
- // Make download path links clickable to navigate in file explorer
- cell.querySelectorAll('.transfer-path-link').forEach(link => {
- link.addEventListener('click', (e) => {
- e.preventDefault();
- navigateToFileInExplorer(link.dataset.path);
- });
- });
- chatContainer.appendChild(cell);
- return cell;
-}
-
-function createUploadMessage(chatContainer, paths, output) {
- createFileTransferCell(chatContainer, 'upload', paths, output, false);
-}
-
-function createDownloadMessage(chatContainer, paths, output) {
- createFileTransferCell(chatContainer, 'download', paths, output, false);
-}
-
-function updateLastCodeCell(chatContainer, output, isError, images) {
- const codeCells = chatContainer.querySelectorAll('.code-cell');
- if (codeCells.length === 0) return;
-
- const lastCell = codeCells[codeCells.length - 1];
-
- // Remove spinner if present
- const spinner = lastCell.querySelector('.tool-spinner');
- if (spinner) {
- spinner.remove();
- }
-
- // Remove existing output if any
- const existingOutput = lastCell.querySelector('.code-cell-output');
- if (existingOutput) {
- existingOutput.remove();
- }
-
- // Add images if any
- if (images && images.length > 0) {
- for (const img of images) {
- const imgDiv = document.createElement('div');
- imgDiv.className = 'code-cell-image';
-
- // Add figure label if available
- if (img.name) {
- const label = document.createElement('div');
- label.className = 'figure-label';
- label.textContent = img.name;
- imgDiv.appendChild(label);
- }
-
- if (img.type === 'png' || img.type === 'jpeg') {
- const imgEl = document.createElement('img');
- imgEl.src = `data:image/${img.type};base64,${img.data}`;
- imgEl.onclick = function() { openImageModal(this.src); };
- imgDiv.appendChild(imgEl);
- } else if (img.type === 'svg') {
- const svgContainer = document.createElement('div');
- svgContainer.innerHTML = atob(img.data);
- imgDiv.appendChild(svgContainer);
- }
-
- lastCell.appendChild(imgDiv);
- }
- }
-
- // Add text output
- const cleanedOut = cleanCodeOutput(output);
- if (cleanedOut) {
- const outputDiv = document.createElement('div');
- outputDiv.className = `code-cell-output${isError ? ' error' : ''}`;
- outputDiv.textContent = cleanedOut;
- lastCell.appendChild(outputDiv);
- }
-}
-
-function showActionWidget(chatContainer, action, message, targetTabId, taskId = null) {
- const widget = document.createElement('div');
- widget.className = 'action-widget';
- if (document.getElementById('collapseAgentsCheckbox')?.checked) {
- widget.classList.add('collapsed');
- }
- widget.dataset.targetTabId = targetTabId;
-
- // Display task_id as title if provided
- const titleDisplay = taskId ? taskId : action.toUpperCase();
- const agentCollapsed = document.getElementById('collapseAgentsCheckbox')?.checked;
-
- widget.innerHTML = `
-
-
-
-
-
QUERY
-
${parseMarkdown(message)}
-
- `;
-
- // Collapse toggle — stop propagation so it doesn't navigate to the agent tab
- const collapseToggle = widget.querySelector('.widget-collapse-toggle');
- collapseToggle.addEventListener('click', (e) => {
- e.stopPropagation();
- widget.classList.toggle('collapsed');
- collapseToggle.classList.toggle('collapsed');
- });
-
- // Make header clickable to jump to the agent
- const clickableArea = widget.querySelector('.action-widget-clickable');
-
- const clickHandler = () => {
- const tabId = parseInt(targetTabId);
- // Check if the tab still exists (use .tab to avoid matching timeline elements)
- const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
- if (tab) {
- // Tab exists, just switch to it
- switchToTab(tabId);
- } else {
- // Tab was closed - restore from timeline data
- const notebook = timelineData[tabId];
- if (notebook) {
- reopenClosedTab(tabId, notebook);
- }
- }
- };
-
- clickableArea.addEventListener('click', clickHandler);
-
- chatContainer.appendChild(widget);
- scrollChatToBottom(chatContainer);
-
- // Store widget for later updates
- actionWidgets[targetTabId] = widget;
-}
-
-async function updateActionWidgetWithResult(tabId, resultContent, figures, images) {
- const widget = actionWidgets[tabId];
- if (!widget) return;
-
- // Replace orbiting dots with checkmark
- const orbitIndicator = widget.querySelector('.orbit-indicator');
- if (orbitIndicator) {
- const doneIndicator = document.createElement('div');
- doneIndicator.className = 'done-indicator';
- orbitIndicator.replaceWith(doneIndicator);
- }
-
- // Process and display result content FIRST (before async operations)
- // Replace tags with placeholders BEFORE markdown processing
- let processedContent = resultContent;
- const figurePlaceholders = {};
-
- if (figures) {
- for (const [figureName, figureData] of Object.entries(figures)) {
- // Use %%% delimiters to avoid markdown interpretation
- const placeholderId = `%%%FIGURE_${figureName}%%%`;
- figurePlaceholders[placeholderId] = figureData;
-
- // Handle both self-closing and pairs
- // First replace paired tags, preserving them as block elements
- const pairedTag = new RegExp(`<${figureName}>${figureName}>`, 'gi');
- processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
-
- // Then replace remaining self-closing tags or orphaned closing tags
- const singleTag = new RegExp(`?${figureName}>`, 'gi');
- processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
- }
- }
-
- // Handle references from image agent
- if (images) {
- for (const [imageName, imageBase64] of Object.entries(images)) {
- const placeholderId = `%%%IMAGE_${imageName}%%%`;
- figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64 };
-
- const pairedTag = new RegExp(`<${imageName}>${imageName}>`, 'gi');
- processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
- const singleTag = new RegExp(`?${imageName}>`, 'gi');
- processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
- }
- }
-
- // Process markdown
- let html = parseMarkdown(processedContent);
-
- // Replace placeholders with actual images AFTER markdown processing
- for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
- let imageHtml = '';
- if (figureData.type === 'png' || figureData.type === 'jpeg') {
- imageHtml = `
`;
- } else if (figureData.type === 'svg') {
- imageHtml = `${atob(figureData.data)}
`;
- }
- // Replace both the placeholder and any paragraph-wrapped version
- html = html.replace(new RegExp(`${placeholderId}
`, 'g'), imageHtml);
- html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
- }
-
- // Add result section inside the body
- const body = widget.querySelector('.action-widget-body');
- if (body) {
- const resultSection = document.createElement('div');
- resultSection.className = 'action-widget-result-section';
- resultSection.innerHTML = `
- RESULT
- ${html}
- `;
- body.appendChild(resultSection);
- }
-
- // Update the tool response DOM element so getConversationHistory picks up actual results
- const toolCallId = toolCallIds[tabId];
- if (toolCallId) {
- // Find the hidden tool response element with this tool_call_id in the command center
- const commandContainer = document.getElementById('messages-command');
- if (commandContainer) {
- const toolMsgs = commandContainer.querySelectorAll('.message.tool[data-tool-response]');
- for (const toolMsg of toolMsgs) {
- try {
- const data = JSON.parse(toolMsg.getAttribute('data-tool-response'));
- if (data.tool_call_id === toolCallId) {
- data.content = resultContent;
- toolMsg.setAttribute('data-tool-response', JSON.stringify(data));
- break;
- }
- } catch (e) { /* ignore parse errors */ }
- }
- }
-
- // Also send to backend (non-blocking)
- apiFetch('/api/conversation/add-tool-response', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- tab_id: '0',
- tool_call_id: toolCallId,
- content: resultContent
- })
- }).catch(error => {
- console.error('Failed to update conversation history with result:', error);
- });
- }
-}
-
-function sendMessageToTab(tabId, message) {
- // Programmatically send a message to an existing agent tab
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
- if (!content) return;
-
- const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
- if (!input) return;
-
- // Set the message and trigger send
- input.value = message;
- sendMessage(tabId);
-}
-
-function handleActionToken(action, message, callback, taskId = null, parentTabId = null) {
- // Check if an agent with this task_id already exists
- if (taskId && taskIdToTabId[taskId]) {
- const existingTabId = taskIdToTabId[taskId];
- const existingContent = document.querySelector(`[data-content-id="${existingTabId}"]`);
-
- if (existingContent) {
- // Send the message to the existing agent
- sendMessageToTab(existingTabId, message);
- if (callback) {
- callback(existingTabId);
- }
- return;
- } else {
- // Tab no longer exists, clean up the mapping
- delete taskIdToTabId[taskId];
- }
- }
-
- // Open the agent with the extracted message as initial prompt
- // Don't auto-switch to the new tab (autoSwitch = false)
- setTimeout(() => {
- const newTabId = createAgentTab(action, message, false, taskId, parentTabId);
- if (callback) {
- callback(newTabId);
- }
- }, 500);
-}
-
-function setTabGenerating(tabId, isGenerating) {
- const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
- if (!tab) return;
-
- const statusIndicator = tab.querySelector('.tab-status');
- if (!statusIndicator) return;
-
- if (isGenerating) {
- statusIndicator.style.display = 'block';
- statusIndicator.classList.add('generating');
- } else {
- statusIndicator.classList.remove('generating');
- // Keep visible but stop animation
- setTimeout(() => {
- statusIndicator.style.display = 'none';
- }, 300);
- }
-
- // Toggle SEND/STOP button
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
- if (content) {
- const sendBtn = content.querySelector('.input-container button');
- if (sendBtn) {
- if (isGenerating) {
- sendBtn.textContent = 'STOP';
- sendBtn.classList.add('stop-btn');
- sendBtn.disabled = false; // Keep enabled so user can click STOP
- } else {
- sendBtn.textContent = 'SEND';
- sendBtn.classList.remove('stop-btn');
- }
- }
- }
-
- // Update timeline to reflect generating state
- setTimelineGenerating(tabId, isGenerating);
-
- // Track when a pending agent launch actually starts generating
- if (isGenerating && pendingAgentLaunches > 0 && tabId !== 0 && timelineData[tabId]?.parentTabId === 0) {
- pendingAgentLaunches--;
- }
-
- // When a child agent finishes and command center is blocked, check if all agents are done
- if (!isGenerating && commandInputBlocked && tabId !== 0) {
- const anyStillGenerating = Object.values(timelineData).some(
- td => td.parentTabId === 0 && td.isGenerating
- );
- if (!anyStillGenerating && pendingAgentLaunches === 0) {
- commandInputBlocked = false;
- setCommandCenterStopState(false);
- // Auto-continue: call command center again with agent results now in history
- continueCommandCenter();
- }
- }
-}
-
-function escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
-function cleanCodeOutput(text) {
- if (!text) return text;
- return text.split('\n')
- .filter(line => !line.match(/^\[Plot\/Image generated\]$/) && !line.match(/^\[Generated figures:.*\]$/))
- .join('\n')
- .trim();
-}
-
-function showRetryIndicator(chatContainer, data) {
- // Remove existing retry indicator if present
- removeRetryIndicator(chatContainer);
-
- const retryDiv = document.createElement('div');
- retryDiv.className = 'retry-indicator';
- retryDiv.innerHTML = `
-
-
-
-
${escapeHtml(data.message)}
-
Retrying (${data.attempt}/${data.max_attempts}) in ${data.delay}s...
-
-
- `;
- chatContainer.appendChild(retryDiv);
- scrollChatToBottom(chatContainer);
-
- // Start countdown
- let remaining = data.delay;
- const statusEl = retryDiv.querySelector('.retry-status');
- const countdownInterval = setInterval(() => {
- remaining--;
- if (remaining > 0 && statusEl) {
- statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts}) in ${remaining}s...`;
- } else {
- clearInterval(countdownInterval);
- if (statusEl) {
- statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts})...`;
- }
- }
- }, 1000);
-
- // Store interval ID for cleanup
- retryDiv.dataset.countdownInterval = countdownInterval;
-}
-
-function removeRetryIndicator(chatContainer) {
- const existing = chatContainer.querySelector('.retry-indicator');
- if (existing) {
- // Clear countdown interval if exists
- if (existing.dataset.countdownInterval) {
- clearInterval(parseInt(existing.dataset.countdownInterval));
- }
- existing.remove();
- }
-}
-
-// Configure marked options once
-if (typeof marked !== 'undefined') {
- // Custom renderer to add target="_blank" to links and syntax highlighting
- const renderer = new marked.Renderer();
-
- // Handle both old API (separate args) and new API (token object)
- renderer.link = function(hrefOrToken, title, text) {
- const href = typeof hrefOrToken === 'object' ? hrefOrToken.href : hrefOrToken;
- const titleVal = typeof hrefOrToken === 'object' ? hrefOrToken.title : title;
- const textVal = typeof hrefOrToken === 'object' ? hrefOrToken.text : text;
- const titleAttr = titleVal ? ` title="${titleVal}"` : '';
- return `${textVal}`;
- };
-
- renderer.code = function(codeOrToken, language) {
- // Handle both old API (separate args) and new API (token object)
- const code = typeof codeOrToken === 'object' ? codeOrToken.text : codeOrToken;
- const lang = typeof codeOrToken === 'object' ? codeOrToken.lang : language;
-
- // Use Prism for syntax highlighting if available
- if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) {
- const highlighted = Prism.highlight(code, Prism.languages[lang], lang);
- return `${highlighted}
`;
- }
- return `${escapeHtml(code)}
`;
- };
-
- marked.setOptions({
- gfm: true, // GitHub Flavored Markdown
- breaks: false, // Don't convert \n to
- pedantic: false,
- renderer: renderer
- });
-}
-
-// Resolve and references using the global registry
-function resolveGlobalFigureRefs(html) {
- return html.replace(/<\/?(figure_\d+|image_\d+)>/gi, (match) => {
- // Extract the name (strip < > and /)
- const name = match.replace(/[<>/]/g, '');
- const data = globalFigureRegistry[name];
- if (!data) return match; // Leave unresolved refs as-is
- if (data.type === 'png' || data.type === 'jpeg') {
- return `
`;
- } else if (data.type === 'svg') {
- return `${atob(data.data)}
`;
- }
- return match;
- });
-}
-
-function parseMarkdown(text) {
- // Use marked library for proper markdown parsing
- let html;
- if (typeof marked !== 'undefined') {
- html = marked.parse(text);
- } else {
- // Fallback: just escape HTML and convert newlines to paragraphs
- html = `${escapeHtml(text).replace(/\n\n/g, '
').replace(/\n/g, '
')}
`;
- }
-
- // Render LaTeX with KaTeX if available
- if (typeof katex !== 'undefined') {
- // Block math: $$ ... $$ (must handle newlines)
- html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex) => {
- try {
- return katex.renderToString(latex.trim(), { displayMode: true, throwOnError: false });
- } catch (e) {
- return `${escapeHtml(match)}`;
- }
- });
-
- // Inline math: $ ... $ (but not $$ or escaped \$)
- html = html.replace(/(? {
- try {
- return katex.renderToString(latex.trim(), { displayMode: false, throwOnError: false });
- } catch (e) {
- return `${escapeHtml(match)}`;
- }
- });
- }
-
- return html;
-}
-
-// ============================================
-// Workspace State Persistence
-// ============================================
-
-async function loadWorkspace() {
- try {
- const response = await apiFetch('/api/workspace');
- if (response.ok) {
- const workspace = await response.json();
- console.log('Workspace loaded:', workspace);
- restoreWorkspace(workspace);
- }
- } catch (e) {
- console.log('Could not load workspace from backend:', e);
- }
-}
-
-function restoreWorkspace(workspace) {
- // Restore counters
- tabCounter = workspace.tabCounter || 1;
- agentCounters = workspace.agentCounters || workspace.notebookCounters || getDefaultCounters();
-
- // Restore timeline data before tabs so renderTimeline works
- if (workspace.timelineData) {
- Object.keys(timelineData).forEach(k => delete timelineData[k]);
- for (const [tabId, data] of Object.entries(workspace.timelineData)) {
- timelineData[tabId] = data;
- }
- }
-
- // Restore debug history
- if (workspace.debugHistory) {
- Object.keys(debugHistory).forEach(k => delete debugHistory[k]);
- for (const [tabId, calls] of Object.entries(workspace.debugHistory)) {
- debugHistory[tabId] = calls;
- }
- }
-
- // Restore tabs (skip command center as it already exists in HTML)
- const tabs = workspace.tabs || [];
- for (const tabData of tabs) {
- if (tabData.id === 0) {
- // Restore command center messages
- restoreTabMessages(tabData);
- } else {
- // Create and restore other tabs
- restoreTab(tabData);
- }
- }
-
- // Switch to the active tab
- if (workspace.activeTabId !== undefined) {
- switchToTab(workspace.activeTabId);
- }
-
- // Render timeline after everything is restored
- renderTimeline();
-}
-
-function restoreTab(tabData) {
- // Create tab element
- const tab = document.createElement('div');
- tab.className = 'tab';
- tab.dataset.tabId = tabData.id;
- tab.innerHTML = `
- ${tabData.title || getTypeLabel(tabData.type) || 'TAB'}
-
- ×
- `;
-
- // Insert into dynamic tabs container
- const dynamicTabs = document.getElementById('dynamicTabs');
- dynamicTabs.appendChild(tab);
-
- // Create content element
- const content = document.createElement('div');
- content.className = 'tab-content';
- content.dataset.contentId = tabData.id;
- content.innerHTML = createAgentContent(tabData.type, tabData.id);
- document.querySelector('.main-content').appendChild(content);
-
- // Add event listeners for the new content
- const input = content.querySelector('textarea');
- const sendBtn = content.querySelector('.input-container button');
-
- if (input && sendBtn) {
- sendBtn.addEventListener('click', () => sendMessage(tabData.id));
-
- input.addEventListener('input', () => {
- input.style.height = 'auto';
- input.style.height = Math.min(input.scrollHeight, 200) + 'px';
- });
-
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendMessage(tabData.id);
- }
- });
- }
-
- // Restore messages
- restoreTabMessages(tabData);
-
- // If this is a code agent, start the sandbox proactively
- if (tabData.type === 'code') {
- startSandbox(tabData.id);
- }
-}
-
-function restoreTabMessages(tabData) {
- const content = document.querySelector(`[data-content-id="${tabData.id}"]`);
- if (!content) return;
-
- const chatContainer = content.querySelector('.chat-container');
- if (!chatContainer) return;
-
- // Remove welcome message if restoring messages
- if (tabData.messages && tabData.messages.length > 0) {
- const welcomeMsg = chatContainer.querySelector('.welcome-message');
- if (welcomeMsg) {
- welcomeMsg.remove();
- }
- }
-
- // Restore each message - messages are now flat, each with its own type
- for (const msg of (tabData.messages || [])) {
- if (msg.role === 'user') {
- const userMsg = document.createElement('div');
- userMsg.className = 'message user';
- userMsg.innerHTML = `${parseMarkdown(msg.content || '')}
`;
- linkifyFilePaths(userMsg);
- chatContainer.appendChild(userMsg);
- } else if (msg.role === 'assistant') {
- // New flat structure: each message has a type field
- switch (msg.type) {
- case 'text':
- const assistantMsg = document.createElement('div');
- assistantMsg.className = 'message assistant';
- const textDiv = document.createElement('div');
- textDiv.className = 'message-content';
- textDiv.innerHTML = msg.html || parseMarkdown(msg.content || '');
- assistantMsg.appendChild(textDiv);
- chatContainer.appendChild(assistantMsg);
- break;
-
- case 'action-widget':
- const widget = renderActionWidget(msg);
- chatContainer.appendChild(widget);
- break;
-
- case 'code-cell':
- const codeCell = renderCodeCell(msg);
- chatContainer.appendChild(codeCell);
- break;
-
- case 'tool-call':
- // Restore hidden tool-call element for getConversationHistory()
- const tcMsg = document.createElement('div');
- tcMsg.className = 'message assistant';
- tcMsg.style.display = 'none';
- tcMsg.setAttribute('data-tool-call', JSON.stringify(msg.data));
- chatContainer.appendChild(tcMsg);
- break;
-
- case 'tool-cell':
- const toolCell = document.createElement('div');
- toolCell.className = 'tool-cell';
- toolCell.setAttribute('data-tool-name', msg.toolName || '');
- const toolDesc = msg.input ? `${escapeHtml(msg.input)}` : '';
- let toolCellHtml = ``;
- toolCellHtml += `${escapeHtml(msg.input || '')}
`;
- if (msg.outputHtml) {
- toolCellHtml += `${msg.outputHtml}
`;
- }
- toolCell.innerHTML = toolCellHtml;
- toolCell.querySelector('.tool-cell-label').addEventListener('click', () => {
- toolCell.classList.toggle('collapsed');
- toolCell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
- });
- chatContainer.appendChild(toolCell);
- break;
-
- case 'result-preview':
- const preview = renderResultPreview(msg);
- chatContainer.appendChild(preview);
- break;
-
- case 'agent-result':
- const report = renderResearchReport(msg);
- chatContainer.appendChild(report);
- break;
-
- case 'research-container':
- const researchWidget = renderResearchContainer(msg);
- chatContainer.appendChild(researchWidget);
- break;
-
- default:
- // Legacy fallback: messages without type or with parts array
- if (msg.parts && msg.parts.length > 0) {
- // Old format with parts array
- for (const part of msg.parts) {
- switch (part.type) {
- case 'text':
- const oldTextDiv = document.createElement('div');
- oldTextDiv.className = 'message assistant';
- oldTextDiv.innerHTML = `${part.html || parseMarkdown(part.content || '')}
`;
- chatContainer.appendChild(oldTextDiv);
- break;
- case 'action-widget':
- chatContainer.appendChild(renderActionWidget(part));
- break;
- case 'code-cell':
- chatContainer.appendChild(renderCodeCell(part));
- break;
- case 'result-preview':
- chatContainer.appendChild(renderResultPreview(part));
- break;
- case 'agent-result':
- chatContainer.appendChild(renderResearchReport(part));
- break;
- }
- }
- } else if (msg.contentHtml) {
- const legacyMsg = document.createElement('div');
- legacyMsg.className = 'message assistant';
- legacyMsg.innerHTML = msg.contentHtml;
- chatContainer.appendChild(legacyMsg);
- } else if (msg.content) {
- const contentMsg = document.createElement('div');
- contentMsg.className = 'message assistant';
- contentMsg.innerHTML = `${parseMarkdown(msg.content)}
`;
- chatContainer.appendChild(contentMsg);
- }
- break;
- }
- } else if (msg.role === 'tool' && msg.type === 'tool-response') {
- // Restore hidden tool-response element for getConversationHistory()
- const trMsg = document.createElement('div');
- trMsg.className = 'message tool';
- trMsg.style.display = 'none';
- trMsg.setAttribute('data-tool-response', JSON.stringify(msg.data));
- chatContainer.appendChild(trMsg);
- }
- }
-}
-
-// Helper functions to render saved message parts
-
-function renderActionWidget(data) {
- const widget = document.createElement('div');
- widget.className = 'action-widget';
- if (data.targetTabId) {
- widget.dataset.targetTabId = data.targetTabId;
- }
-
- const indicatorHtml = data.isDone
- ? ''
- : '
';
-
- let bodyHtml = `
- QUERY
- ${parseMarkdown(data.query || '')}
- `;
-
- // Add result section if present
- if (data.resultHtml || data.result) {
- bodyHtml += `
-
- `;
- }
-
- widget.innerHTML = `
-
-
-
-
- ${bodyHtml}
-
- `;
-
- // Collapse toggle
- const collapseToggle = widget.querySelector('.widget-collapse-toggle');
- collapseToggle.addEventListener('click', (e) => {
- e.stopPropagation();
- widget.classList.toggle('collapsed');
- collapseToggle.classList.toggle('collapsed');
- });
-
- // Make clickable if we have a target tab
- if (data.targetTabId) {
- const clickableArea = widget.querySelector('.action-widget-clickable');
- clickableArea.addEventListener('click', () => {
- const tabId = parseInt(data.targetTabId);
- if (!isNaN(tabId)) {
- // Check if the tab still exists (use .tab to avoid matching timeline elements)
- const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
- if (tab) {
- // Tab exists, just switch to it
- switchToTab(tabId);
- } else {
- // Tab was closed - restore from timeline data
- const notebook = timelineData[tabId];
- if (notebook) {
- reopenClosedTab(tabId, notebook);
- }
- }
- }
- });
- }
-
- return widget;
-}
-
-function renderCodeCell(data) {
- const cell = document.createElement('div');
- cell.className = 'code-cell';
-
- let outputHtml = '';
- const cleanedData = cleanCodeOutput(data.output);
- if (data.outputHtml || cleanedData) {
- const errorClass = data.isError ? ' error' : '';
- outputHtml = `${data.outputHtml || escapeHtml(cleanedData)}
`;
- }
-
- // Build images HTML
- let imagesHtml = '';
- if (data.images && data.images.length > 0) {
- for (const img of data.images) {
- let labelHtml = '';
- if (img.name) {
- labelHtml = `${escapeHtml(img.name)}
`;
- }
- imagesHtml += `${labelHtml}

`;
- }
- }
-
- cell.innerHTML = `
-
-
-
${escapeHtml(data.code || '')}
-
- ${outputHtml}
- ${imagesHtml}
- `;
- cell.querySelector('.code-cell-label').addEventListener('click', () => {
- cell.classList.toggle('collapsed');
- cell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
- });
-
- return cell;
-}
-
-function renderResultPreview(data) {
- const preview = document.createElement('div');
- preview.className = 'result-preview';
-
- preview.innerHTML = `
- Result
- ${data.html || escapeHtml(data.content || '')}
- `;
- linkifyFilePaths(preview);
-
- return preview;
-}
-
-function renderResearchReport(data) {
- const report = document.createElement('div');
- report.className = 'agent-result';
-
- report.innerHTML = `
-
- ${data.html || parseMarkdown(data.content || '')}
- `;
- linkifyFilePaths(report);
-
- return report;
-}
-
-function renderResearchContainer(data) {
- const container = document.createElement('div');
- container.className = 'research-container message assistant';
-
- container.innerHTML = `
-
- ${data.html || ''}
- `;
-
- return container;
-}
-
-function serializeWorkspace() {
- const workspace = {
- version: 1,
- tabCounter: tabCounter,
- activeTabId: activeTabId,
- agentCounters: agentCounters,
- tabs: [],
- timelineData: serializeTimelineData(),
- debugHistory: debugHistory
- };
-
- // Serialize command center (tab 0)
- workspace.tabs.push(serializeTab(0, 'command-center'));
-
- // Serialize all other tabs
- const tabElements = document.querySelectorAll('#dynamicTabs .tab');
- for (const tabEl of tabElements) {
- const tabId = parseInt(tabEl.dataset.tabId);
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
- if (content) {
- const chatContainer = content.querySelector('.chat-container');
- const agentType = chatContainer?.dataset.agentType || 'chat';
- workspace.tabs.push(serializeTab(tabId, agentType));
- }
- }
-
- return workspace;
-}
-
-function serializeTimelineData() {
- const serialized = {};
- for (const [tabId, data] of Object.entries(timelineData)) {
- // Clone but strip savedContent (large HTML, not needed for timeline)
- serialized[tabId] = {
- type: data.type,
- title: data.title,
- events: data.events,
- parentTabId: data.parentTabId,
- isGenerating: false,
- isClosed: data.isClosed || false
- };
- }
- return serialized;
-}
-
-function serializeTab(tabId, type) {
- const tabEl = document.querySelector(`[data-tab-id="${tabId}"]`);
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
-
- const tabData = {
- id: tabId,
- type: type,
- title: tabEl?.querySelector('.tab-title')?.textContent || getTypeLabel(type) || 'TAB',
- messages: []
- };
-
- if (!content) return tabData;
-
- const chatContainer = content.querySelector('.chat-container');
- if (!chatContainer) return tabData;
-
- // Iterate over ALL direct children of chatContainer in order
- // Elements can be: .message.user, .message.assistant, .action-widget, .code-cell, .agent-result, .system-message, .welcome-message
- for (const child of chatContainer.children) {
- // Skip welcome message and system messages
- if (child.classList.contains('welcome-message') || child.classList.contains('system-message')) {
- continue;
- }
-
- // User message
- if (child.classList.contains('message') && child.classList.contains('user')) {
- tabData.messages.push({
- role: 'user',
- content: child.textContent.trim()
- });
- continue;
- }
-
- // Research container (the search tree widget) - check BEFORE generic assistant message
- // because research-container also has .message.assistant classes
- if (child.classList.contains('research-container')) {
- const bodyEl = child.querySelector('.research-body');
- tabData.messages.push({
- role: 'assistant',
- type: 'research-container',
- html: bodyEl?.innerHTML || ''
- });
- continue;
- }
-
- // Tool-call message (hidden, used for LLM conversation history)
- if (child.classList.contains('message') && child.hasAttribute('data-tool-call')) {
- try {
- tabData.messages.push({
- role: 'assistant',
- type: 'tool-call',
- data: JSON.parse(child.getAttribute('data-tool-call'))
- });
- } catch (e) { /* skip unparseable */ }
- continue;
- }
-
- // Tool-response message (hidden, used for LLM conversation history)
- if (child.classList.contains('message') && (child.hasAttribute('data-tool-response') || child.classList.contains('tool'))) {
- try {
- tabData.messages.push({
- role: 'tool',
- type: 'tool-response',
- data: JSON.parse(child.getAttribute('data-tool-response'))
- });
- } catch (e) { /* skip unparseable */ }
- continue;
- }
-
- // Assistant message (with .message-content inside)
- if (child.classList.contains('message') && child.classList.contains('assistant')) {
- const messageContent = child.querySelector('.message-content');
- if (messageContent) {
- tabData.messages.push({
- role: 'assistant',
- type: 'text',
- content: messageContent.textContent.trim(),
- html: messageContent.innerHTML
- });
- } else {
- // Fallback to raw HTML
- tabData.messages.push({
- role: 'assistant',
- type: 'text',
- html: child.innerHTML
- });
- }
- continue;
- }
-
- // Action widget (appended directly to chatContainer)
- if (child.classList.contains('action-widget')) {
- const widgetData = {
- role: 'assistant',
- type: 'action-widget',
- targetTabId: child.dataset.targetTabId,
- actionType: child.querySelector('.action-widget-type')?.textContent?.replace('TASK: ', '') || '',
- query: child.querySelector('.action-widget-body .section-content')?.textContent?.trim() || '',
- isDone: !!child.querySelector('.done-indicator'),
- result: null,
- resultHtml: null
- };
-
- // Extract result if present
- const resultSection = child.querySelector('.action-widget-result-section');
- if (resultSection) {
- const resultContent = resultSection.querySelector('.section-content');
- if (resultContent) {
- widgetData.result = resultContent.textContent.trim();
- widgetData.resultHtml = resultContent.innerHTML;
- }
- }
-
- tabData.messages.push(widgetData);
- continue;
- }
-
- // Code cell (appended directly to chatContainer)
- if (child.classList.contains('code-cell')) {
- const codeEl = child.querySelector('pre code');
- const outputEl = child.querySelector('.code-cell-output');
-
- // Collect images from the code cell
- const images = [];
- const imageEls = child.querySelectorAll('.code-cell-image');
- for (const imgDiv of imageEls) {
- const imgEl = imgDiv.querySelector('img');
- const labelEl = imgDiv.querySelector('.figure-label');
- if (imgEl) {
- images.push({
- src: imgEl.src, // data URL with base64
- name: labelEl?.textContent || ''
- });
- }
- }
-
- tabData.messages.push({
- role: 'assistant',
- type: 'code-cell',
- code: codeEl?.textContent || '',
- output: outputEl?.textContent || '',
- outputHtml: outputEl?.innerHTML || '',
- isError: outputEl?.classList.contains('error') || false,
- images: images
- });
- continue;
- }
-
- // Tool cell (web agent / image agent tool calls)
- if (child.classList.contains('tool-cell')) {
- const labelEl = child.querySelector('.tool-cell-label span');
- const inputEl = child.querySelector('.tool-cell-input');
- const outputEl = child.querySelector('.tool-cell-output');
-
- tabData.messages.push({
- role: 'assistant',
- type: 'tool-cell',
- toolName: child.getAttribute('data-tool-name') || '',
- label: labelEl?.textContent || '',
- input: inputEl?.textContent || '',
- outputHtml: outputEl?.innerHTML || '',
- });
- continue;
- }
-
- // Research report (appended directly to chatContainer)
- if (child.classList.contains('agent-result')) {
- const contentEl = child.querySelector('.result-content');
- tabData.messages.push({
- role: 'assistant',
- type: 'agent-result',
- content: contentEl?.textContent || '',
- html: contentEl?.innerHTML || ''
- });
- continue;
- }
-
- // Result preview
- if (child.classList.contains('result-preview')) {
- const contentEl = child.querySelector('.result-preview-content');
- tabData.messages.push({
- role: 'assistant',
- type: 'result-preview',
- content: contentEl?.textContent || '',
- html: contentEl?.innerHTML || ''
- });
- continue;
- }
- }
-
- return tabData;
-}
-
-function saveWorkspaceDebounced() {
- // Clear any pending save
- if (saveWorkspaceTimer) {
- clearTimeout(saveWorkspaceTimer);
- }
-
- // Schedule save in 500ms
- saveWorkspaceTimer = setTimeout(async () => {
- try {
- const workspace = serializeWorkspace();
- const response = await apiFetch('/api/workspace', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(workspace)
- });
- if (response.ok) {
- console.log('Workspace saved');
- }
- } catch (e) {
- console.error('Failed to save workspace:', e);
- }
- }, 500);
-}
-
-// ============================================
-// Settings management
-// ============================================
-
-// Migrate old settings format (v1) to new format (v2)
-function migrateSettings(oldSettings) {
- // Already migrated or new format
- if (oldSettings.settingsVersion >= 2) {
- return oldSettings;
- }
-
- console.log('Migrating settings from v1 to v2...');
-
- const newSettings = {
- providers: {},
- models: {},
- agents: {
- command: '',
- agent: '',
- code: '',
- research: '',
- chat: ''
- },
- e2bKey: oldSettings.e2bKey || '',
- serperKey: oldSettings.serperKey || '',
- hfToken: oldSettings.hfToken || '',
- imageGenModel: oldSettings.imageGenModel || '',
- imageEditModel: oldSettings.imageEditModel || '',
- researchSubAgentModel: oldSettings.researchSubAgentModel || '',
- researchParallelWorkers: oldSettings.researchParallelWorkers || null,
- researchMaxWebsites: oldSettings.researchMaxWebsites || null,
- themeColor: oldSettings.themeColor || 'forest',
- settingsVersion: 2
- };
-
- // Create a default provider from old endpoint/token if they exist
- if (oldSettings.endpoint) {
- const providerId = 'provider_default';
- newSettings.providers[providerId] = {
- name: 'Default',
- endpoint: oldSettings.endpoint,
- token: oldSettings.token || ''
- };
-
- // Create a default model if old model exists
- if (oldSettings.model) {
- const modelId = 'model_default';
- newSettings.models[modelId] = {
- name: oldSettings.model,
- providerId: providerId,
- modelId: oldSettings.model
- };
-
- // Set as default for all agents
- newSettings.agents.command = modelId;
- newSettings.agents.agent = modelId;
- newSettings.agents.code = modelId;
- newSettings.agents.research = modelId;
- newSettings.agents.chat = modelId;
- }
-
- // Migrate agent-specific models if they existed
- const oldModels = oldSettings.models || {};
- const agentTypes = Object.keys(AGENT_REGISTRY).filter(k => AGENT_REGISTRY[k].hasCounter);
- agentTypes.forEach(type => {
- if (oldModels[type]) {
- const specificModelId = `model_${type}`;
- newSettings.models[specificModelId] = {
- name: `${type.charAt(0).toUpperCase() + type.slice(1)} - ${oldModels[type]}`,
- providerId: providerId,
- modelId: oldModels[type]
- };
- newSettings.agents[type] = specificModelId;
- }
- });
- }
-
- console.log('Settings migrated:', newSettings);
- return newSettings;
-}
-
-async function loadSettings() {
- let loadedSettings = null;
-
- // Try to load from backend API (file-based) first
- try {
- const response = await apiFetch('/api/settings');
- if (response.ok) {
- loadedSettings = await response.json();
- console.log('Settings loaded from file:', loadedSettings);
- }
- } catch (e) {
- console.log('Could not load settings from backend, falling back to localStorage');
- }
-
- // Fallback to localStorage if backend is unavailable
- if (!loadedSettings) {
- const savedSettings = localStorage.getItem('agentui_settings') || localStorage.getItem('productive_settings');
- console.log('Loading settings from localStorage:', savedSettings ? 'found' : 'not found');
- if (savedSettings) {
- try {
- loadedSettings = JSON.parse(savedSettings);
- console.log('Settings loaded from localStorage:', loadedSettings);
- } catch (e) {
- console.error('Failed to parse settings:', e);
- }
- }
- }
-
- if (loadedSettings) {
- // Migrate old "notebooks" key to "agents"
- if (loadedSettings.notebooks && !loadedSettings.agents) {
- loadedSettings.agents = loadedSettings.notebooks;
- delete loadedSettings.notebooks;
- }
- // Migrate if needed
- if (!loadedSettings.settingsVersion || loadedSettings.settingsVersion < 2) {
- loadedSettings = migrateSettings(loadedSettings);
- // Save migrated settings
- try {
- await apiFetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(loadedSettings)
- });
- console.log('Migrated settings saved to file');
- } catch (e) {
- console.log('Could not save migrated settings to file');
- }
- }
- settings = { ...settings, ...loadedSettings };
- } else {
- console.log('Using default settings:', settings);
- }
-}
-
-// Generate unique ID for providers/models
-function generateId(prefix) {
- return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
-}
-
-// Render providers list in settings
-function renderProvidersList() {
- const container = document.getElementById('providers-list');
- if (!container) return;
-
- const providers = settings.providers || {};
- let html = '';
-
- Object.entries(providers).forEach(([id, provider]) => {
- html += `
-
-
- ${escapeHtml(provider.name)}
- ${escapeHtml(provider.endpoint)}
-
-
-
-
-
-
- `;
- });
-
- if (Object.keys(providers).length === 0) {
- html = 'No providers configured. Add one to get started.
';
- }
-
- container.innerHTML = html;
-}
-
-// Render models list in settings
-function renderModelsList() {
- const container = document.getElementById('models-list');
- if (!container) return;
-
- const models = settings.models || {};
- const providers = settings.providers || {};
- let html = '';
-
- Object.entries(models).forEach(([id, model]) => {
- const provider = providers[model.providerId];
- const providerName = provider ? provider.name : 'Unknown';
- html += `
-
-
- ${escapeHtml(model.name)}
- ${escapeHtml(model.modelId)} @ ${escapeHtml(providerName)}
-
-
-
-
-
-
- `;
- });
-
- if (Object.keys(models).length === 0) {
- html = 'No models configured. Add a provider first, then add models.
';
- }
-
- container.innerHTML = html;
-}
-
-// Populate model dropdowns for agent selection
-function populateModelDropdowns() {
- const models = settings.models || {};
- const agents = settings.agents || {};
-
- // Build dropdown IDs from registry + special dropdowns
- const dropdownIds = [
- ...Object.keys(AGENT_REGISTRY).map(t => `setting-agent-${t}`),
- 'setting-research-sub-agent-model',
- 'setting-image-gen-model',
- 'setting-image-edit-model'
- ];
-
- dropdownIds.forEach(dropdownId => {
- const dropdown = document.getElementById(dropdownId);
- if (!dropdown) return;
-
- // Preserve current selection
- const currentValue = dropdown.value;
-
- // Clear and rebuild options
- dropdown.innerHTML = '';
-
- Object.entries(models).forEach(([id, model]) => {
- const option = document.createElement('option');
- option.value = id;
- option.textContent = `${model.name} (${model.modelId})`;
- dropdown.appendChild(option);
- });
-
- // Restore selection
- if (currentValue && models[currentValue]) {
- dropdown.value = currentValue;
- }
- });
-
- // Set values from settings (driven by registry)
- for (const type of Object.keys(AGENT_REGISTRY)) {
- const dropdown = document.getElementById(`setting-agent-${type}`);
- if (dropdown) dropdown.value = agents[type] || '';
- }
- const subAgentDropdown = document.getElementById('setting-research-sub-agent-model');
- if (subAgentDropdown) subAgentDropdown.value = settings.researchSubAgentModel || '';
- const imageGenDropdown = document.getElementById('setting-image-gen-model');
- if (imageGenDropdown) imageGenDropdown.value = settings.imageGenModel || '';
- const imageEditDropdown = document.getElementById('setting-image-edit-model');
- if (imageEditDropdown) imageEditDropdown.value = settings.imageEditModel || '';
-}
-
-// Show add/edit provider dialog
-function showProviderDialog(providerId = null) {
- const isEdit = !!providerId;
- const provider = isEdit ? settings.providers[providerId] : { name: '', endpoint: '', token: '' };
-
- const dialog = document.getElementById('provider-dialog');
- const title = document.getElementById('provider-dialog-title');
- const nameInput = document.getElementById('provider-name');
- const endpointInput = document.getElementById('provider-endpoint');
- const tokenInput = document.getElementById('provider-token');
-
- title.textContent = isEdit ? 'Edit Provider' : 'Add Provider';
- nameInput.value = provider.name;
- endpointInput.value = provider.endpoint;
- tokenInput.value = provider.token;
-
- dialog.dataset.providerId = providerId || '';
- dialog.classList.add('active');
-}
-
-// Hide provider dialog
-function hideProviderDialog() {
- const dialog = document.getElementById('provider-dialog');
- dialog.classList.remove('active');
-}
-
-// Save provider from dialog
-function saveProviderFromDialog() {
- const dialog = document.getElementById('provider-dialog');
- const providerId = dialog.dataset.providerId || generateId('provider');
- const name = document.getElementById('provider-name').value.trim();
- const endpoint = document.getElementById('provider-endpoint').value.trim();
- const token = document.getElementById('provider-token').value.trim();
-
- if (!name || !endpoint) {
- alert('Provider name and endpoint are required');
- return;
- }
-
- settings.providers[providerId] = { name, endpoint, token };
- hideProviderDialog();
- renderProvidersList();
- populateModelDropdowns();
-}
-
-// Edit provider
-function editProvider(providerId) {
- showProviderDialog(providerId);
-}
-
-// Delete provider
-function deleteProvider(providerId) {
- // Check if any models use this provider
- const modelsUsingProvider = Object.entries(settings.models || {})
- .filter(([_, model]) => model.providerId === providerId);
-
- if (modelsUsingProvider.length > 0) {
- alert(`Cannot delete provider. ${modelsUsingProvider.length} model(s) are using it.`);
- return;
- }
-
- if (confirm('Delete this provider?')) {
- delete settings.providers[providerId];
- renderProvidersList();
- }
-}
-
-// Show add/edit model dialog
-function showModelDialog(modelId = null) {
- const isEdit = !!modelId;
- const model = isEdit ? settings.models[modelId] : { name: '', providerId: '', modelId: '', extraParams: null, multimodal: false };
-
- const dialog = document.getElementById('model-dialog');
- const title = document.getElementById('model-dialog-title');
- const nameInput = document.getElementById('model-name');
- const providerSelect = document.getElementById('model-provider');
- const modelIdInput = document.getElementById('model-model-id');
- const extraParamsInput = document.getElementById('model-extra-params');
- const multimodalCheckbox = document.getElementById('model-multimodal');
-
- title.textContent = isEdit ? 'Edit Model' : 'Add Model';
- nameInput.value = model.name;
- modelIdInput.value = model.modelId;
- extraParamsInput.value = model.extraParams ? JSON.stringify(model.extraParams, null, 2) : '';
- multimodalCheckbox.checked = !!model.multimodal;
-
- // Populate provider dropdown
- providerSelect.innerHTML = '';
- Object.entries(settings.providers || {}).forEach(([id, provider]) => {
- const option = document.createElement('option');
- option.value = id;
- option.textContent = provider.name;
- if (id === model.providerId) option.selected = true;
- providerSelect.appendChild(option);
- });
-
- dialog.dataset.modelId = modelId || '';
- dialog.classList.add('active');
-}
-
-// Hide model dialog
-function hideModelDialog() {
- const dialog = document.getElementById('model-dialog');
- dialog.classList.remove('active');
-}
-
-// Save model from dialog
-function saveModelFromDialog() {
- const dialog = document.getElementById('model-dialog');
- const modelId = dialog.dataset.modelId || generateId('model');
- const name = document.getElementById('model-name').value.trim();
- const providerId = document.getElementById('model-provider').value;
- const apiModelId = document.getElementById('model-model-id').value.trim();
- const extraParamsStr = document.getElementById('model-extra-params').value.trim();
-
- if (!name || !providerId || !apiModelId) {
- alert('Name, provider, and model ID are required');
- return;
- }
-
- // Parse extra params if provided
- let extraParams = null;
- if (extraParamsStr) {
- try {
- extraParams = JSON.parse(extraParamsStr);
- } catch (e) {
- alert('Invalid JSON in extra parameters: ' + e.message);
- return;
- }
- }
-
- const multimodal = document.getElementById('model-multimodal').checked;
- settings.models[modelId] = { name, providerId, modelId: apiModelId, extraParams, multimodal };
- hideModelDialog();
- renderModelsList();
- populateModelDropdowns();
-}
-
-// Edit model
-function editModel(modelId) {
- showModelDialog(modelId);
-}
-
-// Delete model
-function deleteModel(modelId) {
- // Check if any agents use this model
- const agentsUsingModel = Object.entries(settings.agents || {})
- .filter(([_, mid]) => mid === modelId);
-
- if (agentsUsingModel.length > 0) {
- const warning = `This model is used by: ${agentsUsingModel.map(([t]) => t).join(', ')}. Delete anyway?`;
- if (!confirm(warning)) return;
-
- // Clear the agent assignments
- agentsUsingModel.forEach(([type]) => {
- settings.agents[type] = '';
- });
- } else if (!confirm('Delete this model?')) {
- return;
- }
-
- delete settings.models[modelId];
- renderModelsList();
- populateModelDropdowns();
-}
-
-function openSettings() {
- // Show settings file path
- const pathEl = document.getElementById('settingsPath');
- if (pathEl) pathEl.textContent = settings._settingsPath || '';
-
- // Render providers and models lists
- renderProvidersList();
- renderModelsList();
- populateModelDropdowns();
-
- // Populate service keys
- document.getElementById('setting-e2b-key').value = settings.e2bKey || '';
- document.getElementById('setting-serper-key').value = settings.serperKey || '';
- document.getElementById('setting-hf-token').value = settings.hfToken || '';
-
- // Populate research settings
- document.getElementById('setting-research-parallel-workers').value = settings.researchParallelWorkers || '';
- document.getElementById('setting-research-max-websites').value = settings.researchMaxWebsites || '';
-
- // Set theme color
- const themeColor = settings.themeColor || 'forest';
- document.getElementById('setting-theme-color').value = themeColor;
-
- // Update selected theme in picker
- const themePicker = document.getElementById('theme-color-picker');
- if (themePicker) {
- themePicker.querySelectorAll('.theme-option').forEach(opt => {
- opt.classList.remove('selected');
- if (opt.dataset.theme === themeColor) {
- opt.classList.add('selected');
- }
- });
- }
-
- // Clear any status message
- const status = document.getElementById('settingsStatus');
- status.className = 'settings-status';
- status.textContent = '';
-}
-
-async function saveSettings() {
- // Get agent model selections from dropdowns (driven by registry)
- const agentModels = {};
- for (const type of Object.keys(AGENT_REGISTRY)) {
- agentModels[type] = document.getElementById(`setting-agent-${type}`)?.value || '';
- }
- const researchSubAgentModel = document.getElementById('setting-research-sub-agent-model')?.value || '';
-
- // Get other settings
- const e2bKey = document.getElementById('setting-e2b-key').value.trim();
- const serperKey = document.getElementById('setting-serper-key').value.trim();
- const hfToken = document.getElementById('setting-hf-token').value.trim();
- const imageGenModel = document.getElementById('setting-image-gen-model')?.value || '';
- const imageEditModel = document.getElementById('setting-image-edit-model')?.value || '';
- const researchParallelWorkers = document.getElementById('setting-research-parallel-workers').value.trim();
- const researchMaxWebsites = document.getElementById('setting-research-max-websites').value.trim();
- const themeColor = document.getElementById('setting-theme-color').value || 'forest';
-
- // Validate: at least one provider and one model should exist
- if (Object.keys(settings.providers || {}).length === 0) {
- showSettingsStatus('Please add at least one provider', 'error');
- return;
- }
-
- if (Object.keys(settings.models || {}).length === 0) {
- showSettingsStatus('Please add at least one model', 'error');
- return;
- }
-
- // Update settings
- settings.agents = agentModels;
- settings.e2bKey = e2bKey;
- settings.serperKey = serperKey;
- settings.hfToken = hfToken;
- settings.imageGenModel = imageGenModel;
- settings.imageEditModel = imageEditModel;
- settings.researchSubAgentModel = researchSubAgentModel;
- settings.researchParallelWorkers = researchParallelWorkers ? parseInt(researchParallelWorkers) : null;
- settings.researchMaxWebsites = researchMaxWebsites ? parseInt(researchMaxWebsites) : null;
- settings.themeColor = themeColor;
- settings.settingsVersion = 2;
-
- // Save to backend API (file-based) first
- try {
- const response = await apiFetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(settings)
- });
- if (response.ok) {
- console.log('Settings saved to file:', settings);
- } else {
- console.error('Failed to save settings to file, falling back to localStorage');
- localStorage.setItem('agentui_settings', JSON.stringify(settings));
- }
- } catch (e) {
- console.error('Could not save settings to backend, falling back to localStorage:', e);
- localStorage.setItem('agentui_settings', JSON.stringify(settings));
- }
-
- // Apply theme
- applyTheme(themeColor);
-
- // Show success message
- showSettingsStatus('Settings saved successfully', 'success');
-
- // Close settings panel and go back to command center after a short delay
- setTimeout(() => {
- const settingsPanel = document.getElementById('settingsPanel');
- const settingsBtn = document.getElementById('settingsBtn');
- const appContainer = document.querySelector('.app-container');
- if (settingsPanel) settingsPanel.classList.remove('active');
- if (settingsBtn) settingsBtn.classList.remove('active');
- if (appContainer) appContainer.classList.remove('panel-open');
- }, 1000);
-}
-
-function showSettingsStatus(message, type) {
- const status = document.getElementById('settingsStatus');
- status.textContent = message;
- status.className = `settings-status ${type}`;
-}
-
-// Theme colors mapping
-// Default light surface colors shared by all light themes
-const lightSurface = {
- bgPrimary: '#ffffff',
- bgSecondary: '#f5f5f5',
- bgTertiary: '#fafafa',
- bgInput: '#ffffff',
- bgHover: '#f0f0f0',
- bgCard: '#ffffff',
- textPrimary: '#1a1a1a',
- textSecondary: '#666666',
- textMuted: '#999999',
- borderPrimary: '#e0e0e0',
- borderSubtle: '#f0f0f0'
-};
-
-const themeColors = {
- forest: {
- border: '#1b5e20', bg: '#e8f5e9', hoverBg: '#c8e6c9',
- accent: '#1b5e20', accentRgb: '27, 94, 32',
- ...lightSurface
- },
- sapphire: {
- border: '#0d47a1', bg: '#e3f2fd', hoverBg: '#bbdefb',
- accent: '#0d47a1', accentRgb: '13, 71, 161',
- ...lightSurface
- },
- ocean: {
- border: '#00796b', bg: '#e0f2f1', hoverBg: '#b2dfdb',
- accent: '#004d40', accentRgb: '0, 77, 64',
- ...lightSurface
- },
- midnight: {
- border: '#283593', bg: '#e8eaf6', hoverBg: '#c5cae9',
- accent: '#1a237e', accentRgb: '26, 35, 126',
- ...lightSurface
- },
- steel: {
- border: '#455a64', bg: '#eceff1', hoverBg: '#cfd8dc',
- accent: '#263238', accentRgb: '38, 50, 56',
- ...lightSurface
- },
- depths: {
- border: '#01579b', bg: '#e3f2fd', hoverBg: '#bbdefb',
- accent: '#01579b', accentRgb: '1, 87, 155',
- ...lightSurface
- },
- ember: {
- border: '#b71c1c', bg: '#fbe9e7', hoverBg: '#ffccbc',
- accent: '#b71c1c', accentRgb: '183, 28, 28',
- ...lightSurface
- },
- noir: {
- border: '#888888', bg: '#1a1a1a', hoverBg: '#2a2a2a',
- accent: '#999999', accentRgb: '153, 153, 153',
- bgPrimary: '#111111',
- bgSecondary: '#1a1a1a',
- bgTertiary: '#0d0d0d',
- bgInput: '#0d0d0d',
- bgHover: '#2a2a2a',
- bgCard: '#1a1a1a',
- textPrimary: '#e0e0e0',
- textSecondary: '#999999',
- textMuted: '#666666',
- borderPrimary: '#333333',
- borderSubtle: '#222222'
- },
- eclipse: {
- border: '#5c9eff', bg: '#0d1520', hoverBg: '#162030',
- accent: '#5c9eff', accentRgb: '92, 158, 255',
- bgPrimary: '#0b1118',
- bgSecondary: '#111a25',
- bgTertiary: '#080e14',
- bgInput: '#080e14',
- bgHover: '#1a2840',
- bgCard: '#111a25',
- textPrimary: '#d0d8e8',
- textSecondary: '#7088a8',
- textMuted: '#4a6080',
- borderPrimary: '#1e2e45',
- borderSubtle: '#151f30'
- },
- terminal: {
- border: '#00cc00', bg: '#0a1a0a', hoverBg: '#0d260d',
- accent: '#00cc00', accentRgb: '0, 204, 0',
- bgPrimary: '#0a0a0a',
- bgSecondary: '#0d1a0d',
- bgTertiary: '#050505',
- bgInput: '#050505',
- bgHover: '#1a3a1a',
- bgCard: '#0d1a0d',
- textPrimary: '#00cc00',
- textSecondary: '#009900',
- textMuted: '#007700',
- borderPrimary: '#1a3a1a',
- borderSubtle: '#0d1a0d'
- }
-};
-
-function applyTheme(themeName) {
- const theme = themeColors[themeName] || themeColors.forest;
- const root = document.documentElement;
-
- // Accent colors
- root.style.setProperty('--theme-border', theme.border);
- root.style.setProperty('--theme-bg', theme.bg);
- root.style.setProperty('--theme-hover-bg', theme.hoverBg);
- root.style.setProperty('--theme-accent', theme.accent);
- root.style.setProperty('--theme-accent-rgb', theme.accentRgb);
-
- // Surface colors
- root.style.setProperty('--bg-primary', theme.bgPrimary);
- root.style.setProperty('--bg-secondary', theme.bgSecondary);
- root.style.setProperty('--bg-tertiary', theme.bgTertiary);
- root.style.setProperty('--bg-input', theme.bgInput);
- root.style.setProperty('--bg-hover', theme.bgHover);
- root.style.setProperty('--bg-card', theme.bgCard);
- root.style.setProperty('--text-primary', theme.textPrimary);
- root.style.setProperty('--text-secondary', theme.textSecondary);
- root.style.setProperty('--text-muted', theme.textMuted);
- root.style.setProperty('--border-primary', theme.borderPrimary);
- root.style.setProperty('--border-subtle', theme.borderSubtle);
-
- // Data attribute for any remaining theme-specific overrides
- document.body.setAttribute('data-theme', themeName);
-}
-
-// Export settings for use in API calls
-function getSettings() {
- return settings;
-}
-
-// Resolve model configuration for an agent type
-// Returns { endpoint, token, model, extraParams } or null if not configured
-function resolveModelConfig(agentType) {
- const modelId = settings.agents?.[agentType];
- if (!modelId) return null;
-
- const model = settings.models?.[modelId];
- if (!model) return null;
-
- const provider = settings.providers?.[model.providerId];
- if (!provider) return null;
-
- return {
- endpoint: provider.endpoint,
- token: provider.token,
- model: model.modelId,
- extraParams: model.extraParams || null,
- multimodal: !!model.multimodal
- };
-}
-
-// Get first available model config as fallback
-function getDefaultModelConfig() {
- const modelIds = Object.keys(settings.models || {});
- if (modelIds.length === 0) return null;
-
- const modelId = modelIds[0];
- const model = settings.models[modelId];
- const provider = settings.providers?.[model.providerId];
- if (!provider) return null;
-
- return {
- endpoint: provider.endpoint,
- token: provider.token,
- model: model.modelId,
- extraParams: model.extraParams || null,
- multimodal: !!model.multimodal
- };
-}
-
-// Build frontend context for API requests
-function getFrontendContext() {
- const currentThemeName = settings.themeColor || 'forest';
- const theme = themeColors[currentThemeName];
-
- return {
- theme: theme ? {
- name: currentThemeName,
- accent: theme.accent,
- bg: theme.bg,
- border: theme.border,
- bgPrimary: theme.bgPrimary,
- bgSecondary: theme.bgSecondary,
- textPrimary: theme.textPrimary,
- textSecondary: theme.textSecondary
- } : null,
- open_agents: getOpenAgentTypes()
- };
-}
-
-// Get list of open agent types
-function getOpenAgentTypes() {
- const tabs = document.querySelectorAll('.tab[data-tab-id]');
- const types = [];
- tabs.forEach(tab => {
- const tabId = tab.dataset.tabId;
- if (tabId === '0') {
- types.push('command');
- } else {
- const content = document.querySelector(`[data-content-id="${tabId}"]`);
- if (content) {
- const chatContainer = content.querySelector('.chat-container');
- if (chatContainer && chatContainer.dataset.agentType) {
- types.push(chatContainer.dataset.agentType);
- }
- }
- }
- });
- return types;
-}
-
-// Sandbox management for code agents
-async function startSandbox(tabId) {
- const currentSettings = getSettings();
- const backendEndpoint = '/api';
-
- if (!currentSettings.e2bKey) {
- console.log('No E2B key configured, skipping sandbox start');
- return;
- }
-
- // Add a status message to the agent
- const uniqueId = `code-${tabId}`;
- const chatContainer = document.getElementById(`messages-${uniqueId}`);
- if (chatContainer) {
- const statusMsg = document.createElement('div');
- statusMsg.className = 'system-message';
- statusMsg.innerHTML = '⚙️ Starting sandbox...';
- chatContainer.appendChild(statusMsg);
- }
-
- try {
- const response = await apiFetch(`${backendEndpoint}/sandbox/start`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- session_id: tabId.toString(),
- e2b_key: currentSettings.e2bKey
- })
- });
-
- const result = await response.json();
-
- // Update status message
- if (chatContainer) {
- const statusMsg = chatContainer.querySelector('.system-message');
- if (statusMsg) {
- if (result.success) {
- // Sandbox is ready - hide the message
- statusMsg.remove();
- } else {
- statusMsg.innerHTML = `⚠ Sandbox error: ${result.error}`;
- statusMsg.style.color = '#c62828';
- }
- }
- }
- } catch (error) {
- console.error('Failed to start sandbox:', error);
- if (chatContainer) {
- const statusMsg = chatContainer.querySelector('.system-message');
- if (statusMsg) {
- statusMsg.innerHTML = `⚠ Failed to start sandbox: ${error.message}`;
- statusMsg.style.color = '#c62828';
- }
- }
- }
-}
-
-async function stopSandbox(tabId) {
- const backendEndpoint = '/api';
-
- try {
- await apiFetch(`${backendEndpoint}/sandbox/stop`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- session_id: tabId.toString()
- })
- });
- } catch (error) {
- console.error('Failed to stop sandbox:', error);
- }
-}
-
-// Image modal for click-to-zoom
-function openImageModal(src) {
- // Create modal if it doesn't exist
- let modal = document.getElementById('imageModal');
- if (!modal) {
- modal = document.createElement('div');
- modal.id = 'imageModal';
- modal.style.cssText = `
- display: none;
- position: fixed;
- z-index: 10000;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.9);
- cursor: pointer;
- `;
- modal.onclick = function() {
- modal.style.display = 'none';
- };
-
- const img = document.createElement('img');
- img.id = 'imageModalContent';
- img.style.cssText = `
- margin: auto;
- display: block;
- max-width: 95%;
- max-height: 95%;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- `;
- modal.appendChild(img);
- document.body.appendChild(modal);
- }
-
- // Show modal with image
- const modalImg = document.getElementById('imageModalContent');
- modalImg.src = src;
- modal.style.display = 'block';
-}
-
-// ============= DEBUG PANEL =============
-
-const debugPanel = document.getElementById('debugPanel');
-const debugBtn = document.getElementById('debugBtn');
-const debugClose = document.getElementById('debugClose');
-const debugContent = document.getElementById('debugContent');
-
-// Toggle debug panel
-if (debugBtn) {
- debugBtn.addEventListener('click', () => {
- const isOpening = !debugPanel.classList.contains('active');
-
- // Close other panels if open
- settingsPanel.classList.remove('active');
- settingsBtn.classList.remove('active');
- if (filesPanel) filesPanel.classList.remove('active');
- if (filesBtn) filesBtn.classList.remove('active');
- appContainer.classList.remove('files-panel-open');
- const sessionsPanel = document.getElementById('sessionsPanel');
- const sessionsBtn = document.getElementById('sessionsBtn');
- if (sessionsPanel) sessionsPanel.classList.remove('active');
- if (sessionsBtn) sessionsBtn.classList.remove('active');
- appContainer.classList.remove('sessions-panel-open');
-
- // Toggle debug panel
- debugPanel.classList.toggle('active');
- debugBtn.classList.toggle('active');
-
- // Shift content when opening, unshift when closing
- if (isOpening) {
- appContainer.classList.add('panel-open');
- loadDebugMessages();
- } else {
- appContainer.classList.remove('panel-open');
- }
- });
-}
-
-// Close debug panel
-if (debugClose) {
- debugClose.addEventListener('click', () => {
- debugPanel.classList.remove('active');
- debugBtn.classList.remove('active');
- appContainer.classList.remove('panel-open');
- });
-}
-
-
-// Load debug messages from backend
-function formatDebugJson(obj) {
- /**
- * Format an object as HTML-escaped JSON, replacing base64 image data
- * with clickable placeholders that show a thumbnail on hover.
- */
- // Collect base64 images and replace with placeholders before escaping
- const images = [];
- const json = JSON.stringify(obj, null, 2);
- const placeholder = json.replace(
- /"(data:image\/[^;]+;base64,)([A-Za-z0-9+/=\n]{200,})"/g,
- (match, prefix, b64) => {
- const idx = images.length;
- const sizeKB = (b64.length * 0.75 / 1024).toFixed(1);
- images.push(prefix + b64);
- return `"__DEBUG_IMG_${idx}_${sizeKB}KB__"`;
- }
- );
- // Now HTML-escape the JSON (placeholders are safe ASCII)
- let html = escapeHtml(placeholder);
- // Replace placeholders with hoverable image thumbnails
- html = html.replace(/__DEBUG_IMG_(\d+)_([\d.]+KB)__/g, (match, idx, size) => {
- const src = images[parseInt(idx)];
- return `[image ${size}]
`;
- });
- return html;
-}
-
-function loadDebugMessages() {
- const calls = debugHistory[activeTabId] || [];
-
- if (calls.length === 0) {
- debugContent.innerHTML = 'No LLM calls recorded yet.
Send a message in this tab to see the call history here.
';
- return;
- }
-
- debugContent.innerHTML = calls.map((call, i) => {
- const isLast = i === calls.length - 1;
- const arrow = isLast ? '▼' : '▶';
- const display = isLast ? 'block' : 'none';
- const msgCount = call.input ? call.input.length : 0;
-
- const inputHtml = call.input ? formatDebugJson(call.input) : 'No input';
-
- let outputHtml;
- if (call.error) {
- outputHtml = `${escapeHtml(call.error)}`;
- } else if (call.output) {
- outputHtml = formatDebugJson(call.output);
- } else {
- outputHtml = 'Pending...';
- }
-
- return `INPUT (${msgCount} messages)
${inputHtml}OUTPUT
${outputHtml} `;
- }).join('');
-}
-
-// Toggle debug call expansion
-window.toggleDebugCall = function(index) {
- const content = document.getElementById(`call-${index}`);
- const arrow = document.getElementById(`arrow-${index}`);
- const item = document.getElementById(`callitem-${index}`);
- if (content.style.display === 'none') {
- content.style.display = 'block';
- arrow.textContent = '▼';
- item.classList.add('expanded');
- } else {
- content.style.display = 'none';
- arrow.textContent = '▶';
- item.classList.remove('expanded');
- }
-}
-
-// ============= SETTINGS PANEL =============
-
-const settingsPanel = document.getElementById('settingsPanel');
-const settingsPanelBody = document.getElementById('settingsPanelBody');
-const settingsPanelClose = document.getElementById('settingsPanelClose');
-const settingsBtn = document.getElementById('settingsBtn');
-const appContainer = document.querySelector('.app-container');
-
-
-// Open settings panel when SETTINGS button is clicked
-if (settingsBtn) {
- settingsBtn.addEventListener('click', () => {
- // Close other panels if open
- debugPanel.classList.remove('active');
- debugBtn.classList.remove('active');
- if (filesPanel) filesPanel.classList.remove('active');
- if (filesBtn) filesBtn.classList.remove('active');
- appContainer.classList.remove('files-panel-open');
- const sessionsPanel = document.getElementById('sessionsPanel');
- const sessionsBtn = document.getElementById('sessionsBtn');
- if (sessionsPanel) sessionsPanel.classList.remove('active');
- if (sessionsBtn) sessionsBtn.classList.remove('active');
- appContainer.classList.remove('sessions-panel-open');
-
- openSettings(); // Populate form fields with current values
- settingsPanel.classList.add('active');
- settingsBtn.classList.add('active');
- appContainer.classList.add('panel-open');
- });
-}
-
-// Close settings panel
-if (settingsPanelClose) {
- settingsPanelClose.addEventListener('click', () => {
- settingsPanel.classList.remove('active');
- settingsBtn.classList.remove('active');
- appContainer.classList.remove('panel-open');
- });
-}
-
-
-// ============= FILES PANEL =============
-
-const filesPanel = document.getElementById('filesPanel');
-const filesPanelClose = document.getElementById('filesPanelClose');
-const filesBtn = document.getElementById('filesBtn');
-const fileTree = document.getElementById('fileTree');
-const showHiddenFiles = document.getElementById('showHiddenFiles');
-const filesRefresh = document.getElementById('filesRefresh');
-const filesUpload = document.getElementById('filesUpload');
-
-// Track expanded folder paths to preserve state on refresh
-let expandedPaths = new Set();
-let filesRoot = '';
-
-// Load file tree from API
-async function loadFileTree() {
- const showHidden = showHiddenFiles?.checked || false;
- try {
- const response = await apiFetch(`/api/files?show_hidden=${showHidden}`);
- if (response.ok) {
- const data = await response.json();
- filesRoot = data.root;
- renderFileTree(data.tree, fileTree, data.root);
- } else {
- fileTree.innerHTML = 'Failed to load files
';
- }
- } catch (e) {
- console.error('Failed to load file tree:', e);
- fileTree.innerHTML = 'Failed to load files
';
- }
-}
-
-// Render file tree recursively
-function renderFileTree(tree, container, rootPath) {
- container.innerHTML = '';
- const rootWrapper = document.createElement('div');
- rootWrapper.className = 'file-tree-root';
-
- // Add header with folder name
- const header = document.createElement('div');
- header.className = 'file-tree-header';
- const folderName = rootPath.split('/').pop() || rootPath;
- header.textContent = './' + folderName;
- rootWrapper.appendChild(header);
-
- // Container with vertical line
- const treeContainer = document.createElement('div');
- treeContainer.className = 'file-tree-container';
- renderTreeItems(tree, treeContainer);
- rootWrapper.appendChild(treeContainer);
-
- container.appendChild(rootWrapper);
-}
-
-function renderTreeItems(tree, container) {
- const len = tree.length;
- for (let i = 0; i < len; i++) {
- const item = tree[i];
- const isLast = (i === len - 1);
-
- const itemEl = document.createElement('div');
- itemEl.className = `file-tree-item ${item.type}`;
- if (isLast) itemEl.classList.add('last');
- itemEl.dataset.path = item.path;
-
- // Check if this folder was previously expanded
- const wasExpanded = expandedPaths.has(item.path);
-
- // Create the clickable line element
- const lineEl = document.createElement('div');
- lineEl.className = 'file-tree-line';
- lineEl.draggable = true;
-
- // Only folders get an icon (arrow), files get empty icon
- const icon = item.type === 'folder' ? (wasExpanded ? '▼' : '▶') : '';
- const actionBtn = item.type === 'file'
- ? ''
- : '';
- lineEl.innerHTML = `
- ${icon}
- ${item.name}
- ${actionBtn}
- `;
- itemEl.appendChild(lineEl);
-
- // Download button (files)
- const downloadBtn = lineEl.querySelector('.file-download-btn');
- if (downloadBtn) {
- downloadBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- window.open(`/api/files/download?path=${encodeURIComponent(item.path)}${SESSION_ID ? '&session_id=' + encodeURIComponent(SESSION_ID) : ''}`, '_blank');
- });
- }
-
- // Upload button (folders)
- const uploadBtn = lineEl.querySelector('.file-upload-btn');
- if (uploadBtn) {
- uploadBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- const input = document.createElement('input');
- input.type = 'file';
- input.addEventListener('change', async () => {
- if (!input.files.length) return;
- const formData = new FormData();
- formData.append('file', input.files[0]);
- try {
- await apiFetch(`/api/files/upload?folder=${encodeURIComponent(item.path)}`, {
- method: 'POST',
- body: formData
- });
- loadFileTree();
- } catch (err) {
- console.error('Upload failed:', err);
- }
- });
- input.click();
- });
- }
-
- container.appendChild(itemEl);
-
- // Handle folder expansion
- if (item.type === 'folder' && item.children && item.children.length > 0) {
- const childrenContainer = document.createElement('div');
- childrenContainer.className = 'file-tree-children';
- if (wasExpanded) {
- childrenContainer.classList.add('expanded');
- itemEl.classList.add('expanded');
- }
- renderTreeItems(item.children, childrenContainer);
- itemEl.appendChild(childrenContainer);
-
- // Use click delay to distinguish single vs double click
- let clickTimer = null;
- lineEl.addEventListener('click', (e) => {
- e.stopPropagation();
- if (clickTimer) {
- // Double click detected - clear timer and expand/collapse
- clearTimeout(clickTimer);
- clickTimer = null;
- const isExpanded = itemEl.classList.toggle('expanded');
- childrenContainer.classList.toggle('expanded');
- const iconEl = lineEl.querySelector('.file-tree-icon');
- if (iconEl) iconEl.textContent = isExpanded ? '▼' : '▶';
- if (isExpanded) {
- expandedPaths.add(item.path);
- } else {
- expandedPaths.delete(item.path);
- }
- } else {
- // Single click - wait to see if it's a double click
- clickTimer = setTimeout(() => {
- clickTimer = null;
- insertPathIntoInput('./' + item.path);
- showClickFeedback(lineEl);
- }, 250);
- }
- });
- } else if (item.type === 'file') {
- // Single click on file inserts path
- lineEl.addEventListener('click', (e) => {
- e.stopPropagation();
- insertPathIntoInput('./' + item.path);
- showClickFeedback(lineEl);
- });
- }
-
- // Drag start handler for future drag-and-drop
- lineEl.addEventListener('dragstart', (e) => {
- e.dataTransfer.setData('text/plain', './' + item.path);
- e.dataTransfer.setData('application/x-file-path', './' + item.path);
- e.dataTransfer.effectAllowed = 'copy';
- });
- }
-}
-
-// Helper to insert path into active input
-function insertPathIntoInput(path) {
- const inputId = activeTabId === 0 ? 'input-command' : `input-${activeTabId}`;
- const inputEl = document.getElementById(inputId);
- if (inputEl) {
- const start = inputEl.selectionStart;
- const end = inputEl.selectionEnd;
- const text = inputEl.value;
- // Wrap path in backticks and add trailing space
- const formattedPath = '`' + path + '` ';
- inputEl.value = text.substring(0, start) + formattedPath + text.substring(end);
- inputEl.focus();
- inputEl.selectionStart = inputEl.selectionEnd = start + formattedPath.length;
- }
-}
-
-// Linkify inline code elements that match existing file paths
-async function linkifyFilePaths(container) {
- // Find all inline elements (not inside )
- const codeEls = [...container.querySelectorAll('code')].filter(c => !c.closest('pre'));
- if (codeEls.length === 0) return;
-
- // Collect candidate paths (must look like a file path)
- const candidates = new Map(); // normalized path -> code element(s)
- for (const code of codeEls) {
- const text = code.textContent.trim();
- if (!text || text.includes(' ') || text.length > 200) continue;
- // Must contain a dot (extension) or slash (directory)
- if (!text.includes('.') && !text.includes('/')) continue;
- const normalized = text.replace(/^\.\//, '');
- if (!candidates.has(normalized)) candidates.set(normalized, []);
- candidates.get(normalized).push(code);
- }
- if (candidates.size === 0) return;
-
- // Check which paths exist on the server
- try {
- const resp = await apiFetch('/api/files/check', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ paths: [...candidates.keys()] })
- });
- if (!resp.ok) return;
- const { existing } = await resp.json();
-
- for (const path of existing) {
- for (const code of candidates.get(path) || []) {
- if (code.closest('.file-path-link')) continue; // already linked
- const link = document.createElement('a');
- link.className = 'file-path-link';
- link.href = '#';
- link.title = 'Open in file explorer';
- link.addEventListener('click', (e) => {
- e.preventDefault();
- navigateToFileInExplorer(path);
- });
- code.parentNode.insertBefore(link, code);
- link.appendChild(code);
- }
- }
- } catch (e) {
- // Silently fail — linkification is a nice-to-have
- }
-}
-
-// Helper to show click feedback
-function showClickFeedback(el) {
- const originalColor = el.style.color;
- el.style.color = 'var(--theme-accent)';
- setTimeout(() => {
- el.style.color = originalColor;
- }, 300);
-}
-
-// Navigate to a file in the file explorer and highlight it
-function navigateToFileInExplorer(path) {
- let relPath = path.replace(/^\.\//, '');
-
- // Open files panel if not already open
- if (!filesPanel.classList.contains('active')) {
- filesBtn.click();
- }
-
- // Wait for tree to render, then expand parents and highlight
- setTimeout(() => {
- const segments = relPath.split('/');
- let currentPath = '';
- for (let i = 0; i < segments.length - 1; i++) {
- currentPath += (i > 0 ? '/' : '') + segments[i];
- const folderItem = fileTree.querySelector(`.file-tree-item[data-path="${currentPath}"]`);
- if (folderItem && !folderItem.classList.contains('expanded')) {
- folderItem.classList.add('expanded');
- const children = folderItem.querySelector('.file-tree-children');
- if (children) children.classList.add('expanded');
- const icon = folderItem.querySelector('.file-tree-icon');
- if (icon) icon.textContent = '▼';
- expandedPaths.add(currentPath);
- }
- }
- const targetItem = fileTree.querySelector(`.file-tree-item[data-path="${relPath}"]`);
- if (targetItem) {
- targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
- const line = targetItem.querySelector('.file-tree-line');
- if (line) {
- line.classList.add('file-tree-highlight');
- setTimeout(() => line.classList.remove('file-tree-highlight'), 2000);
- }
- }
- }, 500);
-}
-
-// Open files panel when FILES button is clicked
-if (filesBtn) {
- filesBtn.addEventListener('click', () => {
- const isOpening = !filesPanel.classList.contains('active');
-
- // Close other panels first
- settingsPanel.classList.remove('active');
- settingsBtn.classList.remove('active');
- debugPanel.classList.remove('active');
- debugBtn.classList.remove('active');
- appContainer.classList.remove('panel-open');
- const sessionsPanel = document.getElementById('sessionsPanel');
- const sessionsBtn = document.getElementById('sessionsBtn');
- if (sessionsPanel) sessionsPanel.classList.remove('active');
- if (sessionsBtn) sessionsBtn.classList.remove('active');
- appContainer.classList.remove('sessions-panel-open');
-
- // Toggle files panel
- filesPanel.classList.toggle('active');
- filesBtn.classList.toggle('active');
-
- // Shift content when opening, unshift when closing
- if (isOpening) {
- appContainer.classList.add('files-panel-open');
- loadFileTree();
- } else {
- appContainer.classList.remove('files-panel-open');
- }
- });
-}
-
-// Close files panel
-if (filesPanelClose) {
- filesPanelClose.addEventListener('click', () => {
- filesPanel.classList.remove('active');
- filesBtn.classList.remove('active');
- appContainer.classList.remove('files-panel-open');
- });
-}
-
-// Refresh button
-if (filesRefresh) {
- filesRefresh.addEventListener('click', () => {
- loadFileTree();
- });
-}
-
-// Upload to root directory
-if (filesUpload) {
- filesUpload.addEventListener('click', () => {
- const input = document.createElement('input');
- input.type = 'file';
- input.addEventListener('change', async () => {
- if (!input.files.length) return;
- const formData = new FormData();
- formData.append('file', input.files[0]);
- try {
- await apiFetch('/api/files/upload?folder=', { method: 'POST', body: formData });
- loadFileTree();
- } catch (err) {
- console.error('Upload failed:', err);
- }
- });
- input.click();
- });
-}
-
-// Show hidden files toggle
-if (showHiddenFiles) {
- showHiddenFiles.addEventListener('change', () => {
- loadFileTree();
- });
-}
-
-// Drag & drop upload on files panel
-if (fileTree) {
- let dragOverFolder = null;
-
- fileTree.addEventListener('dragover', (e) => {
- // Only handle external file drops (not internal path drags)
- if (!e.dataTransfer.types.includes('Files')) return;
- e.preventDefault();
- e.dataTransfer.dropEffect = 'copy';
-
- // Find folder under cursor
- const folderItem = e.target.closest('.file-tree-item.folder');
- if (folderItem) {
- if (dragOverFolder !== folderItem) {
- if (dragOverFolder) dragOverFolder.classList.remove('drag-over');
- fileTree.classList.remove('drag-over-root');
- folderItem.classList.add('drag-over');
- dragOverFolder = folderItem;
- }
- } else {
- if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
- fileTree.classList.add('drag-over-root');
- }
- });
-
- fileTree.addEventListener('dragleave', (e) => {
- // Only clear when leaving the fileTree entirely
- if (!fileTree.contains(e.relatedTarget)) {
- if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
- fileTree.classList.remove('drag-over-root');
- }
- });
-
- fileTree.addEventListener('drop', async (e) => {
- if (!e.dataTransfer.files.length) return;
- e.preventDefault();
-
- // Determine target folder
- const folderItem = e.target.closest('.file-tree-item.folder');
- const folder = folderItem ? folderItem.dataset.path : '';
-
- // Clear highlights
- if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
- fileTree.classList.remove('drag-over-root');
-
- // Upload all files
- for (const file of e.dataTransfer.files) {
- const formData = new FormData();
- formData.append('file', file);
- try {
- await apiFetch(`/api/files/upload?folder=${encodeURIComponent(folder)}`, { method: 'POST', body: formData });
- } catch (err) {
- console.error('Upload failed:', err);
- }
- }
- loadFileTree();
- });
-}
-
-// Sessions panel (same pattern as Files/Settings/Debug panels)
-const sessionsPanel = document.getElementById('sessionsPanel');
-const sessionsPanelClose = document.getElementById('sessionsPanelClose');
-const sessionsBtn = document.getElementById('sessionsBtn');
-
-if (sessionsBtn && sessionsPanel) {
- sessionsBtn.addEventListener('click', () => {
- const isOpening = !sessionsPanel.classList.contains('active');
-
- // Close other panels first
- settingsPanel.classList.remove('active');
- settingsBtn.classList.remove('active');
- debugPanel.classList.remove('active');
- debugBtn.classList.remove('active');
- filesPanel.classList.remove('active');
- if (filesBtn) filesBtn.classList.remove('active');
- appContainer.classList.remove('panel-open');
- appContainer.classList.remove('files-panel-open');
-
- // Toggle sessions panel
- sessionsPanel.classList.toggle('active');
- sessionsBtn.classList.toggle('active');
-
- // Shift content when opening, unshift when closing
- if (isOpening) {
- appContainer.classList.add('sessions-panel-open');
- refreshSessionsList();
- } else {
- appContainer.classList.remove('sessions-panel-open');
- }
- });
-}
-
-if (sessionsPanelClose) {
- sessionsPanelClose.addEventListener('click', () => {
- sessionsPanel.classList.remove('active');
- sessionsBtn.classList.remove('active');
- appContainer.classList.remove('sessions-panel-open');
- });
-}
-