// ============================================ // 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); } } // Restore globalFigureRegistry from saved code-cell images // so that tags in results resolve after reload for (const tabData of tabs) { for (const msg of (tabData.messages || [])) { if (msg.type === 'code-cell' && msg.images) { for (const img of msg.images) { if (img.name && img.src) { // Parse data URL: "data:image/png;base64,..." -> {type: "png", data: "..."} const m = img.src.match(/^data:image\/(\w+);base64,(.+)$/); if (m) { globalFigureRegistry[img.name] = { type: m[1], data: m[2] }; } } } } } } // Re-resolve any figure refs in rendered HTML now that the registry is populated if (Object.keys(globalFigureRegistry).length > 0) { document.querySelectorAll('.action-widget-result-section .section-content, .result-preview-content, .result-content').forEach(el => { const resolved = resolveGlobalFigureRefs(el.innerHTML); if (resolved !== el.innerHTML) { el.innerHTML = resolved; } }); } // 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 setupInputListeners(content, 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 = `
${escapeHtml(msg.label || '')}${toolDesc}
`; toolCellHtml += `
${escapeHtml(msg.input || '')}
`; if (msg.outputHtml) { toolCellHtml += `
${msg.outputHtml}
`; } toolCell.innerHTML = toolCellHtml; setupCollapseToggle(toolCell, '.tool-cell-label'); 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 += `
${data.resultHtml || escapeHtml(data.result || '')}
`; } widget.innerHTML = `
TASK: ${(data.actionType || '').toUpperCase()}
${indicatorHtml}
${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 = `
Code
${escapeHtml(data.code || '')}
${outputHtml} ${imagesHtml} `; setupCollapseToggle(cell, '.code-cell-label'); 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 = `
Result
${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 = `
RESEARCH
${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); }