Spaces:
Running
Running
| // 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 = ` | |
| <span class="tab-title">${title}</span> | |
| <span class="tab-status" style="display: none;"><span></span><span></span><span></span></span> | |
| <span class="tab-close">×</span> | |
| `; | |
| // 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 = `<div class="tl-widget${isNested ? '' : ' compact'}${isActive ? ' active' : ''}${isClosed ? ' closed' : ''}" data-tab-id="${tabId}">`; | |
| if (!isNested) { | |
| // Workspace header - left edge aligned with vertical line | |
| html += `<div class="workspace-block"> | |
| <div class="workspace-label">PROJECT</div> | |
| <div class="workspace-name">${escapeHtml(notebook.title)}</div> | |
| </div>`; | |
| } | |
| html += `<div class="tl">`; | |
| // Group events: consecutive assistant events form a group | |
| const groups = []; | |
| for (const event of notebook.events) { | |
| if (event.type === 'assistant') { | |
| const lastGroup = groups[groups.length - 1]; | |
| if (lastGroup && lastGroup.type === 'assistant') { | |
| lastGroup.events.push(event); | |
| } else { | |
| groups.push({ type: 'assistant', events: [event] }); | |
| } | |
| } else { | |
| groups.push({ type: event.type, events: [event] }); | |
| } | |
| } | |
| // Render groups | |
| for (const group of groups) { | |
| if (group.type === 'assistant') { | |
| if (!showAllTurns && group.events.length > 1) { | |
| // Collapsed: single summary dot with turn count label — use first event's index | |
| const firstEvent = group.events[0]; | |
| html += ` | |
| <div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${firstEvent.index}"> | |
| <div class="tl-dot"></div> | |
| <span class="tl-turn-count">${group.events.length} turns</span> | |
| </div>`; | |
| } else { | |
| // Expanded or single: render each dot individually | |
| for (const event of group.events) { | |
| if (event.meta?.tag) { | |
| html += ` | |
| <div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${event.index}"> | |
| <div class="tl-dot"></div> | |
| <div class="tl-tool"><span class="tl-tool-tag">${event.meta.tag}</span>${event.content ? `<span class="tl-tool-text">${escapeHtml(event.content)}</span>` : ''}</div> | |
| </div>`; | |
| } else { | |
| html += ` | |
| <div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${event.index}"> | |
| <div class="tl-dot"></div> | |
| <span class="tl-label">${escapeHtml(event.content)}</span> | |
| </div>`; | |
| } | |
| } | |
| } | |
| } else if (group.type === 'user') { | |
| const event = group.events[0]; | |
| html += ` | |
| <div class="tl-row turn user" data-tab-id="${tabId}" data-event-index="${event.index}"> | |
| <div class="tl-dot"></div> | |
| <span class="tl-label">${escapeHtml(event.content)}</span> | |
| </div>`; | |
| } else if (group.type === 'agent') { | |
| const event = group.events[0]; | |
| if (event.childTabId !== null) { | |
| const childNotebook = timelineData[event.childTabId]; | |
| if (childNotebook) { | |
| const childTypeLabel = getTypeLabel(childNotebook.type); | |
| const childIsGenerating = childNotebook.isGenerating; | |
| const turnCount = childNotebook.events.length; | |
| const hasEvents = childNotebook.events.length > 0; | |
| const isCollapsed = collapsedAgents.has(String(event.childTabId)); | |
| const isChildActive = activeTabId === event.childTabId; | |
| html += ` | |
| <div class="tl-row has-agent${hasEvents ? ' has-nested' : ''}" data-tab-id="${event.childTabId}" data-event-index="${event.index}"> | |
| <div class="tl-dot"></div> | |
| <div class="agent-box${isChildActive ? ' active' : ''}" data-tab-id="${event.childTabId}"> | |
| <div class="agent-header"> | |
| ${hasEvents ? `<div class="collapse-toggle${isCollapsed ? ' collapsed' : ''}"></div>` : ''} | |
| <span>${childTypeLabel}</span> | |
| </div> | |
| <div class="agent-body"> | |
| <span class="agent-body-text">${escapeHtml(childNotebook.title)}</span> | |
| <div class="agent-status"> | |
| <span>${turnCount} turns</span> | |
| ${childIsGenerating ? ` | |
| <div class="agent-progress"><span></span><span></span><span></span></div> | |
| ` : ` | |
| <div class="agent-done${childNotebook.aborted ? ' aborted' : ''}"></div> | |
| `} | |
| </div> | |
| </div> | |
| </div> | |
| </div>`; | |
| // Render nested timeline if child has events | |
| if (hasEvents) { | |
| const isComplete = !childIsGenerating; | |
| html += ` | |
| <div class="tl-nested${isComplete ? ' complete' : ''}${isCollapsed ? ' collapsed' : ''}" data-child-tab-id="${event.childTabId}"> | |
| ${renderAgentTimeline(event.childTabId, childNotebook, true)} | |
| </div>`; | |
| // Return row with dot on parent line - only when subagent is complete | |
| if (isComplete) { | |
| html += ` | |
| <div class="tl-row tl-return"> | |
| <div class="tl-dot"></div> | |
| <div class="tl-return-connector"></div> | |
| </div>`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Show generating indicator if currently generating and no events yet | |
| if (notebook.isGenerating && notebook.events.length === 0) { | |
| html += ` | |
| <div class="tl-row turn"> | |
| <div class="tl-dot generating"></div> | |
| </div>`; | |
| } | |
| html += `</div></div>`; | |
| 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'; | |
| } | |
| } | |