agent-ui / frontend /tabs.js
lvwerra's picture
lvwerra HF Staff
Fix tool message handling, parallel image refs, error display, and UX polish
4da424f
Raw
History Blame Contribute Delete
20.3 kB
function createAgentTab(type, initialMessage = null, autoSwitch = true, taskId = null, parentTabId = null) {
const tabId = tabCounter++;
// Use task_id if provided, otherwise generate default title
let title;
if (taskId) {
// Convert dashes to spaces and title case for display
title = taskId;
// Register this agent for task_id reuse
taskIdToTabId[taskId] = tabId;
} else if (type !== 'command-center') {
agentCounters[type]++;
title = `New ${type} task`;
} else {
title = getTypeLabel(type);
}
// Register in timeline
registerAgentInTimeline(tabId, type, title, parentTabId);
// Create tab
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);
// Hide tab for subagents until the user clicks on them
if (!autoSwitch && parentTabId !== null) {
tab.style.display = 'none';
}
// Create content
const content = document.createElement('div');
content.className = 'tab-content';
content.dataset.contentId = tabId;
content.innerHTML = createAgentContent(type, tabId, title);
document.querySelector('.main-content').appendChild(content);
// Only switch to new tab if autoSwitch is true
if (autoSwitch) {
switchToTab(tabId);
}
// Add event listeners for the new content
if (type !== 'command-center') {
setupInputListeners(content, tabId);
const input = content.querySelector('textarea');
// If this is a code agent, start the sandbox proactively
if (type === 'code') {
startSandbox(tabId);
}
// If there's an initial message, automatically send it
if (initialMessage && input) {
input.value = initialMessage;
// Small delay to ensure everything is set up
setTimeout(() => {
sendMessage(tabId);
}, 100);
}
}
// Save workspace state after creating new tab
saveWorkspaceDebounced();
return tabId; // Return the tabId so we can reference it
}
function createAgentContent(type, tabId, title = null) {
if (type === 'command-center') {
return document.querySelector('[data-content-id="0"]').innerHTML;
}
// Use unique ID combining type and tabId to ensure unique container IDs
const uniqueId = `${type}-${tabId}`;
// Display title or default
const displayTitle = title || `New ${type} task`;
return `
<div class="agent-interface">
<div class="agent-header">
<div>
<div class="agent-type">${getTypeLabel(type)}</div>
<h2>${escapeHtml(displayTitle)}</h2>
</div>
</div>
<div class="agent-body">
<div class="chat-container" id="messages-${uniqueId}" data-agent-type="${type}">
</div>
</div>
<div class="input-area">
<div class="input-container">
<textarea placeholder="${getPlaceholder(type)}" id="input-${uniqueId}" rows="1"></textarea>
<button>SEND</button>
</div>
</div>
</div>
`;
}
function switchToTab(tabId) {
// Deactivate all tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// Activate selected tab (use .tab selector to avoid matching timeline elements)
const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
const content = document.querySelector(`.tab-content[data-content-id="${tabId}"]`);
if (content) {
// For settings, there's no tab, just show the content
if (tabId === 'settings') {
content.classList.add('active');
activeTabId = tabId;
} else if (tab) {
tab.style.display = '';
tab.classList.add('active');
content.classList.add('active');
activeTabId = tabId;
// Save workspace state after switching tabs
saveWorkspaceDebounced();
}
}
// Update timeline to reflect active tab
renderTimeline();
}
function closeTab(tabId) {
if (tabId === 0) return; // Can't close command center
const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
const content = document.querySelector(`[data-content-id="${tabId}"]`);
if (tab && content) {
// Check if this is a code agent and stop its sandbox
const chatContainer = content.querySelector('.chat-container');
const agentType = chatContainer?.dataset.agentType || 'chat';
if (agentType === 'code') {
stopSandbox(tabId);
}
// Save the tab's content BEFORE removing from DOM so we can restore it later
if (timelineData[tabId]) {
timelineData[tabId].savedContent = content.innerHTML;
timelineData[tabId].savedTitle = tab.querySelector('.tab-title')?.textContent;
}
// Clean up task_id mapping
for (const [taskId, mappedTabId] of Object.entries(taskIdToTabId)) {
if (mappedTabId === tabId) {
delete taskIdToTabId[taskId];
break;
}
}
tab.remove();
content.remove();
// Mark as closed in timeline (don't remove - allow reopening)
if (timelineData[tabId]) {
timelineData[tabId].isClosed = true;
renderTimeline();
}
// Switch to command center if closing active tab
if (activeTabId === tabId) {
switchToTab(0);
}
// Save workspace state after closing tab
saveWorkspaceDebounced();
}
}
function showProgressWidget(chatContainer) {
// Remove any existing progress widget
hideProgressWidget(chatContainer);
const widget = document.createElement('div');
widget.className = 'progress-widget';
widget.innerHTML = `
<div class="progress-spinner">
<span></span>
<span></span>
<span></span>
</div>
<span class="progress-text">Generating...</span>
`;
chatContainer.appendChild(widget);
scrollChatToBottom(chatContainer, true);
return widget;
}
function hideProgressWidget(chatContainer) {
const widget = chatContainer.querySelector('.progress-widget');
if (widget) {
widget.remove();
}
}
function scrollChatToBottom(chatContainer, force = false) {
// The actual scrolling container is .agent-body
const agentBody = chatContainer.closest('.agent-body');
if (!agentBody) return;
// If not forced, only scroll if user is already near the bottom
if (!force) {
const distanceFromBottom = agentBody.scrollHeight - agentBody.scrollTop - agentBody.clientHeight;
if (distanceFromBottom > 150) return;
}
agentBody.scrollTop = agentBody.scrollHeight;
}
function scrollToTimelineEvent(tabId, eventIndex) {
if (eventIndex === undefined || eventIndex === null) return;
// Find the tab's content area and chat container
const content = document.querySelector(`[data-content-id="${tabId}"]`);
if (!content) return;
const chatContainer = content.querySelector('.chat-container');
if (!chatContainer) return;
// Find the chat element tagged with this timeline index
const target = chatContainer.querySelector(`[data-timeline-index="${eventIndex}"]`);
if (!target) return;
// Scroll the agent-body so the target is near the top
const agentBody = chatContainer.closest('.agent-body');
if (!agentBody) return;
// Use a small delay to ensure tab switch has rendered
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
async function abortAgent(tabId) {
// Abort the frontend fetch
const controller = activeAbortControllers[tabId];
if (controller) {
controller.abort();
}
// For command center (tab 0): also abort all generating child tabs
if (tabId === 0) {
for (const [childId, td] of Object.entries(timelineData)) {
if (td.parentTabId === 0 && td.isGenerating) {
// Abort frontend fetch
const childController = activeAbortControllers[childId];
if (childController) {
childController.abort();
}
// Abort backend agent
try {
await apiFetch('/api/abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_id: childId.toString() })
});
} catch (e) { /* ignore */ }
}
}
// Unblock command center input and restore SEND button
commandInputBlocked = false;
pendingAgentLaunches = 0;
setCommandCenterStopState(false);
const commandInput = document.getElementById('input-command');
if (commandInput) {
commandInput.disabled = false;
}
}
// Tell the backend to set the abort flag for this agent
try {
await apiFetch('/api/abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_id: tabId.toString() })
});
} catch (e) {
// Ignore abort endpoint errors
}
}
async function sendMessage(tabId) {
// If tab is currently generating, abort instead of sending
// For command center: also abort if input is blocked (sub-agents still running)
if (timelineData[tabId]?.isGenerating || (tabId === 0 && commandInputBlocked)) {
abortAgent(tabId);
return;
}
const content = document.querySelector(`[data-content-id="${tabId}"]`);
if (!content) return;
// Support both textarea and input
const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
const chatContainer = content.querySelector('.chat-container');
if (!input || !chatContainer) return;
const message = input.value.trim();
if (!message) return;
// Remove welcome message if it exists (only on first user message)
const welcomeMsg = chatContainer.querySelector('.welcome-message');
const isFirstMessage = welcomeMsg !== null;
if (welcomeMsg) {
welcomeMsg.remove();
}
// Add user message
const userMsg = document.createElement('div');
userMsg.className = 'message user';
userMsg.innerHTML = `<div class="message-content">${parseMarkdown(message.trim())}</div>`;
linkifyFilePaths(userMsg);
chatContainer.appendChild(userMsg);
// Add to timeline and tag DOM element for scroll-to-turn
const evIdx = addTimelineEvent(tabId, 'user', message);
userMsg.dataset.timelineIndex = evIdx;
// Scroll the agent body (the actual scrolling container) to bottom
const agentBody = chatContainer.closest('.agent-body');
if (agentBody) {
agentBody.scrollTop = agentBody.scrollHeight;
}
// Show progress widget while waiting for response
showProgressWidget(chatContainer);
// Generate a title for the agent if this is the first message and not command center
if (isFirstMessage && tabId !== 0) {
generateAgentTitle(tabId, message);
}
// Clear input and disable it during processing
input.value = '';
input.style.height = 'auto'; // Reset textarea height
input.disabled = true;
// Set tab to generating state
setTabGenerating(tabId, true);
// Determine agent type from chat container ID
const agentType = getAgentTypeFromContainer(chatContainer);
// Send full conversation history for all agent types (stateless backend)
const messages = getConversationHistory(chatContainer);
// Stream response from backend
await streamChatResponse(messages, chatContainer, agentType, tabId);
// Re-enable input and mark generation as complete
setTabGenerating(tabId, false);
if (tabId === 0) {
// Command center: keep input blocked if launched agents are still running or pending
const anyChildGenerating = Object.values(timelineData).some(
td => td.parentTabId === 0 && td.isGenerating
);
if (anyChildGenerating || pendingAgentLaunches > 0) {
commandInputBlocked = true;
// Keep STOP button visible and input disabled while children run
input.disabled = true;
setCommandCenterStopState(true);
} else {
input.disabled = false;
input.focus();
}
} else {
input.disabled = false;
input.focus();
}
// Save workspace state after message exchange completes
saveWorkspaceDebounced();
}
function setCommandCenterStopState(showStop) {
const content = document.querySelector('[data-content-id="0"]');
if (!content) return;
const sendBtn = content.querySelector('.input-container button');
if (!sendBtn) return;
if (showStop) {
sendBtn.textContent = 'STOP';
sendBtn.classList.add('stop-btn');
sendBtn.disabled = false;
} else {
sendBtn.textContent = 'SEND';
sendBtn.classList.remove('stop-btn');
}
}
async function continueCommandCenter() {
// Called when all launched agents finish — re-run command center with actual results in history
const chatContainer = document.getElementById('messages-command');
const commandInput = document.getElementById('input-command');
if (!chatContainer) return;
setTabGenerating(0, true);
showProgressWidget(chatContainer);
const messages = getConversationHistory(chatContainer);
await streamChatResponse(messages, chatContainer, 'command', 0);
setTabGenerating(0, false);
// Check if new agents were launched (recursive blocking)
const anyChildGenerating = Object.values(timelineData).some(
td => td.parentTabId === 0 && td.isGenerating
);
if (anyChildGenerating || pendingAgentLaunches > 0) {
commandInputBlocked = true;
if (commandInput) commandInput.disabled = true;
setCommandCenterStopState(true);
} else if (commandInput) {
commandInput.disabled = false;
commandInput.focus();
}
saveWorkspaceDebounced();
}
async function generateAgentTitle(tabId, query) {
const currentSettings = getSettings();
const backendEndpoint = '/api';
const llmEndpoint = currentSettings.endpoint || 'https://api.openai.com/v1';
const modelToUse = currentSettings.model || 'gpt-4';
try {
const response = await apiFetch(`${backendEndpoint}/generate-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
endpoint: llmEndpoint,
token: currentSettings.token || null,
model: modelToUse
})
});
if (response.ok) {
const result = await response.json();
const title = result.title;
// Update the tab title
const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
if (tab) {
const titleEl = tab.querySelector('.tab-title');
if (titleEl) {
titleEl.textContent = title.toUpperCase();
// Save workspace after title update
saveWorkspaceDebounced();
// Update timeline to reflect new title
updateTimelineTitle(tabId, title.toUpperCase());
}
}
}
} catch (error) {
console.error('Failed to generate title:', error);
// Don't show error to user, just keep the default title
}
}
function getAgentTypeFromContainer(chatContainer) {
// Try to get type from data attribute first (for dynamically created agents)
const typeFromData = chatContainer.dataset.agentType;
if (typeFromData) {
return typeFromData;
}
// Fallback: Extract agent type from the container ID (e.g., "messages-command" -> "command")
const containerId = chatContainer.id;
if (containerId && containerId.startsWith('messages-')) {
const type = containerId.replace('messages-', '');
// Map to agent type
if (type === 'command') return 'command';
if (type.startsWith('agent')) return 'agent';
if (type.startsWith('code')) return 'code';
if (type.startsWith('research')) return 'research';
if (type.startsWith('chat')) return 'chat';
}
return 'command'; // Default fallback
}
function getConversationHistory(chatContainer) {
const messages = [];
const messageElements = chatContainer.querySelectorAll('.message');
messageElements.forEach(msg => {
if (msg.classList.contains('user')) {
// User messages use .message-content
const contentEl = msg.querySelector('.message-content');
const content = contentEl?.textContent.trim() || msg.textContent.trim();
if (content) {
messages.push({ role: 'user', content: content });
}
} else if (msg.classList.contains('assistant')) {
// Assistant messages use .message-content
const contentEl = msg.querySelector('.message-content');
const content = contentEl?.textContent.trim();
// Check if this message has a tool call
const toolCallData = msg.getAttribute('data-tool-call');
if (toolCallData) {
const toolCall = JSON.parse(toolCallData);
let funcName, funcArgs;
if (toolCall.function_name) {
// Agent-style tool call (web_search, read_url, etc.)
funcName = toolCall.function_name;
funcArgs = toolCall.arguments;
} else {
// Command center-style tool call (launch_*_agent)
funcName = `launch_${toolCall.agent_type}_agent`;
funcArgs = JSON.stringify({
task: toolCall.message,
topic: toolCall.message,
message: toolCall.message
});
}
messages.push({
role: 'assistant',
content: toolCall.thinking || content || '',
tool_calls: [{
id: toolCall.tool_call_id || 'tool_' + Date.now(),
type: 'function',
function: {
name: funcName,
arguments: funcArgs
}
}]
});
} else if (content && !content.includes('msg-') && !content.includes('→ Launched')) {
// Regular assistant message (exclude launch notifications)
messages.push({ role: 'assistant', content: content });
}
} else if (msg.classList.contains('tool')) {
// Tool response message
const toolResponseData = msg.getAttribute('data-tool-response');
if (toolResponseData) {
const toolResponse = JSON.parse(toolResponseData);
messages.push({
role: 'tool',
tool_call_id: toolResponse.tool_call_id,
content: toolResponse.content
});
}
}
});
return messages;
}