// 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') { setupInputListeners(content, 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'; } }