| |
| function addTimelineEvent(tabId, eventType, content, childTabId = null, meta = null) { |
| if (!timelineData[tabId]) { |
| timelineData[tabId] = { type: 'unknown', title: 'Unknown', events: [], parentTabId: null, isGenerating: false }; |
| } |
|
|
| |
| const preview = content.length > 80 ? content.substring(0, 80) + '...' : content; |
|
|
| const eventIndex = timelineData[tabId].events.length; |
| timelineData[tabId].events.push({ |
| type: eventType, |
| content: preview, |
| childTabId: childTabId, |
| meta: meta, |
| timestamp: Date.now(), |
| index: eventIndex, |
| }); |
|
|
| renderTimeline(); |
| return eventIndex; |
| } |
|
|
| |
| function registerAgentInTimeline(tabId, type, title, parentTabId = null) { |
| timelineData[tabId] = { |
| type: type, |
| title: title, |
| events: [], |
| parentTabId: parentTabId, |
| isGenerating: false |
| }; |
|
|
| |
| if (parentTabId !== null && timelineData[parentTabId]) { |
| addTimelineEvent(parentTabId, 'agent', title, tabId); |
| } |
|
|
| renderTimeline(); |
| } |
|
|
| |
| function setTimelineGenerating(tabId, isGenerating) { |
| if (timelineData[tabId]) { |
| timelineData[tabId].isGenerating = isGenerating; |
| renderTimeline(); |
| } |
| } |
|
|
| |
| function updateTimelineTitle(tabId, title) { |
| if (timelineData[tabId]) { |
| timelineData[tabId].title = title; |
| renderTimeline(); |
| } |
| } |
|
|
| |
| function removeFromTimeline(tabId) { |
| |
| 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(); |
| } |
|
|
| |
| function openOrSwitchToTab(tabId) { |
| |
| const existingTab = document.querySelector(`.tab[data-tab-id="${tabId}"]`); |
| if (existingTab) { |
| |
| switchToTab(tabId); |
| } else { |
| |
| const notebook = timelineData[tabId]; |
| if (notebook) { |
| reopenClosedTab(tabId, notebook); |
| } |
| } |
| } |
|
|
| |
| function reopenClosedTab(tabId, notebook) { |
| const type = notebook.type; |
| const title = notebook.savedTitle || notebook.title; |
|
|
| |
| 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> |
| `; |
|
|
| |
| const dynamicTabs = document.getElementById('dynamicTabs'); |
| dynamicTabs.appendChild(tab); |
|
|
| |
| const content = document.createElement('div'); |
| content.className = 'tab-content'; |
| content.dataset.contentId = tabId; |
|
|
| if (notebook.savedContent) { |
| |
| content.innerHTML = notebook.savedContent; |
| } else { |
| |
| content.innerHTML = createAgentContent(type, tabId, title); |
| } |
|
|
| document.querySelector('.main-content').appendChild(content); |
|
|
| |
| notebook.isClosed = false; |
| delete notebook.savedContent; |
| delete notebook.savedTitle; |
|
|
| |
| switchToTab(tabId); |
|
|
| |
| if (type !== 'command-center') { |
| setupInputListeners(content, tabId); |
|
|
| |
| if (type === 'code') { |
| startSandbox(tabId); |
| } |
| } |
|
|
| |
| renderTimeline(); |
|
|
| |
| saveWorkspaceDebounced(); |
| } |
|
|
| |
| function renderTimeline() { |
| const sidebarContent = document.getElementById('sidebarAgents'); |
| if (!sidebarContent) return; |
|
|
| |
| 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; |
|
|
| |
| updateTimelineLines(); |
|
|
| |
| 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(); |
| const clickTabId = parseInt(box.dataset.tabId); |
| openOrSwitchToTab(clickTabId); |
| }); |
| }); |
|
|
| |
| 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(); |
| |
| 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'); |
| |
| if (childTabId) { |
| if (nested.classList.contains('collapsed')) { |
| collapsedAgents.add(childTabId); |
| } else { |
| collapsedAgents.delete(childTabId); |
| } |
| } |
| updateTimelineLines(); |
| } |
| } |
| }); |
| }); |
| } |
|
|
| |
| 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) { |
| |
| html += `<div class="workspace-block"> |
| <div class="workspace-label">PROJECT</div> |
| <div class="workspace-name">${escapeHtml(notebook.title)}</div> |
| </div>`; |
| } |
|
|
| html += `<div class="tl">`; |
|
|
| |
| 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] }); |
| } |
| } |
|
|
| |
| for (const group of groups) { |
| if (group.type === 'assistant') { |
| if (!showAllTurns && group.events.length > 1) { |
| |
| 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 { |
| |
| 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>`; |
|
|
| |
| 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>`; |
| |
| if (isComplete) { |
| html += ` |
| <div class="tl-row tl-return"> |
| <div class="tl-dot"></div> |
| <div class="tl-return-connector"></div> |
| </div>`; |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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'); |
| }); |
|
|
| |
| 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; |
|
|
| |
| returnRow.style.top = '0'; |
|
|
| |
| const agentRow = nested.previousElementSibling; |
| const agentBox = agentRow?.querySelector('.agent-box'); |
|
|
| if (isCollapsed && agentBox) { |
| |
| const agentBoxRect = agentBox.getBoundingClientRect(); |
| const returnDotRect = returnDot.getBoundingClientRect(); |
|
|
| |
| const yOffset = agentBoxRect.bottom - returnDotRect.top - 3; |
| returnRow.style.top = yOffset + 'px'; |
|
|
| if (connector) { |
| |
| const connectorWidth = agentBoxRect.left - returnDotRect.right; |
| connector.style.width = Math.max(0, connectorWidth) + 'px'; |
| connector.style.background = 'var(--theme-accent)'; |
| } |
| } else { |
| |
| 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(); |
|
|
| |
| const yOffset = lastNestedRect.top - returnDotRect.top; |
| returnRow.style.top = yOffset + 'px'; |
|
|
| |
| if (connector) { |
| const nestedTlRect = nestedTl.getBoundingClientRect(); |
| |
| 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(); |
|
|
| |
| 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'; |
| } |
| } |
|
|