agent-ui / frontend /timeline.js
lvwerra's picture
lvwerra HF Staff
Split frontend script.js (5460 lines) into 8 focused modules
78f4d62
// 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';
}
}