Files
2026-06-17 14:45:42 +08:00

3570 lines
131 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DeepResearch Frontend with WebSocket Streaming
// WebSocket connection
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
// Session management - only restore from URL params (fork links), not localStorage
let sessionId = new URLSearchParams(window.location.search).get('session_id') || null;
// State
let currentTab = 'sessions';
let configData = null;
let toolStats = { call_count: 0, tools_used: {}, total_duration_ms: 0 };
// Excalidraw state
let excalidrawCanvasShown = false;
let excalidrawCanvasUrl = null;
// Background agents counter
let backgroundAgentCount = 0;
const backgroundAgentTasks = new Set(); // track task IDs
// DOM Elements
const messagesContainer = document.getElementById('messages');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const filesList = document.getElementById('files-list');
// Smart auto-scroll: only scrolls if user is near the bottom
function autoScroll() {
const threshold = 150;
const atBottom = messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < threshold;
if (atBottom) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
// Force scroll: always scrolls to bottom (used for new user messages, initial load)
function forceScroll() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Replay flag — suppresses animations and auto-scroll during replay
let isReplay = false;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
setupResizer();
connectWebSocket();
refreshFiles();
loadExcalidrawConfig();
loadSessionsList();
if (sessionId) {
loadSession(sessionId);
}
});
// Load Excalidraw canvas URL from config at startup
async function loadExcalidrawConfig() {
try {
const response = await fetch('/config');
if (response.ok) {
const raw = await response.json();
const cfg = raw.features || raw;
if (cfg.excalidraw_canvas_url) {
excalidrawCanvasUrl = cfg.excalidraw_canvas_url;
}
}
} catch { /* config not available yet */ }
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/chat`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
updateConnectionStatus(true);
// Send session join immediately so backend can set up canvas isolation
if (sessionId) {
ws.send(JSON.stringify({ session_id: sessionId }));
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
updateConnectionStatus(false);
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
setTimeout(connectWebSocket, 2000 * reconnectAttempts);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onmessage = handleWebSocketMessage;
}
async function loadSession(sid) {
if (!sid) return;
try {
// Try JSONL event replay first (full fidelity)
const resp = await fetch(`/sessions/${encodeURIComponent(sid)}/events`);
if (resp.ok) {
const data = await resp.json();
if (data.events && data.events.length > 0) {
await replayEvents(data.events);
return;
}
}
// Fallback to old history endpoint
const histResp = await fetch(`/history?session_id=${encodeURIComponent(sid)}`);
if (!histResp.ok) return;
const histData = await histResp.json();
if (histData.messages && histData.messages.length > 0) {
replayHistoryMessages(histData.messages);
}
} catch (e) {
console.error('Failed to load session:', e);
}
}
async function replayEvents(events) {
isReplay = true;
// Clear messages but keep welcome banner hidden
messagesContainer.innerHTML = '';
// Filter to important events for replay (skip noisy deltas)
const replayableTypes = new Set([
'session_created', 'user_message', 'start', 'tool_call_start', 'tool_start',
'tool_output', 'text_delta', 'thinking_delta', 'response', 'todos_update',
'done', 'cancelled', 'error', 'approval_required', 'ask_user_question',
'checkpoint_saved', 'checkpoint_rewind', 'background_task_completed',
'report_updated',
]);
for (const event of events) {
if (!replayableTypes.has(event.type)) continue;
// For user messages, render as user bubble
if (event.type === 'user_message') {
if (event.content) addMessage(event.content, 'user');
continue;
}
// Replay through the normal handler
handleWebSocketMessage({ data: JSON.stringify(event) });
}
isReplay = false;
updateOutlinePanel();
// Scroll to bottom after replay
forceScroll();
}
function replayHistoryMessages(messages) {
// Fallback: old-style history replay
isReplay = true;
messagesContainer.innerHTML = '';
let historyMsgEl = null;
let lastToolEl = null;
for (const msg of messages) {
if (msg.role === 'user') {
addMessage(msg.content, 'user');
historyMsgEl = null;
lastToolEl = null;
} else if (msg.role === 'assistant') {
if (historyMsgEl) {
const contentEl = historyMsgEl.querySelector('.message-content');
if (contentEl && msg.content) contentEl.innerHTML = formatMessage(msg.content);
} else {
addMessage(msg.content, 'assistant');
}
historyMsgEl = null;
lastToolEl = null;
} else if (msg.role === 'tool_call') {
if (!historyMsgEl) historyMsgEl = createMessageContainer('assistant');
const toolsEl = historyMsgEl.querySelector('.message-tools');
if (!toolsEl) continue;
// Hidden tools: skip rendering (shown in dedicated UI components)
if (HIDDEN_TOOLS.has(msg.tool_name)) {
lastToolEl = null;
continue;
}
const toolEl = document.createElement('div');
toolEl.dataset.toolName = msg.tool_name;
// Render specialized cards for known tool types
if (_isTeamCall(msg.tool_name)) {
toolEl.className = 'tool-call team-card';
toolEl.innerHTML = _renderTeamCard(msg.tool_name, msg.args, 'done');
} else if (_isSubagentCall(msg.tool_name, msg.args)) {
toolEl.className = 'tool-call subagent-delegation';
toolEl.innerHTML = _renderSubagentCard(_isSubagentCall(msg.tool_name, msg.args), 'done');
} else if (_isAgentFactoryCall(msg.tool_name, msg.args)) {
toolEl.className = 'tool-call subagent-delegation';
toolEl.innerHTML = _renderFactoryCard(_isAgentFactoryCall(msg.tool_name, msg.args), 'done');
} else if (_isSearchCall(msg.tool_name)) {
toolEl.className = 'tool-call search-tool-card';
toolEl.innerHTML = _renderSearchCard(msg.tool_name, msg.args, 'done');
} else if (_isExcalidrawCall(msg.tool_name)) {
toolEl.className = 'tool-call excalidraw-compact';
toolEl.innerHTML = _renderExcalidrawCard(msg.tool_name, 'done');
} else {
const collapsed = _isCollapsibleTool(msg.tool_name);
const iconClass = _getToolIcon(msg.tool_name);
const catColor = _getToolCategoryColor(msg.tool_name);
toolEl.className = 'tool-call' + (collapsed ? ' collapsible collapsed' : '');
toolEl.dataset.toolName = msg.tool_name;
toolEl.innerHTML = `
<div class="tool-header"${collapsed ? ' onclick="this.parentElement.classList.toggle(\'collapsed\')" style="cursor:pointer"' : ''}>
<span class="tool-icon-badge" style="background:${catColor}22;color:${catColor}"><i class="${iconClass}"></i></span>
<span class="tool-name" style="color:${catColor}">${escapeHtml(msg.tool_name)}</span>
<span class="tool-status done"></span>
${collapsed ? '<i class="ri-arrow-down-s-line collapse-chevron"></i>' : ''}
</div>
<div class="tool-output"></div>
`;
}
// Group consecutive collapsible tools of the same type
if (_isCollapsibleTool(msg.tool_name)) {
const lastChild = toolsEl.lastElementChild;
const tn = msg.tool_name;
if (lastChild && lastChild.classList.contains('tool-group') && lastChild.dataset.toolName === tn) {
const itemsEl = lastChild.querySelector('.tool-group-items');
itemsEl.appendChild(toolEl);
lastChild.querySelector('.tool-group-count').textContent = `×${itemsEl.children.length}`;
lastToolEl = toolEl;
continue;
} else if (lastChild && lastChild.classList.contains('tool-call') && lastChild.dataset.toolName === tn && _isCollapsibleTool(tn)) {
const iconClass2 = _getToolIcon(tn);
const catColor2 = _getToolCategoryColor(tn);
const groupEl = document.createElement('div');
groupEl.className = 'tool-group collapsed';
groupEl.dataset.toolName = tn;
groupEl.innerHTML = `
<div class="tool-group-header" onclick="this.parentElement.classList.toggle('collapsed')">
<span class="tool-icon-badge" style="background:${catColor2}22;color:${catColor2}"><i class="${iconClass2}"></i></span>
<span class="tool-name" style="color:${catColor2}">${escapeHtml(tn)}</span>
<span class="tool-group-count">×2</span>
<i class="ri-arrow-down-s-line collapse-chevron"></i>
</div>
<div class="tool-group-items"></div>
`;
const itemsEl = groupEl.querySelector('.tool-group-items');
toolsEl.replaceChild(groupEl, lastChild);
itemsEl.appendChild(lastChild);
itemsEl.appendChild(toolEl);
lastToolEl = toolEl;
continue;
}
}
toolsEl.appendChild(toolEl);
lastToolEl = toolEl;
} else if (msg.role === 'tool_return') {
if (!lastToolEl) continue;
const outputEl = lastToolEl.querySelector('.tool-output');
if (outputEl && msg.output) {
const output = msg.output;
outputEl.innerHTML = `<div class="tool-output-wrap"><pre>${escapeHtml(output.length > 500 ? output.substring(0, 500) + '...' : output)}</pre></div>`;
}
lastToolEl = null;
}
}
isReplay = false;
updateOutlinePanel();
forceScroll();
}
function updateConnectionStatus(connected) {
sendBtn.disabled = !connected;
}
// Current message state for streaming
let currentMessageEl = null;
let currentToolsEl = null;
let streamedText = '';
let isAgentRunning = false;
let rawStreamedText = '';
function handleWebSocketMessage(event) {
const data = JSON.parse(event.data);
switch (data.type) {
case 'session_created':
sessionId = data.session_id;
localStorage.setItem('sessionId', sessionId);
console.log('New session created:', sessionId);
if (!isReplay) loadSessionsList();
break;
case 'canvas_ready':
console.log('Canvas ready for session:', data.session_id);
// Reload iframe if excalidraw panel is visible
_reloadExcalidrawIframe();
break;
case 'start':
currentMessageEl = createMessageContainer('assistant');
currentToolsEl = null;
streamedText = '';
rawStreamedText = '';
excalidrawCanvasShown = false;
resetTasksPanel();
setAgentRunning(true);
// Show typing indicator
if (!isReplay && currentMessageEl) {
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl) {
contentEl.innerHTML = '<span class="typing-indicator"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>';
}
}
break;
case 'status':
updateStatus(data.content);
break;
case 'tool_call_start':
startToolCallStreaming(data.tool_name, data.tool_call_id);
break;
case 'tool_args_delta':
appendToolArgsDelta(data.tool_name, data.args_delta);
break;
case 'tool_start':
addToolEvent(data.tool_name, data.args);
break;
case 'tool_output':
updateToolOutput(data.tool_name, data.output);
break;
case 'text_delta':
appendTextChunk(data.content);
break;
case 'thinking_delta':
appendThinkingChunk(data.content);
break;
case 'response':
if (currentMessageEl) {
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl) {
contentEl.innerHTML = formatMessage(data.content);
}
}
break;
case 'todos_update':
handleTodosUpdate(data.todos);
break;
case 'cancelled':
if (currentMessageEl) {
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl && !contentEl.textContent.trim()) {
contentEl.innerHTML = '<span class="cancelled-label"><i class="ri-stop-circle-line"></i> Stopped</span>';
}
// Remove any pending ask-user or approval dialogs
currentMessageEl.querySelectorAll('.ask-user-container, .approval-dialog').forEach(el => el.remove());
}
break;
case 'done':
finishMessage();
refreshFiles();
if (!isReplay) loadSessionsList();
break;
case 'error':
showError(data.content);
break;
case 'approval_required':
showApprovalDialog(data.requests);
break;
case 'middleware_event':
handleMiddlewareEvent(data);
break;
case 'hook_event':
handleHookEvent(data);
break;
case 'ask_user_question':
handleAskUserQuestion(data);
break;
case 'checkpoint_saved':
handleCheckpointSaved(data);
break;
case 'checkpoint_rewind':
handleCheckpointRewind(data);
break;
case 'background_task_completed':
showBackgroundTaskToast(data);
break;
case 'report_updated':
handleReportUpdated(data);
break;
}
}
function createMessageContainer(type) {
const id = 'msg-' + Date.now();
const messageEl = document.createElement('div');
messageEl.className = `message ${type}`;
messageEl.id = id;
const labelMap = {
'user': {text: 'You', icon: 'icon-user', i: 'ri-user-smile-line'},
'assistant': {text: 'DeepResearch', icon: 'icon-ai', i: 'ri-search-eye-line'},
'system': {text: 'System', icon: 'icon-system', i: 'ri-error-warning-fill'}
};
const info = labelMap[type] || labelMap['system'];
const showHeaderCopy = (type === 'user');
messageEl.innerHTML = `
<div class="message-header ${info.icon}">
<i class="${info.i}"></i> <span>${info.text}</span>
${showHeaderCopy ? '<button class="msg-copy-btn" onclick="copyMessage(this)" title="Copy"><i class="ri-file-copy-line"></i></button>' : ''}
</div>
<div class="message-tools"></div>
<div class="message-content"></div>
${type === 'assistant' ? '<div class="message-actions"></div>' : ''}
`;
if (isReplay) messageEl.style.animation = 'none';
messagesContainer.appendChild(messageEl);
if (!isReplay) forceScroll();
if (!isReplay) updateOutlinePanel();
return messageEl;
}
function copyMessage(btn) {
const messageEl = btn.closest('.message');
if (!messageEl) return;
const contentEl = messageEl.querySelector('.message-content');
if (!contentEl) return;
const text = contentEl.innerText || contentEl.textContent;
_copyWithFeedback(btn, text);
}
function copyToolOutput(btn) {
const pre = btn.closest('.tool-output-wrap')?.querySelector('pre');
if (!pre) return;
_copyWithFeedback(btn, pre.textContent);
}
function _copyWithFeedback(btn, text) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="ri-check-line"></i>';
btn.classList.add('copied');
setTimeout(() => {
btn.innerHTML = orig;
btn.classList.remove('copied');
}, 1500);
});
}
function updateStatus(status) {
if (!currentMessageEl) return;
let statusEl = currentMessageEl.querySelector('.message-status-line');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.className = 'message-status-line';
statusEl.style.cssText = "font-size: 11px; color: #666; font-family: monospace; margin-top: 4px; padding-left: 1rem;";
currentMessageEl.appendChild(statusEl);
}
statusEl.innerHTML = `<i class="ri-loader-4-line"></i> ${escapeHtml(status)}`;
}
// Streaming tool args accumulator
let streamingToolArgs = '';
function startToolCallStreaming(toolName, toolCallId) {
if (!currentMessageEl) return;
const toolsEl = currentMessageEl.querySelector('.message-tools');
if (!toolsEl) return;
streamingToolArgs = '';
// Hidden tools: skip rendering (shown in dedicated UI components)
if (HIDDEN_TOOLS.has(toolName)) {
currentToolsEl = null;
return;
}
// Excalidraw tools: show live canvas + compact card (no streaming args)
if (_isExcalidrawCall(toolName)) {
showExcalidrawCanvas();
const toolEl = document.createElement('div');
toolEl.className = 'tool-call excalidraw-compact';
toolEl.dataset.toolCallId = toolCallId || '';
toolEl.innerHTML = _renderExcalidrawCard(toolName, 'running');
toolsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
return;
}
// Search tools: compact colored card (args arrive later via addToolEvent)
if (_isSearchCall(toolName)) {
const toolEl = document.createElement('div');
toolEl.className = 'tool-call search-tool-card';
toolEl.dataset.toolCallId = toolCallId || '';
toolEl.dataset.toolName = toolName;
toolEl.innerHTML = _renderSearchCard(toolName, null, 'running');
toolsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
return;
}
const collapsed = _isCollapsibleTool(toolName);
const iconClass = _getToolIcon(toolName);
const catColor = _getToolCategoryColor(toolName);
const toolEl = document.createElement('div');
toolEl.className = 'tool-call streaming' + (collapsed ? ' collapsible collapsed' : '');
toolEl.dataset.toolCallId = toolCallId || '';
toolEl.dataset.toolName = toolName;
toolEl.innerHTML = `
<div class="tool-header"${collapsed ? ' onclick="this.parentElement.classList.toggle(\'collapsed\')" style="cursor:pointer"' : ''}>
<span class="tool-icon-badge" style="background:${catColor}22;color:${catColor}"><i class="${iconClass}"></i></span>
<span class="tool-name" style="color:${catColor}">${escapeHtml(toolName)}</span>
<span class="tool-status streaming">STREAMING</span>
${collapsed ? '<i class="ri-arrow-down-s-line collapse-chevron"></i>' : ''}
</div>
<div class="tool-args streaming-args"><code></code></div>
<div class="tool-output"></div>
`;
// Group consecutive collapsible tools of the same type
if (collapsed) {
const lastChild = toolsEl.lastElementChild;
if (lastChild) {
const isSameGroup = lastChild.classList.contains('tool-group') && lastChild.dataset.toolName === toolName;
const isSameTool = lastChild.classList.contains('tool-call') && lastChild.dataset.toolName === toolName && _isCollapsibleTool(toolName);
if (isSameGroup) {
// Add to existing group
const itemsEl = lastChild.querySelector('.tool-group-items');
itemsEl.appendChild(toolEl);
const countEl = lastChild.querySelector('.tool-group-count');
countEl.textContent = `×${itemsEl.children.length}`;
currentToolsEl = toolEl;
autoScroll();
return;
} else if (isSameTool) {
// Wrap previous single tool + this one into a new group
const groupEl = document.createElement('div');
groupEl.className = 'tool-group collapsed';
groupEl.dataset.toolName = toolName;
groupEl.innerHTML = `
<div class="tool-group-header" onclick="this.parentElement.classList.toggle('collapsed')">
<span class="tool-icon-badge" style="background:${catColor}22;color:${catColor}"><i class="${iconClass}"></i></span>
<span class="tool-name" style="color:${catColor}">${escapeHtml(toolName)}</span>
<span class="tool-group-count">×2</span>
<i class="ri-arrow-down-s-line collapse-chevron"></i>
</div>
<div class="tool-group-items"></div>
`;
const itemsEl = groupEl.querySelector('.tool-group-items');
toolsEl.replaceChild(groupEl, lastChild);
itemsEl.appendChild(lastChild);
itemsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
return;
}
}
}
toolsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
}
function appendToolArgsDelta(toolName, argsDelta) {
if (!currentToolsEl) return;
streamingToolArgs += argsDelta;
const argsEl = currentToolsEl.querySelector('.tool-args code');
if (argsEl) {
argsEl.textContent = streamingToolArgs;
autoScroll();
}
}
// ---------------------------------------------------------------------------
// Subagent & Agent Factory Detection
// ---------------------------------------------------------------------------
function _isExcalidrawCall(toolName) {
return toolName && toolName.startsWith('excalidraw_');
}
// ---------------------------------------------------------------------------
// Web Search Tool Detection & Labels
// ---------------------------------------------------------------------------
// Prefix-based detection — tool_prefix doubles the name (tavily_tavily_search, brave_brave_web_search, etc.)
const SEARCH_PROVIDERS = [
{ prefix: 'tavily_', label: 'Tavily', icon: 'ri-sparkling-2-fill', color: '#0ea5e9' },
{ prefix: 'brave_', label: 'Brave', icon: 'ri-shield-flash-line', color: '#f97316' },
{ prefix: 'jina_', label: 'Jina', icon: 'ri-links-line', color: '#8b5cf6' },
{ prefix: 'firecrawl_', label: 'Firecrawl', icon: 'ri-fire-line', color: '#ef4444' },
{ prefix: 'playwright_', label: 'Playwright', icon: 'ri-chrome-line', color: '#2dd4bf' },
];
function _getSearchProvider(toolName) {
if (!toolName) return null;
for (const p of SEARCH_PROVIDERS) {
if (toolName.startsWith(p.prefix)) return p;
}
return null;
}
function _isSearchCall(toolName) {
return _getSearchProvider(toolName) !== null;
}
function _searchLabel(toolName) {
const p = _getSearchProvider(toolName);
if (!p) return toolName;
// Strip prefix and prettify: "tavily_tavily_search" → "Search", "jina_jina_read_url" → "Read URL"
let action = toolName.slice(p.prefix.length);
// Strip doubled provider name (tavily_search → search, brave_web_search → web_search)
if (action.startsWith(p.label.toLowerCase() + '_')) {
action = action.slice(p.label.length + 1);
}
return p.label + ' ' + action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function _extractSearchQuery(toolName, args) {
const parsed = typeof args === 'string' ? (() => { try { return JSON.parse(args); } catch { return null; } })() : args;
if (!parsed || typeof parsed !== 'object') return '';
// Tavily: query field; Jina readurl: url field; Jina search: query; Brave: query
return parsed.query || parsed.url || parsed.search_query || '';
}
function _renderSearchCard(toolName, args, status) {
const provider = _getSearchProvider(toolName) || { label: 'Search', icon: 'ri-search-line', color: 'var(--accent-primary)' };
const info = { label: _searchLabel(toolName), icon: provider.icon, color: provider.color };
const query = _extractSearchQuery(toolName, args);
const isRunning = status === 'running';
const statusHtml = isRunning
? '<span class="search-status running"><i class="ri-loader-4-line"></i> Searching...</span>'
: '<span class="search-status done"></span>';
return `
<div class="search-card-header" style="--search-color: ${info.color}" onclick="this.parentElement.classList.toggle('expanded')">
<span class="search-provider"><i class="${info.icon}"></i> ${escapeHtml(info.label)}</span>
${query ? `<span class="search-query">"${escapeHtml(query.length > 80 ? query.substring(0, 80) + '…' : query)}"</span>` : ''}
${statusHtml}
<i class="ri-arrow-down-s-line search-expand-icon"></i>
</div>
<div class="tool-output"></div>
`;
}
// Tools that should be auto-collapsed (utility/background tools)
const COLLAPSED_TOOLS = new Set(['load_skill', 'list_skills', 'read_file', 'glob', 'grep', 'remember']);
// Tools completely hidden from chat (shown in dedicated UI components instead)
const HIDDEN_TOOLS = new Set(['read_todos', 'write_todos', 'add_todo', 'update_todo_status', 'remove_todo', 'wait_tasks']);
// Per-tool icons and category colors
const TOOL_ICONS = {
'read_file': 'ri-file-text-line', 'write_file': 'ri-file-edit-line',
'edit_file': 'ri-edit-line', 'execute': 'ri-terminal-line',
'glob': 'ri-search-line', 'grep': 'ri-search-2-line',
'ls': 'ri-folder-open-line', 'read_todos': 'ri-checkbox-circle-line',
'write_todos': 'ri-checkbox-circle-line', 'list_skills': 'ri-book-open-line',
'load_skill': 'ri-book-open-line', 'task': 'ri-robot-line',
'check_task': 'ri-robot-line', 'list_active_tasks': 'ri-robot-line',
'soft_cancel_task': 'ri-close-circle-line', 'hard_cancel_task': 'ri-close-circle-line',
'create_agent': 'ri-user-add-line', 'remove_agent': 'ri-user-minus-line',
'list_agents': 'ri-team-line', 'get_agent_info': 'ri-user-search-line',
'list_checkpoints': 'ri-history-line', 'rewind': 'ri-arrow-go-back-line',
'fork': 'ri-git-branch-line',
'spawn_team': 'ri-team-line', 'assign_task': 'ri-user-follow-line',
'check_teammates': 'ri-group-line', 'dissolve_team': 'ri-user-unfollow-line',
'remember': 'ri-brain-line',
'add_todo': 'ri-add-circle-line', 'update_todo_status': 'ri-checkbox-circle-line',
'remove_todo': 'ri-delete-bin-line',
'save_checkpoint': 'ri-save-line', 'rewind_to': 'ri-arrow-go-back-line',
'ask_user': 'ri-question-line', 'save_plan': 'ri-draft-line',
'answer_subagent': 'ri-chat-3-line',
};
const TOOL_CATEGORIES = {
'file': { color: '#3b82f6', tools: ['read_file', 'write_file', 'edit_file', 'glob', 'grep', 'ls'] },
'execute': { color: '#f59e0b', tools: ['execute'] },
'planning': { color: '#8b5cf6', tools: ['read_todos', 'write_todos', 'add_todo', 'update_todo_status', 'remove_todo', 'list_skills', 'load_skill', 'ask_user', 'save_plan'] },
'agents': { color: '#06b6d4', tools: ['task', 'check_task', 'list_active_tasks', 'soft_cancel_task', 'hard_cancel_task', 'create_agent', 'remove_agent', 'list_agents', 'get_agent_info', 'answer_subagent'] },
'teams': { color: '#a855f7', tools: ['spawn_team', 'assign_task', 'check_teammates', 'dissolve_team'] },
'memory': { color: '#f472b6', tools: ['remember'] },
'system': { color: '#6b7280', tools: ['list_checkpoints', 'save_checkpoint', 'rewind_to', 'rewind', 'fork'] },
};
function _getToolIcon(toolName) {
return TOOL_ICONS[toolName] || 'ri-settings-5-line';
}
function _getToolCategoryColor(toolName) {
for (const cat of Object.values(TOOL_CATEGORIES)) {
if (cat.tools.includes(toolName)) return cat.color;
}
return '#6b7280';
}
function _isCollapsibleTool(toolName) {
return COLLAPSED_TOOLS.has(toolName);
}
// Friendly Excalidraw tool labels (yctimlin/mcp_excalidraw tools)
const EXCALIDRAW_LABELS = {
'excalidraw_create_element': 'Create Element',
'excalidraw_batch_create_elements': 'Create Elements',
'excalidraw_update_element': 'Update Element',
'excalidraw_delete_element': 'Delete Element',
'excalidraw_create_from_mermaid': 'From Mermaid',
'excalidraw_describe_scene': 'Inspect Scene',
'excalidraw_get_canvas_screenshot': 'Screenshot',
'excalidraw_align_elements': 'Align',
'excalidraw_distribute_elements': 'Distribute',
'excalidraw_group_elements': 'Group',
'excalidraw_ungroup_elements': 'Ungroup',
'excalidraw_export_scene': 'Export Scene',
'excalidraw_export_to_excalidraw_url': 'Share Link',
'excalidraw_export_to_image': 'Export Image',
'excalidraw_import_scene': 'Import Scene',
'excalidraw_clear_canvas': 'Clear Canvas',
'excalidraw_read_diagram_guide': 'Read Guide',
'excalidraw_set_viewport': 'Set Viewport',
'excalidraw_query_elements': 'Query Elements',
'excalidraw_get_element': 'Get Element',
'excalidraw_get_resource': 'Get Resource',
'excalidraw_lock_elements': 'Lock',
'excalidraw_unlock_elements': 'Unlock',
'excalidraw_snapshot_scene': 'Snapshot',
'excalidraw_restore_snapshot': 'Restore',
'excalidraw_duplicate_elements': 'Duplicate',
};
function _isSubagentCall(toolName, args) {
if (toolName !== 'task' && toolName !== 'delegate_task') return null;
const parsed = typeof args === 'string' ? (() => { try { return JSON.parse(args); } catch { return args; } })() : args;
if (parsed && typeof parsed === 'object' && parsed.subagent_type) return parsed;
return null;
}
function _isAgentFactoryCall(toolName, args) {
if (toolName !== 'create_agent' && toolName !== 'remove_agent' &&
toolName !== 'list_agents' && toolName !== 'get_agent_info') return null;
const parsed = typeof args === 'string' ? (() => { try { return JSON.parse(args); } catch { return args; } })() : args;
return { tool: toolName, args: parsed || {} };
}
const AGENT_ICONS = {
'joke-generator': 'ri-emotion-laugh-line',
'code-reviewer': 'ri-code-s-slash-line',
'general-purpose': 'ri-robot-2-line',
'planner': 'ri-draft-line',
};
const AGENT_COLORS = {
'joke-generator': '#f59e0b',
'code-reviewer': '#8b5cf6',
'general-purpose': '#06b6d4',
'planner': '#3b82f6',
};
function _renderSubagentCard(agentInfo, status) {
const name = agentInfo.subagent_type || 'subagent';
const icon = AGENT_ICONS[name] || 'ri-user-shared-line';
const color = AGENT_COLORS[name] || 'var(--accent-primary)';
const desc = agentInfo.description || '';
const statusHtml = status === 'running'
? '<span class="subagent-status running"><i class="ri-loader-4-line"></i> Working...</span>'
: '<span class="subagent-status done"></span>';
return `
<div class="subagent-header" style="--agent-color: ${color}">
<div class="subagent-avatar"><i class="${icon}"></i></div>
<div class="subagent-info">
<span class="subagent-name">${escapeHtml(name)}</span>
${statusHtml}
</div>
</div>
${desc ? `<div class="subagent-task"><i class="ri-arrow-right-s-line"></i> ${escapeHtml(desc)}</div>` : ''}
<div class="tool-output"></div>
`;
}
function _renderFactoryCard(factoryInfo, status) {
const toolIcons = {
'create_agent': 'ri-add-circle-line',
'remove_agent': 'ri-delete-bin-line',
'list_agents': 'ri-list-check',
'get_agent_info': 'ri-information-line',
};
const toolLabels = {
'create_agent': 'Creating Agent',
'remove_agent': 'Removing Agent',
'list_agents': 'Listing Agents',
'get_agent_info': 'Agent Info',
};
const tool = factoryInfo.tool;
const args = factoryInfo.args || {};
const icon = toolIcons[tool] || 'ri-robot-2-line';
const label = toolLabels[tool] || tool;
const agentName = args.name || '';
const desc = args.description || '';
const statusHtml = status === 'running'
? '<span class="subagent-status running"><i class="ri-loader-4-line"></i> Working...</span>'
: '<span class="subagent-status done"></span>';
return `
<div class="subagent-header" style="--agent-color: #10b981">
<div class="subagent-avatar"><i class="${icon}"></i></div>
<div class="subagent-info">
<span class="subagent-name">${escapeHtml(label)}${agentName ? ': ' + escapeHtml(agentName) : ''}</span>
${statusHtml}
</div>
</div>
${desc ? `<div class="subagent-task"><i class="ri-arrow-right-s-line"></i> ${escapeHtml(desc)}</div>` : ''}
<div class="tool-output"></div>
`;
}
// ---------------------------------------------------------------------------
// Team Agent Cards
// ---------------------------------------------------------------------------
const TEAM_TOOLS = new Set(['spawn_team', 'assign_task', 'check_teammates', 'dissolve_team']);
function _isTeamCall(toolName) {
return TEAM_TOOLS.has(toolName);
}
const MEMBER_COLORS = ['#a855f7', '#ec4899', '#06b6d4', '#f59e0b', '#10b981', '#3b82f6'];
function _renderTeamCard(toolName, args, status) {
const parsed = typeof args === 'string' ? (() => { try { return JSON.parse(args); } catch { return args; } })() : (args || {});
const statusHtml = status === 'running'
? '<span class="subagent-status running"><i class="ri-loader-4-line"></i> Working...</span>'
: '<span class="subagent-status done"></span>';
if (toolName === 'spawn_team') {
const teamName = parsed.team_name || 'team';
const members = parsed.members || [];
const membersHtml = members.map((m, i) => {
const c = MEMBER_COLORS[i % MEMBER_COLORS.length];
const name = typeof m === 'string' ? m : (m.name || 'Member');
const role = typeof m === 'object' ? (m.role || '') : '';
return `<div class="team-member" style="--member-color: ${c}">
<div class="team-member-avatar" style="background:${c}22;color:${c}"><i class="ri-user-3-line"></i></div>
<div class="team-member-info">
<span class="team-member-name">${escapeHtml(name)}</span>
${role ? `<span class="team-member-role">${escapeHtml(role)}</span>` : ''}
</div>
</div>`;
}).join('');
return `
<div class="subagent-header" style="--agent-color: #a855f7">
<div class="subagent-avatar" style="background:rgba(168,85,247,0.15);color:#a855f7"><i class="ri-team-line"></i></div>
<div class="subagent-info">
<span class="subagent-name">Spawning Team: ${escapeHtml(teamName)}</span>
${statusHtml}
</div>
</div>
${members.length ? `<div class="team-members">${membersHtml}</div>` : ''}
<div class="tool-output"></div>
`;
}
if (toolName === 'assign_task') {
const memberName = parsed.member_name || 'member';
const taskDesc = parsed.task_description || '';
return `
<div class="subagent-header" style="--agent-color: #a855f7">
<div class="subagent-avatar" style="background:rgba(168,85,247,0.15);color:#a855f7"><i class="ri-user-follow-line"></i></div>
<div class="subagent-info">
<span class="subagent-name">${escapeHtml(memberName)}</span>
${statusHtml}
</div>
</div>
${taskDesc ? `<div class="subagent-task"><i class="ri-arrow-right-s-line"></i> ${escapeHtml(taskDesc.length > 200 ? taskDesc.substring(0, 200) + '…' : taskDesc)}</div>` : ''}
<div class="tool-output"></div>
`;
}
if (toolName === 'check_teammates') {
return `
<div class="subagent-header" style="--agent-color: #a855f7">
<div class="subagent-avatar" style="background:rgba(168,85,247,0.15);color:#a855f7"><i class="ri-group-line"></i></div>
<div class="subagent-info">
<span class="subagent-name">Checking Teammates</span>
${statusHtml}
</div>
</div>
<div class="tool-output"></div>
`;
}
if (toolName === 'dissolve_team') {
return `
<div class="subagent-header" style="--agent-color: #a855f7">
<div class="subagent-avatar" style="background:rgba(168,85,247,0.15);color:#a855f7"><i class="ri-user-unfollow-line"></i></div>
<div class="subagent-info">
<span class="subagent-name">Dissolving Team</span>
${statusHtml}
</div>
</div>
<div class="tool-output"></div>
`;
}
// Fallback
return `
<div class="subagent-header" style="--agent-color: #a855f7">
<div class="subagent-avatar" style="background:rgba(168,85,247,0.15);color:#a855f7"><i class="ri-team-line"></i></div>
<div class="subagent-info">
<span class="subagent-name">${escapeHtml(toolName)}</span>
${statusHtml}
</div>
</div>
<div class="tool-output"></div>
`;
}
// ---------------------------------------------------------------------------
// Excalidraw Live Canvas
// ---------------------------------------------------------------------------
function _renderExcalidrawCard(toolName, status) {
const label = EXCALIDRAW_LABELS[toolName] || toolName.replace('excalidraw_', '');
const statusHtml = status === 'running'
? '<span class="tool-status running"><i class="ri-loader-4-line"></i></span>'
: '<span class="tool-status done"></span>';
return `
<div class="tool-header excalidraw-tool">
<span class="tool-name"><i class="ri-artboard-line"></i> ${escapeHtml(label)}</span>
${statusHtml}
</div>
<div class="tool-output"></div>
`;
}
function showExcalidrawCanvas() {
excalidrawCanvasShown = true;
if (!excalidrawCanvasUrl) return;
// Reuse existing canvas element if present
let canvas = document.getElementById('excalidraw-inline');
if (!canvas) {
canvas = document.createElement('div');
canvas.id = 'excalidraw-inline';
canvas.className = 'excalidraw-inline';
canvas.innerHTML = `
<div class="excalidraw-inline-header" onclick="toggleExcalidrawInline()">
<span class="excalidraw-inline-title"><i class="ri-artboard-line"></i> Canvas</span>
<button class="excalidraw-inline-toggle"><i class="ri-arrow-up-s-line"></i></button>
</div>
<iframe id="excalidraw-iframe" class="excalidraw-inline-iframe" src="about:blank"></iframe>
`;
// Place inside the current message, after .message-content (part of the response)
if (currentMessageEl) {
const content = currentMessageEl.querySelector('.message-content');
if (content) {
content.after(canvas);
} else {
currentMessageEl.appendChild(canvas);
}
} else {
messagesContainer.appendChild(canvas);
}
}
const iframe = document.getElementById('excalidraw-iframe');
if (iframe && (!iframe.src || iframe.src === 'about:blank' || iframe.src === window.location.href)) {
iframe.src = excalidrawCanvasUrl;
}
canvas.classList.remove('collapsed');
autoScroll();
}
function _reloadExcalidrawIframe() {
const iframe = document.getElementById('excalidraw-iframe');
if (!iframe || !excalidrawCanvasUrl) return;
iframe.src = 'about:blank';
const canvas = document.getElementById('excalidraw-inline');
if (canvas && !canvas.classList.contains('collapsed')) {
setTimeout(() => { iframe.src = excalidrawCanvasUrl; }, 100);
}
}
function closeExcalidrawPanel() {
const canvas = document.getElementById('excalidraw-inline');
if (canvas) canvas.classList.add('collapsed');
}
function _hideExcalidrawContainer() {
const canvas = document.getElementById('excalidraw-inline');
if (canvas) canvas.remove();
const iframe = document.getElementById('excalidraw-iframe');
if (iframe) iframe.src = 'about:blank';
}
function toggleExcalidrawInline() {
const canvas = document.getElementById('excalidraw-inline');
if (!canvas) return;
canvas.classList.toggle('collapsed');
}
function toggleExcalidrawPanel() {
const canvas = document.getElementById('excalidraw-inline');
if (!canvas) {
showExcalidrawCanvas();
} else {
toggleExcalidrawInline();
}
}
// ---------------------------------------------------------------------------
// Tool Events (with subagent/factory card rendering)
// ---------------------------------------------------------------------------
function addToolEvent(toolName, args) {
if (!currentMessageEl) return;
const toolsEl = currentMessageEl.querySelector('.message-tools');
if (!toolsEl) return;
// Hidden tools: skip rendering (shown in dedicated UI components)
if (HIDDEN_TOOLS.has(toolName)) {
currentToolsEl = null;
return;
}
// Excalidraw tools: compact card + show inline canvas
if (_isExcalidrawCall(toolName)) {
showExcalidrawCanvas();
// Update existing compact card if streaming, or create new one
const existingCompact = toolsEl.querySelector('.tool-call.excalidraw-compact');
if (existingCompact && existingCompact === currentToolsEl) {
existingCompact.innerHTML = _renderExcalidrawCard(toolName, 'running');
return;
}
const toolEl = document.createElement('div');
toolEl.className = 'tool-call excalidraw-compact';
toolEl.innerHTML = _renderExcalidrawCard(toolName, 'running');
toolsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
return;
}
// Search tools: compact colored card with query
if (_isSearchCall(toolName)) {
// Update existing streaming search card, or create new one
const existingSearch = currentToolsEl && currentToolsEl.classList.contains('search-tool-card') ? currentToolsEl : null;
if (existingSearch) {
existingSearch.innerHTML = _renderSearchCard(toolName, args, 'running');
return;
}
const toolEl = document.createElement('div');
toolEl.className = 'tool-call search-tool-card';
toolEl.dataset.toolName = toolName;
toolEl.innerHTML = _renderSearchCard(toolName, args, 'running');
toolsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
return;
}
// Detect team tools, subagent delegation, or agent factory call
const isTeam = _isTeamCall(toolName);
const agentInfo = !isTeam ? _isSubagentCall(toolName, args) : null;
const factoryInfo = !isTeam && !agentInfo ? _isAgentFactoryCall(toolName, args) : null;
const existingStreamingTool = toolsEl.querySelector('.tool-call.streaming');
if (existingStreamingTool) {
existingStreamingTool.classList.remove('streaming');
if (isTeam) {
existingStreamingTool.className = 'tool-call team-card';
existingStreamingTool.innerHTML = _renderTeamCard(toolName, args, 'running');
} else if (agentInfo) {
existingStreamingTool.className = 'tool-call subagent-delegation';
existingStreamingTool.innerHTML = _renderSubagentCard(agentInfo, 'running');
} else if (factoryInfo) {
existingStreamingTool.className = 'tool-call subagent-delegation';
existingStreamingTool.innerHTML = _renderFactoryCard(factoryInfo, 'running');
} else {
const statusEl = existingStreamingTool.querySelector('.tool-status');
if (statusEl) {
statusEl.className = 'tool-status running';
statusEl.textContent = 'running';
}
const argsEl = existingStreamingTool.querySelector('.tool-args');
if (argsEl) {
argsEl.className = 'tool-args';
argsEl.innerHTML = formatToolArgs(args);
}
}
currentToolsEl = existingStreamingTool;
return;
}
const toolEl = document.createElement('div');
if (isTeam) {
toolEl.className = 'tool-call team-card';
toolEl.innerHTML = _renderTeamCard(toolName, args, 'running');
} else if (agentInfo) {
toolEl.className = 'tool-call subagent-delegation';
toolEl.innerHTML = _renderSubagentCard(agentInfo, 'running');
} else if (factoryInfo) {
toolEl.className = 'tool-call subagent-delegation';
toolEl.innerHTML = _renderFactoryCard(factoryInfo, 'running');
} else {
const collapsed = _isCollapsibleTool(toolName);
const iconClass = _getToolIcon(toolName);
const catColor = _getToolCategoryColor(toolName);
toolEl.className = 'tool-call running' + (collapsed ? ' collapsible collapsed' : '');
toolEl.innerHTML = `
<div class="tool-header"${collapsed ? ' onclick="this.parentElement.classList.toggle(\'collapsed\')" style="cursor:pointer"' : ''}>
<span class="tool-icon-badge" style="background:${catColor}22;color:${catColor}"><i class="${iconClass}"></i></span>
<span class="tool-name" style="color:${catColor}">${escapeHtml(toolName)}</span>
<span class="tool-status running">RUNNING...</span>
${collapsed ? '<i class="ri-arrow-down-s-line collapse-chevron"></i>' : ''}
</div>
<div class="tool-args">${formatToolArgs(args)}</div>
<div class="tool-output"></div>
`;
}
toolsEl.appendChild(toolEl);
currentToolsEl = toolEl;
autoScroll();
}
function formatToolArgs(args) {
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch {
return `<code>${escapeHtml(args)}</code>`;
}
}
if (typeof args === 'object') {
const parts = [];
for (const [key, value] of Object.entries(args)) {
const displayValue = typeof value === 'string' && value.length > 100
? value.substring(0, 100) + '...'
: JSON.stringify(value);
parts.push(`<span class="arg-key">${escapeHtml(key)}:</span> <span class="arg-value">${escapeHtml(displayValue)}</span>`);
}
return parts.join('<br>');
}
return '';
}
function updateToolOutput(toolName, output) {
if (!currentToolsEl) return;
const isTeam = currentToolsEl.classList.contains('team-card');
const isSubagent = currentToolsEl.classList.contains('subagent-delegation');
const isExcalidraw = currentToolsEl.classList.contains('excalidraw-compact');
const isSearch = currentToolsEl.classList.contains('search-tool-card');
const outputEl = currentToolsEl.querySelector('.tool-output');
if (isTeam) {
const statusEl = currentToolsEl.querySelector('.subagent-status');
if (statusEl) {
statusEl.className = 'subagent-status done';
statusEl.innerHTML = '';
}
if (outputEl && output) {
outputEl.innerHTML = `<div class="subagent-result">${formatMessage(output)}</div>`;
}
} else if (isSearch) {
// Update status to done
const statusEl = currentToolsEl.querySelector('.search-status');
if (statusEl) {
statusEl.className = 'search-status done';
statusEl.innerHTML = '';
}
// Show output collapsed — user clicks header to expand
if (outputEl && output) {
const preview = output.length > 300 ? output.substring(0, 300) + '…' : output;
outputEl.innerHTML = `<div class="search-results-wrap"><pre>${escapeHtml(preview)}</pre>${output.length > 300 ? `<button class="search-show-more" onclick="this.parentElement.querySelector('pre').textContent = this.parentElement.dataset.full; this.remove();">Show full results</button>` : ''}</div>`;
if (output.length > 300) {
outputEl.querySelector('.search-results-wrap').dataset.full = output;
}
}
} else if (isExcalidraw) {
// Update status — diagram renders live in the inline canvas
const statusEl = currentToolsEl.querySelector('.tool-status');
if (statusEl) {
statusEl.className = 'tool-status done';
statusEl.innerHTML = '';
}
} else if (isSubagent) {
const statusEl = currentToolsEl.querySelector('.subagent-status');
if (statusEl) {
statusEl.className = 'subagent-status done';
statusEl.innerHTML = '';
}
if (outputEl) {
outputEl.innerHTML = `<div class="subagent-result">${formatMessage(output)}</div>`;
}
} else {
if (outputEl) {
outputEl.innerHTML = `<div class="tool-output-wrap"><pre>${escapeHtml(output)}</pre><button class="tool-copy-btn" onclick="copyToolOutput(this)" title="Copy output"><i class="ri-file-copy-line"></i></button></div>`;
}
const statusEl = currentToolsEl.querySelector('.tool-status');
if (statusEl) {
statusEl.className = 'tool-status done';
statusEl.innerHTML = '';
}
// Remove running class (stops shimmer animation)
currentToolsEl.classList.remove('running');
}
// Refresh file list when file-modifying tools complete
const fileTools = ['write_file', 'edit_file', 'execute'];
if (fileTools.includes(toolName)) {
refreshFiles();
}
// Track background agent tasks (assign_task and async task dispatch)
if ((toolName === 'assign_task' || toolName === 'task') && output) {
// Match both "Task assigned to 'X' (ID: abc123)" and "Task ID: abc123"
const idMatch = output.match(/\(ID:\s*([a-f0-9-]+)\)/) || output.match(/Task ID:\s*([a-f0-9-]+)/);
if (idMatch) {
backgroundAgentTasks.add(idMatch[1]);
backgroundAgentCount = backgroundAgentTasks.size;
updateBackgroundBadge();
}
}
}
function appendTextChunk(chunk) {
if (!currentMessageEl) return;
streamedText += chunk;
rawStreamedText += chunk;
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl) {
// Remove typing indicator if present
const typing = contentEl.querySelector('.typing-indicator');
if (typing) typing.remove();
contentEl.innerHTML = formatMessage(streamedText);
// Add streaming cursor during live streaming
if (!isReplay) {
contentEl.classList.add('streaming-cursor');
}
autoScroll();
}
}
function appendThinkingChunk(chunk) {
if (!currentMessageEl) return;
let thinkingEl = currentMessageEl.querySelector('.message-thinking');
if (!thinkingEl) {
thinkingEl = document.createElement('div');
thinkingEl.className = 'message-thinking';
thinkingEl.innerHTML = '<span class="thinking-label" onclick="this.parentElement.classList.toggle(\'collapsed\')"><i class="ri-brain-line"></i> Thinking <i class="ri-arrow-down-s-line" style="margin-left:auto;font-size:14px;"></i></span><div class="thinking-content"></div>';
const contentEl = currentMessageEl.querySelector('.message-content');
currentMessageEl.insertBefore(thinkingEl, contentEl);
}
const thinkingContent = thinkingEl.querySelector('.thinking-content');
if (thinkingContent) {
thinkingContent.textContent += chunk;
autoScroll();
}
}
function finishMessage() {
if (currentMessageEl) {
const statusEl = currentMessageEl.querySelector('.message-status');
if (statusEl) statusEl.remove();
const statusLine = currentMessageEl.querySelector('.message-status-line');
if (statusLine) statusLine.remove();
const toolStatuses = currentMessageEl.querySelectorAll('.tool-status.running');
toolStatuses.forEach(el => {
el.className = 'tool-status done';
el.innerHTML = '<i class="ri-check-line"></i>';
});
// Clear running subagent/team statuses
currentMessageEl.querySelectorAll('.subagent-status.running').forEach(el => {
el.className = 'subagent-status done';
el.innerHTML = '';
});
// Remove running class from tool cards
currentMessageEl.querySelectorAll('.tool-call.running').forEach(el => el.classList.remove('running'));
// Remove streaming cursor
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl) contentEl.classList.remove('streaming-cursor');
// Remove typing indicator
const typing = currentMessageEl.querySelector('.typing-indicator');
if (typing) typing.remove();
// Ensure action bar has at least copy if no checkpoint added it
const actionsEl = currentMessageEl.querySelector('.message-actions');
if (actionsEl && !actionsEl.hasChildNodes()) {
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn';
copyBtn.title = 'Copy response';
copyBtn.innerHTML = '<i class="ri-file-copy-line"></i> Copy';
copyBtn.onclick = () => copyMessage(copyBtn);
actionsEl.appendChild(copyBtn);
}
}
currentMessageEl = null;
currentToolsEl = null;
setAgentRunning(false);
updateOutlinePanel();
}
function _renderMessageActions(actionsEl, checkpointId, checkpointLabel) {
actionsEl.innerHTML = '';
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn';
copyBtn.title = 'Copy response';
copyBtn.innerHTML = '<i class="ri-file-copy-line"></i> Copy';
copyBtn.onclick = () => copyMessage(copyBtn);
const rewindBtn = document.createElement('button');
rewindBtn.className = 'msg-action-btn';
rewindBtn.title = 'Rewind to this point';
rewindBtn.innerHTML = '<i class="ri-arrow-go-back-line"></i> Rewind';
rewindBtn.onclick = () => rewindToCheckpoint(checkpointId, checkpointLabel);
const forkBtn = document.createElement('button');
forkBtn.className = 'msg-action-btn';
forkBtn.title = 'Fork into new session';
forkBtn.innerHTML = '<i class="ri-git-branch-line"></i> Fork';
forkBtn.onclick = () => forkFromCheckpoint(checkpointId);
actionsEl.append(copyBtn, rewindBtn, forkBtn);
}
function setAgentRunning(running) {
isAgentRunning = running;
const stopBtn = document.getElementById('stop-btn');
if (running) {
sendBtn.disabled = true;
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
} else {
sendBtn.disabled = false;
sendBtn.style.display = 'flex';
stopBtn.style.display = 'none';
}
}
function stopAgent() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ cancel: true }));
}
function showError(message) {
if (currentMessageEl) {
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl) {
contentEl.innerHTML = `<span class="error"><i class="ri-error-warning-line"></i> Error: ${escapeHtml(message)}</span>`;
}
} else {
addMessage(`Error: ${message}`, 'system');
}
finishMessage();
}
// ---------------------------------------------------------------------------
// Human-in-the-Loop Approval Dialog
// ---------------------------------------------------------------------------
let pendingApprovals = [];
function showApprovalDialog(requests) {
pendingApprovals = requests;
if (!currentMessageEl) {
currentMessageEl = createMessageContainer('assistant');
}
const contentEl = currentMessageEl.querySelector('.message-content');
if (!contentEl) return;
let html = '<div class="approval-dialog">';
html += '<h4><i class="ri-alert-line"></i> Approval Required</h4>';
html += '<p>The following operations require your approval:</p>';
for (const req of requests) {
html += `
<div class="approval-item" data-id="${req.tool_call_id}">
<div class="approval-tool">
<span class="tool-icon"><i class="ri-settings-5-line"></i></span>
<strong>${escapeHtml(req.tool_name)}</strong>
</div>
<div class="approval-args">${formatToolArgs(req.args)}</div>
</div>
`;
}
html += `
<div class="approval-buttons">
<button class="approve-btn" onclick="handleApprovalResponse(true)">Approve All</button>
<button class="deny-btn" onclick="handleApprovalResponse(false)">Deny All</button>
</div>
</div>`;
contentEl.innerHTML = html;
forceScroll();
}
function handleApprovalResponse(approved) {
if (!pendingApprovals.length) return;
const approvalResponse = {};
for (const req of pendingApprovals) {
approvalResponse[req.tool_call_id] = approved;
}
pendingApprovals = [];
if (currentMessageEl) {
const contentEl = currentMessageEl.querySelector('.message-content');
if (contentEl) {
contentEl.innerHTML = `<p>${approved ? '<i class="ri-check-line"></i> Approved' : '<i class="ri-close-line"></i> Denied'} - continuing...</p>`;
}
}
ws.send(JSON.stringify({approval: approvalResponse}));
}
// Pending file attachments for next message
let pendingAttachments = [];
function setupEventListeners() {
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
messageInput.addEventListener('input', () => {
messageInput.style.height = 'auto';
messageInput.style.height = Math.min(messageInput.scrollHeight, 150) + 'px';
});
// Paste handler for images/files
messageInput.addEventListener('paste', (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file') {
e.preventDefault();
const file = item.getAsFile();
if (file) addAttachment(file);
}
}
});
// Chat file input (paperclip button)
const chatFileInput = document.getElementById('chat-file-input');
chatFileInput.addEventListener('change', (e) => {
for (const file of e.target.files) {
addAttachment(file);
}
chatFileInput.value = '';
});
// Drag & drop to chat area
const chatPanel = document.querySelector('.chat-panel');
chatPanel.addEventListener('dragover', (e) => {
e.preventDefault();
chatPanel.classList.add('drag-active');
});
chatPanel.addEventListener('dragleave', (e) => {
if (!chatPanel.contains(e.relatedTarget)) {
chatPanel.classList.remove('drag-active');
}
});
chatPanel.addEventListener('drop', (e) => {
e.preventDefault();
chatPanel.classList.remove('drag-active');
for (const file of e.dataTransfer.files) {
addAttachment(file);
}
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTab = btn.dataset.tab;
const configPanel = document.getElementById('config-panel');
const sessionsPanel = document.getElementById('sessions-panel');
// Hide all panels
filesList.style.display = 'none';
configPanel.style.display = 'none';
if (sessionsPanel) sessionsPanel.style.display = 'none';
if (currentTab === 'config') {
configPanel.style.display = 'block';
renderConfigPanel();
} else if (currentTab === 'sessions') {
if (sessionsPanel) sessionsPanel.style.display = 'block';
loadSessionsList();
} else if (currentTab === 'files') {
filesList.style.display = 'block';
refreshFiles();
}
});
});
}
function setupResizer() {
const resizer = document.getElementById('drag-handle');
const root = document.documentElement;
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
resizer.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
let newWidth = e.clientX;
if (newWidth < 200) newWidth = 200;
if (newWidth > 600) newWidth = 600;
root.style.setProperty('--sidebar-width', `${newWidth}px`);
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
async function sendMessage() {
const message = messageInput.value.trim();
if (!message && pendingAttachments.length === 0) return;
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('Not connected to server. Reconnecting...', 'system');
connectWebSocket();
return;
}
// Read attachments as base64 locally
const attachments = [];
if (pendingAttachments.length > 0) {
for (const file of pendingAttachments) {
const base64 = await readFileAsBase64(file);
attachments.push({
name: file.name,
type: file.type || 'application/octet-stream',
data: base64,
size: file.size,
});
}
}
// Clear input immediately
messageInput.value = '';
messageInput.style.height = 'auto';
pendingAttachments = [];
renderAttachments();
// Remove welcome banner if present
const banner = messagesContainer.querySelector('.welcome-banner');
if (banner) {
const bannerMsg = banner.closest('.message');
if (bannerMsg) bannerMsg.remove();
}
// Show user message with attachment chips
addMessage(message, 'user', attachments);
// Send everything via WebSocket in one shot
const payload = { message: message };
if (sessionId) payload.session_id = sessionId;
if (attachments.length > 0) payload.attachments = attachments;
ws.send(JSON.stringify(payload));
setAgentRunning(true);
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function addAttachment(file) {
if (pendingAttachments.some(f => f.name === file.name && f.size === file.size)) return;
pendingAttachments.push(file);
renderAttachments();
}
function removeAttachment(index) {
pendingAttachments.splice(index, 1);
renderAttachments();
}
function renderAttachments() {
const container = document.getElementById('attached-files');
if (!container) return;
if (pendingAttachments.length === 0) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
container.style.display = 'flex';
container.innerHTML = pendingAttachments.map((file, i) => {
const iconClass = getFileIconClass(file.name);
const isImage = file.type.startsWith('image/');
let preview = '';
if (isImage) {
const url = URL.createObjectURL(file);
preview = `<img src="${url}" class="attachment-thumb" alt="">`;
}
return `
<div class="attachment-chip">
${preview || `<i class="${iconClass}"></i>`}
<span class="attachment-name">${escapeHtml(file.name)}</span>
<span class="attachment-size">${formatBytes(file.size)}</span>
<button class="attachment-remove" onclick="removeAttachment(${i})" title="Remove">
<i class="ri-close-line"></i>
</button>
</div>
`;
}).join('');
}
function sendQuickMessage(message) {
messageInput.value = message;
sendMessage();
}
function addMessage(content, type, attachments = []) {
const id = 'msg-' + Date.now();
const messageEl = document.createElement('div');
messageEl.className = `message ${type}`;
messageEl.id = id;
const labelMap = {
'user': {text: 'You', i: 'ri-user-smile-line'},
'assistant': {text: 'DeepResearch', i: 'ri-search-eye-line'},
'system': {text: 'System', i: 'ri-error-warning-fill'}
};
const info = labelMap[type];
// Build attachment chips HTML
let attachHtml = '';
if (attachments.length > 0) {
const chips = attachments.map(a => {
const iconClass = getFileIconClass(a.name);
const isImage = (a.type || '').startsWith('image/');
let thumb = '';
if (isImage && a.data) {
thumb = `<img src="data:${a.type};base64,${a.data}" class="msg-attach-thumb" alt="">`;
}
return `<div class="msg-attach-chip">${thumb || `<i class="${iconClass}"></i>`}<span class="msg-attach-name">${escapeHtml(a.name)}</span><span class="msg-attach-size">${formatBytes(a.size)}</span></div>`;
}).join('');
attachHtml = `<div class="msg-attachments">${chips}</div>`;
}
messageEl.innerHTML = `
<div class="message-header">
<i class="${info.i}"></i> <span>${info.text}</span>
<button class="msg-copy-btn" onclick="copyMessage(this)" title="Copy"><i class="ri-file-copy-line"></i></button>
</div>
${attachHtml}
<div class="message-content">${content ? formatMessage(content) : ''}</div>
`;
messagesContainer.appendChild(messageEl);
forceScroll();
updateOutlinePanel();
return id;
}
function formatMessage(content) {
if (!content) return '';
// Pre-process: extract code blocks to protect them from other formatting
const codeBlocks = [];
let processed = content.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const idx = codeBlocks.length;
codeBlocks.push({ lang: lang || 'none', code });
return `%%CODEBLOCK_${idx}%%`;
});
// Escape HTML (but not our placeholders)
processed = escapeHtml(processed);
// Headers (must be at start of line)
processed = processed.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
processed = processed.replace(/^### (.+)$/gm, '<h3>$1</h3>');
processed = processed.replace(/^## (.+)$/gm, '<h2>$1</h2>');
processed = processed.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Horizontal rule
processed = processed.replace(/^---$/gm, '<hr>');
// Blockquotes
processed = processed.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// Tables
processed = processed.replace(/((?:^\|.+\|$\n?)+)/gm, (tableBlock) => {
const rows = tableBlock.trim().split('\n');
if (rows.length < 2) return tableBlock;
const separator = rows[1];
if (!/^\|[\s\-:|]+\|$/.test(separator)) return tableBlock;
let html = '<table class="md-table"><thead><tr>';
const headers = rows[0].split('|').filter(c => c.trim());
for (const h of headers) {
html += `<th>${h.trim()}</th>`;
}
html += '</tr></thead><tbody>';
for (let i = 2; i < rows.length; i++) {
const cells = rows[i].split('|').filter(c => c.trim());
if (cells.length === 0) continue;
html += '<tr>';
for (const c of cells) {
html += `<td>${c.trim()}</td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
});
// Unordered lists (-, *)
processed = processed.replace(/((?:^[\s]*[-*] .+$\n?)+)/gm, (listBlock) => {
const items = listBlock.trim().split('\n');
let html = '<ul>';
for (const item of items) {
const text = item.replace(/^\s*[-*] /, '');
html += `<li>${text}</li>`;
}
html += '</ul>';
return html;
});
// Ordered lists (1. 2. etc)
processed = processed.replace(/((?:^\d+\. .+$\n?)+)/gm, (listBlock) => {
const items = listBlock.trim().split('\n');
let html = '<ol>';
for (const item of items) {
const text = item.replace(/^\d+\. /, '');
html += `<li>${text}</li>`;
}
html += '</ol>';
return html;
});
// Inline formatting
processed = processed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/\*([^*]+)\*/g, '<em>$1</em>');
processed = processed.replace(/~~([^~]+)~~/g, '<del>$1</del>');
processed = processed.replace(/`([^`]+)`/g, '<code>$1</code>');
// Links [text](url)
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Line breaks
processed = processed.replace(/\n/g, '<br>');
// Restore code blocks with syntax highlighting
for (let i = 0; i < codeBlocks.length; i++) {
const { lang, code } = codeBlocks[i];
const escapedCode = escapeHtml(code);
const langLabel = lang && lang !== 'none' ? `<span class="code-lang">${lang}</span>` : '';
const copyBtn = `<button class="code-copy-btn" onclick="copyCodeBlock(this)" title="Copy"><i class="ri-file-copy-line"></i></button>`;
processed = processed.replace(
`%%CODEBLOCK_${i}%%`,
`<div class="code-block-wrapper">${langLabel}${copyBtn}<pre><code class="language-${lang}">${escapedCode}</code></pre></div>`
);
}
// Linkify file paths
processed = linkifyFilePaths(processed);
// Linkify Excalidraw URLs — render as diagram links with icon
processed = linkifyExcalidrawUrls(processed);
// Trigger Prism highlighting after DOM update
setTimeout(() => {
document.querySelectorAll('.message-content pre code[class*="language-"]').forEach(el => {
if (window.Prism && !el.classList.contains('prism-highlighted')) {
Prism.highlightElement(el);
el.classList.add('prism-highlighted');
}
});
}, 10);
return processed;
}
function copyCodeBlock(btn) {
const pre = btn.closest('.code-block-wrapper').querySelector('code');
if (!pre) return;
navigator.clipboard.writeText(pre.textContent).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="ri-check-line"></i>';
setTimeout(() => btn.innerHTML = orig, 1000);
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
uploadFile(file);
}
}
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
let url = '/upload';
if (sessionId) {
url += `?session_id=${encodeURIComponent(sessionId)}`;
}
try {
const response = await fetch(url, {method: 'POST', body: formData});
const data = await response.json();
if (response.ok) {
if (data.session_id && !sessionId) {
sessionId = data.session_id;
localStorage.setItem('sessionId', sessionId);
}
refreshFiles();
addMessage(`File uploaded: ${data.filename} (${formatBytes(data.size)})`, 'system');
} else {
addMessage(`Upload error: ${data.detail}`, 'system');
}
} catch (error) {
addMessage(`Upload error: ${error.message}`, 'system');
}
}
const expandedFolders = new Set();
async function refreshFiles() {
if (!sessionId) return;
try {
const response = await fetch(`/files?session_id=${encodeURIComponent(sessionId)}`);
if (!response.ok) return;
const data = await response.json();
const uploads = data.uploads || [];
const workspace = data.workspace || [];
if (uploads.length === 0 && workspace.length === 0) {
filesList.innerHTML = '<p class="empty-state">No files yet</p>';
return;
}
let html = '';
if (uploads.length > 0) {
html += '<div class="file-section-title"><i class="ri-upload-cloud-2-line"></i> Uploads</div>';
html += uploads.map(file => {
const name = typeof file === 'string' ? file.split('/').pop() : file;
const fullPath = `/uploads/${name}`;
const iconClass = getFileIconClass(name);
return `
<div class="file-item clickable" onclick="openFilePreview('${escapeHtml(fullPath)}')" title="Click to preview">
<i class="${iconClass}"></i>
<span>${escapeHtml(name)}</span>
</div>
`;
}).join('');
}
if (workspace.length > 0) {
html += '<div class="file-section-title"><i class="ri-folder-3-line"></i> Workspace</div>';
const tree = buildFileTree(workspace);
html += renderFileTree(tree, 0);
}
filesList.innerHTML = html;
} catch (error) {
filesList.innerHTML = '<p class="empty-state">Error loading files</p>';
}
}
function buildFileTree(filePaths) {
const root = {};
for (const filePath of filePaths) {
let normalizedPath = filePath;
if (normalizedPath.startsWith('/workspace/')) {
normalizedPath = normalizedPath.slice('/workspace/'.length);
} else if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.slice(1);
}
const parts = normalizedPath.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (i === parts.length - 1) {
current[part] = {__isFile: true, __path: filePath};
} else {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
}
}
return root;
}
function renderFileTree(node, depth, parentPath = '/workspace') {
let html = '';
const entries = Object.entries(node).sort((a, b) => {
const aIsFile = a[1].__isFile;
const bIsFile = b[1].__isFile;
if (aIsFile && !bIsFile) return 1;
if (!aIsFile && bIsFile) return -1;
return a[0].localeCompare(b[0]);
});
for (const [name, value] of entries) {
if (name.startsWith('__')) continue;
const currentPath = `${parentPath}/${name}`;
const indent = depth * 12;
if (value.__isFile) {
const iconClass = getFileIconClass(name);
html += `
<div class="file-item clickable" style="padding-left: ${indent + 8}px"
onclick="openFilePreview('${escapeHtml(value.__path)}')" title="${escapeHtml(value.__path)}">
<i class="${iconClass}"></i>
<span>${escapeHtml(name)}</span>
</div>
`;
} else {
const isExpanded = expandedFolders.has(currentPath);
const folderIcon = isExpanded ? 'ri-folder-open-line' : 'ri-folder-line';
const chevronIcon = isExpanded ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line';
html += `
<div class="folder-item ${isExpanded ? 'expanded' : ''}" style="padding-left: ${indent + 8}px"
onclick="toggleFolder('${escapeHtml(currentPath)}')">
<i class="folder-chevron ${chevronIcon}"></i>
<i class="folder-icon ${folderIcon}"></i>
<span>${escapeHtml(name)}</span>
</div>
`;
if (isExpanded) {
html += `<div class="folder-children">`;
html += renderFileTree(value, depth + 1, currentPath);
html += `</div>`;
}
}
}
return html;
}
function toggleFolder(folderPath) {
if (expandedFolders.has(folderPath)) {
expandedFolders.delete(folderPath);
} else {
expandedFolders.add(folderPath);
}
refreshFiles();
}
// ---------------------------------------------------------------------------
// Sticky Tasks Panel (above input)
// ---------------------------------------------------------------------------
let latestTodos = [];
let tasksCollapsed = true;
let tasksStartTime = 0;
let tasksTimerInterval = null;
function handleTodosUpdate(todos) {
console.log('[tasks] todos_update received:', todos);
latestTodos = todos || [];
renderTasksPanel(latestTodos);
}
function renderTasksPanel(todos) {
const panel = document.getElementById('tasks-panel');
const label = document.getElementById('tasks-label');
const badge = document.getElementById('tasks-badge');
const fill = document.getElementById('tasks-progress-fill');
const preview = document.getElementById('tasks-preview');
const list = document.getElementById('tasks-list');
const elapsed = document.getElementById('tasks-elapsed');
if (!todos || todos.length === 0) {
panel.style.display = 'none';
_stopTasksTimer();
return;
}
panel.style.display = 'block';
const completed = todos.filter(t => t.status === 'completed').length;
const total = todos.length;
const pct = Math.round((completed / total) * 100);
const allDone = completed === total;
const inProgress = todos.find(t => t.status === 'in_progress');
label.textContent = allDone ? 'Tasks Complete' : 'Task Progress';
badge.textContent = `${completed}/${total}`;
panel.classList.toggle('all-done', allDone);
fill.style.width = pct + '%';
if (!allDone && tasksStartTime === 0) {
tasksStartTime = Date.now();
_startTasksTimer();
}
if (allDone) _stopTasksTimer();
elapsed.style.display = (!allDone && tasksStartTime) ? 'flex' : 'none';
if (tasksCollapsed && inProgress && total > 1) {
const remaining = total - completed - 1;
preview.innerHTML =
`<i class="ri-loader-4-line"></i>` +
`<span class="tasks-preview-text">${escapeHtml(inProgress.active_form || inProgress.content)}</span>` +
(remaining > 0 ? `<span class="tasks-more">+${remaining} more</span>` : '');
preview.style.display = 'flex';
} else {
preview.style.display = 'none';
}
if (!tasksCollapsed) {
let html = '';
for (const todo of todos) {
const st = todo.status || 'pending';
const icons = {
completed: 'ri-checkbox-circle-fill',
in_progress: 'ri-loader-4-line',
pending: 'ri-checkbox-blank-circle-line',
};
const text = (st === 'in_progress' && todo.active_form) ? todo.active_form : todo.content;
html += `<div class="task-row ${st}">` +
`<span class="task-icon"><i class="${icons[st] || icons.pending}"></i></span>` +
`<span>${escapeHtml(text)}</span></div>`;
}
list.innerHTML = html;
list.style.display = 'flex';
} else {
list.style.display = 'none';
}
}
function toggleTasksPanel() {
tasksCollapsed = !tasksCollapsed;
document.getElementById('tasks-panel').classList.toggle('expanded', !tasksCollapsed);
renderTasksPanel(latestTodos);
}
function _startTasksTimer() {
if (tasksTimerInterval) return;
tasksTimerInterval = setInterval(() => {
const secs = Math.floor((Date.now() - tasksStartTime) / 1000);
const el = document.getElementById('tasks-elapsed-text');
if (el) el.textContent = secs < 60 ? `${secs}s` : `${Math.floor(secs/60)}m ${secs%60}s`;
}, 1000);
}
function _stopTasksTimer() {
if (tasksTimerInterval) { clearInterval(tasksTimerInterval); tasksTimerInterval = null; }
tasksStartTime = 0;
}
function resetTasksPanel() {
latestTodos = [];
tasksCollapsed = true;
_stopTasksTimer();
const panel = document.getElementById('tasks-panel');
if (panel) { panel.style.display = 'none'; panel.classList.remove('all-done', 'expanded'); }
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
let currentPreviewPath = null;
let currentPreviewContent = null;
let currentPreviewMode = 'code';
const filePreviewPanel = document.getElementById('file-preview-panel');
const previewFilename = document.getElementById('preview-filename');
const previewContainer = document.getElementById('preview-container');
const previewIcon = document.getElementById('preview-icon');
const previewModeToggle = document.getElementById('preview-mode-toggle');
const PREVIEWABLE_EXTENSIONS = ['html', 'htm', 'svg'];
async function openFilePreview(filePath) {
if (!sessionId) return;
try {
const filename = filePath.split('/').pop();
previewFilename.textContent = filename;
const iconClass = getFileIconClass(filename);
previewIcon.innerHTML = `<i class="${iconClass}"></i>`;
filePreviewPanel.classList.remove('hidden');
previewContainer.innerHTML = '<div style="padding: 20px; color: var(--text-muted);">Loading...</div>';
const ext = filename.split('.').pop().toLowerCase();
if (PREVIEWABLE_EXTENSIONS.includes(ext)) {
previewModeToggle.classList.add('visible');
} else {
previewModeToggle.classList.remove('visible');
currentPreviewMode = 'code';
}
updatePreviewModeButtons();
if (ext === 'pdf') {
currentPreviewPath = filePath;
currentPreviewContent = '';
previewContainer.innerHTML = `
<embed class="embed-container" src="/files/binary/${encodeURIComponent(filePath)}?session_id=${encodeURIComponent(sessionId)}" type="application/pdf">
`;
return;
}
const response = await fetch(`/files/content/${encodeURIComponent(filePath)}?session_id=${encodeURIComponent(sessionId)}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load file');
}
const data = await response.json();
currentPreviewPath = filePath;
currentPreviewContent = stripLineNumbers(data.content);
renderPreview(filename, currentPreviewContent);
} catch (error) {
console.error('Error loading file:', error);
previewContainer.innerHTML = `<div style="padding: 20px; color: var(--error);">Error loading file: ${escapeHtml(error.message)}</div>`;
}
}
function setPreviewMode(mode) {
currentPreviewMode = mode;
updatePreviewModeButtons();
if (currentPreviewPath && currentPreviewContent) {
const filename = currentPreviewPath.split('/').pop();
renderPreview(filename, currentPreviewContent);
}
}
function updatePreviewModeButtons() {
const buttons = previewModeToggle.querySelectorAll('.mode-btn');
buttons.forEach(btn => {
if (btn.dataset.mode === currentPreviewMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function renderPreview(filename, content) {
const ext = filename.split('.').pop().toLowerCase();
if (currentPreviewMode === 'preview' && PREVIEWABLE_EXTENSIONS.includes(ext)) {
renderLivePreview(content, ext);
return;
}
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'].includes(ext)) {
const imageUrl = `/files/binary/${encodeURIComponent(currentPreviewPath)}?session_id=${encodeURIComponent(sessionId)}`;
previewContainer.innerHTML = `
<div style="display:flex; justify-content:center; align-items:center; height:100%; padding:20px; background:#1a1a1a;">
<img src="${imageUrl}" alt="${escapeHtml(filename)}" style="max-width:100%; max-height:100%; object-fit:contain; border-radius:4px;">
</div>
`;
return;
}
if (ext === 'csv') {
const tableHtml = parseCSVtoTable(content);
previewContainer.innerHTML = `<div class="csv-container">${tableHtml}</div>`;
return;
}
if (ext === 'pdf') {
previewContainer.innerHTML = `
<embed class="embed-container" src="/files/binary/${encodeURIComponent(currentPreviewPath)}?session_id=${sessionId}" type="application/pdf">
`;
return;
}
const languageMap = {
'js': 'javascript', 'py': 'python', 'rs': 'rust', 'html': 'html',
'css': 'css', 'json': 'json', 'md': 'markdown', 'sh': 'bash',
'ts': 'typescript', 'go': 'go', 'java': 'java', 'cpp': 'cpp',
'htm': 'html', 'svg': 'xml'
};
const lang = languageMap[ext] || 'none';
const pre = document.createElement('pre');
const code = document.createElement('code');
code.className = `language-${lang}`;
code.textContent = content;
pre.appendChild(code);
previewContainer.innerHTML = '';
previewContainer.appendChild(pre);
if (window.Prism) {
Prism.highlightElement(code);
}
}
function renderLivePreview(content, ext) {
const iframe = document.createElement('iframe');
iframe.className = 'live-preview-frame';
iframe.sandbox = 'allow-scripts allow-same-origin';
previewContainer.innerHTML = '';
previewContainer.appendChild(iframe);
if (ext === 'svg') {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #1a1a1a;
}
svg {
max-width: 90%;
max-height: 90vh;
}
</style>
</head>
<body>${content}</body>
</html>
`);
iframeDoc.close();
} else {
const previewUrl = `/preview/${sessionId}${currentPreviewPath}`;
iframe.src = previewUrl;
}
}
function stripLineNumbers(content) {
if (!content) return content;
return content.split('\n').map(line => {
const match = line.match(/^\s*\d+\t(.*)$/);
return match ? match[1] : line;
}).join('\n');
}
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
function parseCSVtoTable(csvText) {
const lines = csvText.trim().split(/\r?\n/);
if (lines.length === 0) return '<p>Empty CSV</p>';
const headers = parseCSVLine(lines[0]);
const numCols = headers.length;
let html = '<table class="csv-table"><thead><tr>';
html += `<th class="row-num">#</th>`;
headers.forEach(h => html += `<th>${escapeHtml(h)}</th>`);
html += '</tr></thead><tbody>';
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const row = parseCSVLine(lines[i]);
html += '<tr>';
html += `<td class="row-num">${i}</td>`;
for (let j = 0; j < numCols; j++) {
const cell = row[j] || '';
const displayCell = cell.length > 100 ? cell.substring(0, 100) + '...' : cell;
html += `<td title="${escapeHtml(cell)}">${escapeHtml(displayCell)}</td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
const rowCount = lines.length - 1;
html = `<div class="csv-info">${rowCount} rows x ${numCols} columns</div>` + html;
return html;
}
function closeFilePreview() {
filePreviewPanel.classList.add('hidden');
currentPreviewPath = null;
currentPreviewContent = null;
}
async function copyFileContent() {
if (!currentPreviewContent) return;
try {
await navigator.clipboard.writeText(currentPreviewContent);
const btn = filePreviewPanel.querySelector('.preview-btn[onclick="copyFileContent()"]');
const originalIcon = btn.innerHTML;
btn.innerHTML = '<i class="ri-check-line" style="color: var(--success)"></i>';
setTimeout(() => btn.innerHTML = originalIcon, 1000);
} catch (error) {
console.error('Failed to copy:', error);
}
}
function downloadPreviewFile() {
if (!currentPreviewPath || !currentPreviewContent) return;
const filename = currentPreviewPath.split('/').pop();
const blob = new Blob([currentPreviewContent], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Report Export (MD / HTML / PDF)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Background task toast notifications
// ---------------------------------------------------------------------------
function updateBackgroundBadge() {
let badge = document.getElementById('bg-agents-badge');
if (backgroundAgentCount <= 0) {
if (badge) badge.style.display = 'none';
return;
}
if (!badge) {
badge = document.createElement('div');
badge.id = 'bg-agents-badge';
badge.className = 'bg-agents-badge';
document.querySelector('.input-container').prepend(badge);
}
badge.style.display = 'flex';
badge.innerHTML = `<i class="ri-loader-4-line spinning"></i> ${backgroundAgentCount} agent${backgroundAgentCount > 1 ? 's' : ''} working`;
}
function showBackgroundTaskToast(data) {
// Update background agent tracking
if (data.task_id) {
backgroundAgentTasks.delete(data.task_id);
backgroundAgentCount = backgroundAgentTasks.size;
updateBackgroundBadge();
}
const isSuccess = data.status === 'completed';
const isError = data.status === 'failed';
const durationText = data.duration_seconds ? ` in ${data.duration_seconds.toFixed(1)}s` : '';
const statusLabel = isSuccess ? 'Completed' : isError ? 'Failed' : data.status;
const statusClass = isSuccess ? 'completed' : isError ? 'failed' : 'info';
const statusIcon = isSuccess ? 'ri-checkbox-circle-fill' : isError ? 'ri-error-warning-fill' : 'ri-information-fill';
// Always insert a result card into the chat
_insertSubagentResultCard(data, statusClass, statusIcon, statusLabel, durationText);
// Skip toast during replay (card is enough)
if (isReplay) return;
// Brief toast notification (just the header, no "View Result" button)
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${isSuccess ? 'success' : isError ? 'error' : 'info'}`;
toast.innerHTML = `
<div class="toast-icon"><i class="${statusIcon}"></i></div>
<div class="toast-body">
<div class="toast-title">${escapeHtml(data.subagent_name)}${statusLabel}${durationText}</div>
<div class="toast-desc">${escapeHtml(data.description || '').substring(0, 80)}</div>
</div>
<button class="toast-close" onclick="this.closest('.toast').remove()"><i class="ri-close-line"></i></button>
`;
container.appendChild(toast);
// Auto-dismiss after 5s (result is already in chat)
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function _insertSubagentResultCard(data, statusClass, statusIcon, statusLabel, durationText) {
// Find or create assistant message container for the result
let msgEl = currentMessageEl;
if (!msgEl) {
msgEl = createMessageContainer('assistant');
}
const toolsEl = msgEl.querySelector('.message-tools');
if (!toolsEl) return;
const catColor = '#06b6d4'; // agents category color
const resultEl = document.createElement('div');
resultEl.className = `subagent-result-card ${statusClass}`;
resultEl.innerHTML = `
<div class="subagent-result-header">
<span class="tool-icon-badge" style="background:${catColor}22;color:${catColor}"><i class="ri-robot-2-line"></i></span>
<span class="subagent-result-name" style="color:${catColor}">${escapeHtml(data.subagent_name)}</span>
<span class="subagent-result-status ${statusClass}"><i class="${statusIcon}"></i> ${statusLabel}${durationText}</span>
</div>
<div class="subagent-result-desc">${escapeHtml(data.description || '')}</div>
${data.result_preview ? `
<div class="subagent-result-body collapsed">
<div class="subagent-result-content">${formatMessage(data.result_preview)}</div>
</div>
<button class="subagent-result-toggle" onclick="_toggleSubagentResult(this)">
<i class="ri-arrow-down-s-line"></i> Show result
</button>
` : ''}
${data.error ? `<div class="subagent-result-error">${escapeHtml(data.error.substring(0, 300))}</div>` : ''}
`;
toolsEl.appendChild(resultEl);
autoScroll();
}
function _toggleSubagentResult(btn) {
const card = btn.closest('.subagent-result-card');
const body = card.querySelector('.subagent-result-body');
const isCollapsed = body.classList.contains('collapsed');
body.classList.toggle('collapsed');
btn.innerHTML = isCollapsed
? '<i class="ri-arrow-up-s-line"></i> Hide result'
: '<i class="ri-arrow-down-s-line"></i> Show result';
}
// ---------------------------------------------------------------------------
// Report auto-preview
// ---------------------------------------------------------------------------
function handleReportUpdated(data) {
const path = data.path;
if (!path) return;
// Auto-open file preview with the report
openFilePreview(path);
}
function getFileIconClass(filename) {
const ext = filename.split('.').pop().toLowerCase();
const icons = {
'py': 'ri-code-s-slash-line',
'js': 'ri-javascript-line',
'ts': 'ri-braces-line',
'json': 'ri-braces-line',
'csv': 'ri-grid-line',
'md': 'ri-markdown-line',
'txt': 'ri-file-text-line',
'html': 'ri-html5-line',
'css': 'ri-css3-line',
'pdf': 'ri-file-pdf-line',
'zip': 'ri-file-zip-line',
'png': 'ri-image-line',
'jpg': 'ri-image-line'
};
return icons[ext] || 'ri-file-line';
}
function linkifyFilePaths(html) {
const pathPattern = /(\/(?:workspace|uploads|app|home|tmp|var|etc)\/[^\s<>"'`,;()[\]{}]+\.[a-zA-Z0-9]+)/g;
return html.replace(pathPattern, (match, path) => {
const cleanPath = path.replace(/[.,;:!?)]+$/, '');
const trailing = path.slice(cleanPath.length);
return `<span class="file-link" onclick="openFilePreview('${cleanPath}')" title="Click to preview">${escapeHtml(cleanPath)}</span>${trailing}`;
});
}
function linkifyExcalidrawUrls(html) {
// Turn excalidraw.com shareable URLs into clickable links
let result = html.replace(
/\[([^\]]*)\]\((https:\/\/excalidraw\.com\/#json=[^\)]+)\)/g,
'<a href="$2" target="_blank" class="excalidraw-link"><i class="ri-artboard-line"></i> $1</a>'
);
result = result.replace(
/(https:\/\/excalidraw\.com\/#json=[^\s<"']+)/g,
'<a href="$1" target="_blank" class="excalidraw-link"><i class="ri-artboard-line"></i> Open in Excalidraw</a>'
);
return result;
}
// --- Config Panel ---
async function renderConfigPanel() {
const configPanel = document.getElementById('config-panel');
if (!configPanel) return;
if (!configData) {
try {
const response = await fetch('/config');
if (response.ok) {
const raw = await response.json();
configData = raw.features || raw;
// Config loaded
}
} catch (e) {
configPanel.innerHTML = '<p class="empty-state">Error loading config</p>';
return;
}
}
if (!configData) {
configPanel.innerHTML = '<p class="empty-state">No config available</p>';
return;
}
let html = '';
// Runtime
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-server-line"></i> Runtime</div>
<div class="config-item">
<span class="config-label">Docker Runtime</span>
<span class="config-value">${escapeHtml(configData.runtime || 'default')}</span>
</div>
</div>
`;
// Hooks
const hooks = configData.hooks || [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-shield-check-line"></i> Hooks (${hooks.length})</div>
`;
for (const hook of hooks) {
html += `
<div class="config-item">
<span class="config-label">${escapeHtml(hook.name || hook.event)}</span>
<span class="config-value tag">${escapeHtml(hook.event)}</span>
</div>
`;
if (hook.description) {
html += `<div class="config-sub-item">${escapeHtml(hook.description)}</div>`;
}
if (hook.matcher) {
html += `<div class="config-sub-item">matcher: <code>${escapeHtml(hook.matcher)}</code></div>`;
}
if (hook.background) {
html += `<div class="config-sub-item">background: true</div>`;
}
}
html += '</div>';
// Middleware
const middleware = configData.middleware || [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-stack-line"></i> Middleware (${middleware.length})</div>
`;
for (const mw of middleware) {
const name = typeof mw === 'string' ? mw : (mw.name || 'unknown');
const desc = typeof mw === 'object' && mw.description ? mw.description : '';
html += `
<div class="config-item">
<span class="config-label">${escapeHtml(name)}</span>
${desc ? `<span class="config-value tag">${escapeHtml(desc)}</span>` : ''}
</div>
`;
}
html += '</div>';
// Processors
const procs = configData.processors || {};
const eviction = procs.eviction || {};
const slidingWindow = procs.sliding_window || {};
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-cpu-line"></i> Processors</div>
<div class="config-item">
<span class="config-label">Eviction Limit</span>
<span class="config-value">${eviction.token_limit ? eviction.token_limit + ' tokens' : 'disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">Patch Tool Calls</span>
<span class="config-value">${procs.patch_tool_calls ? 'enabled' : 'disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">Sliding Window</span>
<span class="config-value">${slidingWindow.trigger ? slidingWindow.trigger + ' -> keep ' + slidingWindow.keep : 'disabled'}</span>
</div>
</div>
`;
// MCP Servers
const mcpServers = configData.mcp_servers || [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-plug-line"></i> MCP Servers (${mcpServers.length})</div>
`;
for (const server of mcpServers) {
const name = typeof server === 'string' ? server : (server.name || server.prefix || 'unknown');
const stype = typeof server === 'object' && server.type ? server.type : '';
html += `
<div class="config-item">
<span class="config-label">${escapeHtml(name)}</span>
${stype ? `<span class="config-value tag">${escapeHtml(stype)}</span>` : ''}
</div>
`;
}
html += '</div>';
// Context Files
const contextFiles = configData.context_files || [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-file-info-line"></i> Context Files (${contextFiles.length})</div>
`;
for (const cf of contextFiles) {
html += `<div class="config-item"><span class="config-label">${escapeHtml(cf)}</span></div>`;
}
html += '</div>';
// Checkpointing
const cp = configData.checkpointing || {};
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-save-line"></i> Checkpointing</div>
<div class="config-item">
<span class="config-label">Status</span>
<span class="config-value">${cp.enabled ? 'enabled' : 'disabled'}</span>
</div>
${cp.enabled ? `
<div class="config-item">
<span class="config-label">Frequency</span>
<span class="config-value">${escapeHtml(cp.frequency || 'unknown')}</span>
</div>
<div class="config-item">
<span class="config-label">Max Checkpoints</span>
<span class="config-value">${cp.max_checkpoints || 'unlimited'}</span>
</div>
` : ''}
</div>
`;
// Features
const interruptTools = configData.interrupt_on
? Object.entries(configData.interrupt_on).filter(([, v]) => v).map(([k]) => k)
: [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-toggle-line"></i> Features</div>
<div class="config-item">
<span class="config-label">Image Support</span>
<span class="config-value">${configData.image_support ? 'enabled' : 'disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">Human-in-the-Loop</span>
<span class="config-value">${interruptTools.length > 0 ? interruptTools.map(t => escapeHtml(t)).join(', ') : 'disabled'}</span>
</div>
</div>
`;
// Subagents
const subagents = configData.subagents || [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-group-line"></i> Subagents (${subagents.length})</div>
`;
for (const sa of subagents) {
const saName = typeof sa === 'string' ? sa : (sa.name || 'unknown');
const saDesc = typeof sa === 'object' && sa.description ? sa.description : '';
html += `
<div class="config-item">
<span class="config-label">${escapeHtml(saName)}</span>
${saDesc ? `<span class="config-value tag">${escapeHtml(saDesc)}</span>` : ''}
</div>
`;
}
if (configData.general_purpose_subagent) {
html += `
<div class="config-item">
<span class="config-label">general-purpose</span>
<span class="config-value tag">built-in</span>
</div>
`;
}
html += '</div>';
// Skills
const skills = configData.skills || [];
const skillDirs = configData.skill_directories || [];
html += `
<div class="config-section">
<div class="config-section-title"><i class="ri-lightbulb-line"></i> Skills</div>
`;
for (const dir of skillDirs) {
html += `<div class="config-item"><span class="config-label">Dir: ${escapeHtml(typeof dir === 'string' ? dir : dir.path)}</span></div>`;
}
for (const sk of skills) {
html += `
<div class="config-item">
<span class="config-label">${escapeHtml(sk.name || sk)}</span>
<span class="config-value tag">programmatic</span>
</div>
`;
}
html += '</div>';
// Tool Usage Stats
html += `
<div class="config-section" id="tool-stats-section">
<div class="config-section-title"><i class="ri-bar-chart-box-line"></i> Tool Usage (Live)</div>
<div id="tool-stats-content">
${renderToolStats()}
</div>
</div>
`;
configPanel.innerHTML = html;
}
function renderToolStats() {
let html = `
<div class="config-item">
<span class="config-label">Total Calls</span>
<span class="config-value">${toolStats.call_count}</span>
</div>
<div class="config-item">
<span class="config-label">Total Duration</span>
<span class="config-value">${(toolStats.total_duration_ms / 1000).toFixed(1)}s</span>
</div>
`;
const toolsUsed = toolStats.tools_used || {};
const entries = Object.entries(toolsUsed).sort((a, b) => b[1] - a[1]);
if (entries.length > 0) {
html += '<div class="config-sub-item" style="margin-top: 4px; font-weight: 500;">Breakdown:</div>';
for (const [tool, count] of entries) {
html += `
<div class="config-item">
<span class="config-label" style="padding-left: 8px;">${escapeHtml(tool)}</span>
<span class="config-value">${count}</span>
</div>
`;
}
}
return html;
}
function updateToolStatsDisplay() {
const statsContent = document.getElementById('tool-stats-content');
if (statsContent) {
statsContent.innerHTML = renderToolStats();
}
}
// --- Middleware & Hook Event Handlers ---
function handleMiddlewareEvent(data) {
if (data.total_calls !== undefined) {
toolStats.call_count = data.total_calls;
}
if (data.total_duration_ms !== undefined) {
toolStats.total_duration_ms = data.total_duration_ms;
}
if (data.tools_breakdown) {
toolStats.tools_used = data.tools_breakdown;
}
updateToolStatsDisplay();
if (data.event === 'tool_audit' && data.tool_name && currentMessageEl) {
const toolsEl = currentMessageEl.querySelector('.message-tools');
if (toolsEl) {
const badge = document.createElement('div');
badge.className = 'middleware-badge';
badge.innerHTML = `<i class="ri-shield-check-line"></i> ${escapeHtml(data.tool_name)} <span class="mw-count">#${data.total_calls}</span>`;
toolsEl.appendChild(badge);
}
}
}
// --- Plan Mode: Ask User Question ---
function handleAskUserQuestion(data) {
if (!currentMessageEl) {
currentMessageEl = createMessageContainer('assistant');
}
const toolsEl = currentMessageEl.querySelector('.message-tools');
if (!toolsEl) return;
const container = document.createElement('div');
container.className = 'ask-user-container';
container.dataset.questionId = data.question_id;
// Question text
const questionEl = document.createElement('div');
questionEl.className = 'ask-user-question';
questionEl.innerHTML = `<i class="ri-question-line"></i> ${escapeHtml(data.question)}`;
container.appendChild(questionEl);
// Options
const optionsEl = document.createElement('div');
optionsEl.className = 'ask-user-options';
(data.options || []).forEach(option => {
const btn = document.createElement('button');
btn.className = 'ask-user-option';
const isRecommended = (option.recommended || '').toLowerCase() === 'true';
if (isRecommended) btn.classList.add('recommended');
btn.innerHTML = `
<div class="option-top">
<span class="option-label">${escapeHtml(option.label)}</span>
${isRecommended ? '<span class="option-badge">Recommended</span>' : ''}
</div>
${option.description ? `<span class="option-desc">${escapeHtml(option.description)}</span>` : ''}
`;
btn.onclick = () => sendQuestionAnswer(data.question_id, option.label, container);
optionsEl.appendChild(btn);
});
container.appendChild(optionsEl);
// Custom answer input
const customEl = document.createElement('div');
customEl.className = 'ask-user-custom';
const inputEl = document.createElement('input');
inputEl.type = 'text';
inputEl.placeholder = 'Or type your own answer...';
inputEl.className = 'ask-user-input';
inputEl.onkeydown = (e) => {
if (e.key === 'Enter' && inputEl.value.trim()) {
sendQuestionAnswer(data.question_id, inputEl.value.trim(), container);
}
};
const submitBtn = document.createElement('button');
submitBtn.className = 'ask-user-submit';
submitBtn.innerHTML = '<i class="ri-send-plane-fill"></i>';
submitBtn.onclick = () => {
if (inputEl.value.trim()) {
sendQuestionAnswer(data.question_id, inputEl.value.trim(), container);
}
};
customEl.appendChild(inputEl);
customEl.appendChild(submitBtn);
container.appendChild(customEl);
toolsEl.appendChild(container);
forceScroll();
inputEl.focus();
}
function sendQuestionAnswer(questionId, answer, container) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
question_answer: {
question_id: questionId,
answer: answer,
}
}));
if (container) {
container.classList.add('answered');
const answeredEl = document.createElement('div');
answeredEl.className = 'ask-user-answered';
answeredEl.innerHTML = `<i class="ri-check-line"></i> ${escapeHtml(answer)}`;
container.appendChild(answeredEl);
}
}
function handleHookEvent(data) {
if (!currentMessageEl) return;
const toolsEl = currentMessageEl.querySelector('.message-tools');
if (!toolsEl) return;
const hookEl = document.createElement('div');
hookEl.className = `hook-event ${data.allowed === false ? 'blocked' : ''}`;
const icon = data.allowed === false ? 'ri-shield-cross-line' : 'ri-shield-check-line';
const label = data.allowed === false ? 'BLOCKED' : 'HOOK';
hookEl.innerHTML = `
<i class="${icon}"></i>
<span class="hook-label">${label}</span>
<span class="hook-detail">${escapeHtml(data.hook_name || data.event || '')}${data.tool_name ? ' on ' + escapeHtml(data.tool_name) : ''}</span>
${data.reason ? `<span class="hook-reason">${escapeHtml(data.reason)}</span>` : ''}
`;
toolsEl.appendChild(hookEl);
autoScroll();
}
// ---------------------------------------------------------------------------
// Checkpointing: Timeline, Rewind, Fork
// ---------------------------------------------------------------------------
let checkpoints = [];
function handleCheckpointSaved(data) {
const existing = checkpoints.findIndex(cp => cp.id === data.checkpoint_id);
const cpData = {
id: data.checkpoint_id,
label: data.label,
turn: data.turn,
message_count: data.message_count,
metadata: data.metadata || {},
created_at: new Date().toISOString(),
};
if (existing >= 0) {
checkpoints[existing] = cpData;
} else {
checkpoints.push(cpData);
}
if (currentMessageEl) {
// Store checkpoint on the message element
currentMessageEl.dataset.checkpointId = data.checkpoint_id;
currentMessageEl.dataset.checkpointLabel = data.label;
// Populate the action bar with rewind/fork buttons
const actionsEl = currentMessageEl.querySelector('.message-actions');
if (actionsEl) {
_renderMessageActions(actionsEl, data.checkpoint_id, data.label);
}
}
if (currentOutlineTab === 'timeline') {
renderTimeline();
}
}
async function handleCheckpointRewind(data) {
messagesContainer.innerHTML = '';
await loadHistory();
addMessage(
`Rewound to checkpoint **${escapeHtml(data.label)}** (${data.message_count} messages restored).`,
'system'
);
const idx = checkpoints.findIndex(cp => cp.id === data.checkpoint_id);
if (idx >= 0) {
checkpoints = checkpoints.slice(0, idx + 1);
}
if (currentOutlineTab === 'timeline') {
renderTimeline();
}
}
async function loadCheckpoints() {
if (!sessionId) {
renderTimeline();
return;
}
try {
const response = await fetch(`/checkpoints?session_id=${encodeURIComponent(sessionId)}`);
if (response.ok) {
const data = await response.json();
checkpoints = data.checkpoints || [];
}
} catch (e) {
console.error('Failed to load checkpoints:', e);
}
renderTimeline();
}
function renderTimeline() {
const panel = document.getElementById('timeline-panel');
if (!panel) return;
if (checkpoints.length === 0) {
panel.innerHTML = `
<div class="timeline-empty">
<i class="ri-time-line"></i>
<p>No checkpoints yet</p>
<span>Checkpoints are saved automatically after each turn.</span>
</div>
`;
return;
}
const sorted = [...checkpoints].reverse();
let html = `<div class="timeline-header">
<span>${checkpoints.length} checkpoint${checkpoints.length !== 1 ? 's' : ''}</span>
</div>`;
html += '<div class="timeline-list">';
for (const cp of sorted) {
const time = cp.created_at ? new Date(cp.created_at).toLocaleTimeString() : '';
const toolName = cp.metadata?.last_tool || '';
const isLatest = cp === sorted[0];
html += `
<div class="timeline-item ${isLatest ? 'latest' : ''}">
<div class="timeline-dot-col">
<div class="timeline-dot ${isLatest ? 'pulse' : ''}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
<div class="timeline-label">${escapeHtml(cp.label)}</div>
<div class="timeline-meta">
<span title="Turn ${cp.turn}"><i class="ri-repeat-line"></i> ${cp.turn}</span>
<span title="${cp.message_count} messages"><i class="ri-chat-3-line"></i> ${cp.message_count}</span>
${toolName ? `<span class="timeline-tool" title="Last tool: ${escapeHtml(toolName)}"><i class="ri-tools-line"></i> ${escapeHtml(toolName)}</span>` : ''}
${time ? `<span class="timeline-time"><i class="ri-time-line"></i> ${time}</span>` : ''}
</div>
<div class="timeline-actions">
<button class="timeline-btn rewind" onclick="rewindToCheckpoint('${cp.id}', '${escapeHtml(cp.label)}')" title="Rewind to this point">
<i class="ri-arrow-go-back-line"></i> Rewind
</button>
<button class="timeline-btn fork" onclick="forkFromCheckpoint('${cp.id}')" title="Fork into new session">
<i class="ri-git-branch-line"></i> Fork
</button>
</div>
</div>
</div>
`;
}
html += '</div>';
panel.innerHTML = html;
}
async function rewindToCheckpoint(checkpointId, label) {
if (!confirm(`Rewind to checkpoint "${label}"? Messages after this point will be discarded.`)) {
return;
}
try {
const response = await fetch(
`/checkpoints/${checkpointId}/rewind?session_id=${encodeURIComponent(sessionId)}`,
{ method: 'POST' }
);
if (response.ok) {
const data = await response.json();
messagesContainer.innerHTML = '';
await loadHistory();
addMessage(
`Rewound to checkpoint **${escapeHtml(data.label)}** (${data.message_count} messages restored).`,
'system'
);
const idx = checkpoints.findIndex(cp => cp.id === checkpointId);
if (idx >= 0) {
checkpoints = checkpoints.slice(0, idx + 1);
}
renderTimeline();
} else {
const err = await response.json();
alert('Rewind failed: ' + (err.detail || 'Unknown error'));
}
} catch (e) {
alert('Rewind failed: ' + e.message);
}
}
async function forkFromCheckpoint(checkpointId) {
try {
const response = await fetch(
`/checkpoints/${checkpointId}/fork?session_id=${encodeURIComponent(sessionId)}`,
{ method: 'POST' }
);
if (response.ok) {
const data = await response.json();
const newUrl = `${window.location.origin}/?session_id=${data.new_session_id}`;
window.open(newUrl, '_blank');
addMessage(
`Forked new session from checkpoint (${data.message_count} messages). Opened in new tab.`,
'system'
);
} else {
const err = await response.json();
alert('Fork failed: ' + (err.detail || 'Unknown error'));
}
} catch (e) {
alert('Fork failed: ' + e.message);
}
}
// ---------------------------------------------------------------------------
// Right-Side Conversation Outline Panel
// ---------------------------------------------------------------------------
let outlinePanelVisible = true;
let currentOutlineTab = 'messages';
function toggleOutlinePanel() {
const panel = document.getElementById('outline-panel');
const floatBtn = document.getElementById('outline-float-btn');
if (!panel) return;
outlinePanelVisible = !outlinePanelVisible;
panel.classList.toggle('collapsed', !outlinePanelVisible);
if (floatBtn) {
floatBtn.style.display = outlinePanelVisible ? 'none' : 'flex';
}
}
function switchOutlineTab(tab) {
currentOutlineTab = tab;
document.querySelectorAll('.outline-tab').forEach(b => b.classList.remove('active'));
const activeBtn = document.querySelector(`.outline-tab[data-outline-tab="${tab}"]`);
if (activeBtn) activeBtn.classList.add('active');
const outlineList = document.getElementById('outline-list');
const timelinePanel = document.getElementById('timeline-panel');
if (tab === 'messages') {
outlineList.style.display = 'block';
timelinePanel.style.display = 'none';
} else {
outlineList.style.display = 'none';
timelinePanel.style.display = 'block';
loadCheckpoints();
}
}
function updateOutlinePanel() {
const list = document.getElementById('outline-list');
if (!list) return;
const messages = messagesContainer.querySelectorAll('.message');
if (messages.length === 0) {
list.innerHTML = '<div class="outline-empty">No messages yet</div>';
return;
}
let html = '';
messages.forEach((msgEl, idx) => {
const isUser = msgEl.classList.contains('user');
const isAssistant = msgEl.classList.contains('assistant');
const isSystem = msgEl.classList.contains('system');
let icon, roleClass, text;
if (isUser) {
icon = 'ri-user-smile-line';
roleClass = 'user';
const contentEl = msgEl.querySelector('.message-content');
text = contentEl ? contentEl.textContent.trim() : '';
} else if (isAssistant) {
icon = 'ri-search-eye-line';
roleClass = 'assistant';
// Check if there are tool cards
const toolCards = msgEl.querySelectorAll('.tool-call');
const contentEl = msgEl.querySelector('.message-content');
const contentText = contentEl ? contentEl.textContent.trim() : '';
if (toolCards.length > 0 && !contentText) {
// Tool-only message — show first tool name
const firstTool = toolCards[0];
const toolName = firstTool.querySelector('.tool-name, .search-provider, .subagent-name');
text = toolName ? toolName.textContent.trim() : 'Tool call';
icon = 'ri-tools-line';
roleClass = 'tool';
} else {
text = contentText;
}
} else if (isSystem) {
icon = 'ri-error-warning-fill';
roleClass = 'system';
const contentEl = msgEl.querySelector('.message-content');
text = contentEl ? contentEl.textContent.trim() : 'System';
} else {
return;
}
// Truncate text
if (!text) text = '...';
if (text.length > 50) text = text.substring(0, 50) + '…';
html += `<div class="outline-item ${roleClass}" data-msg-idx="${idx}" onclick="scrollToMessage(${idx})">
<span class="outline-item-icon"><i class="${icon}"></i></span>
<span class="outline-item-text">${escapeHtml(text)}</span>
</div>`;
});
list.innerHTML = html;
}
function scrollToMessage(idx) {
const messages = messagesContainer.querySelectorAll('.message');
if (idx >= 0 && idx < messages.length) {
messages[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
// Highlight active item in outline
const items = document.querySelectorAll('.outline-item');
items.forEach(item => item.classList.remove('active'));
const activeItem = document.querySelector(`.outline-item[data-msg-idx="${idx}"]`);
if (activeItem) activeItem.classList.add('active');
}
}
// Track active outline item based on scroll position
let outlineScrollTimer = null;
if (messagesContainer) {
messagesContainer.addEventListener('scroll', () => {
if (outlineScrollTimer) clearTimeout(outlineScrollTimer);
outlineScrollTimer = setTimeout(updateActiveOutlineItem, 100);
});
}
function updateActiveOutlineItem() {
const messages = messagesContainer.querySelectorAll('.message');
const items = document.querySelectorAll('.outline-item');
if (messages.length === 0 || items.length === 0) return;
const containerRect = messagesContainer.getBoundingClientRect();
const midY = containerRect.top + containerRect.height * 0.3;
let closestIdx = 0;
let closestDist = Infinity;
messages.forEach((msgEl, idx) => {
const rect = msgEl.getBoundingClientRect();
const dist = Math.abs(rect.top - midY);
if (dist < closestDist) {
closestDist = dist;
closestIdx = idx;
}
});
items.forEach(item => item.classList.remove('active'));
const activeItem = document.querySelector(`.outline-item[data-msg-idx="${closestIdx}"]`);
if (activeItem) {
activeItem.classList.add('active');
// Scroll outline list to show active item
activeItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
// ---------------------------------------------------------------------------
// Session List Management
// ---------------------------------------------------------------------------
let sessionsList = [];
async function loadSessionsList() {
try {
const resp = await fetch('/sessions');
if (!resp.ok) return;
const data = await resp.json();
sessionsList = data.sessions || [];
renderSessionsList();
populateWelcomeSessions();
} catch (e) {
console.error('Failed to load sessions:', e);
}
}
function renderSessionsList() {
const container = document.getElementById('sessions-list');
if (!container) return;
if (sessionsList.length === 0) {
container.innerHTML = '<div class="sessions-empty"><i class="ri-chat-new-line"></i> No sessions yet</div>';
return;
}
container.innerHTML = sessionsList.map(s => {
const isActive = s.session_id === sessionId;
const title = escapeHtml(s.title || 'New Session');
const time = _timeAgo(s.updated_at);
const count = s.message_count || 0;
return `
<div class="session-item${isActive ? ' session-active' : ''}" data-sid="${s.session_id}" onclick="switchSession('${s.session_id}')">
<div class="session-item-main">
<span class="session-title">${title}</span>
<span class="session-meta">${time} · ${count} msgs</span>
</div>
<button class="session-delete-btn" onclick="event.stopPropagation(); deleteSession('${s.session_id}')" title="Delete">
<i class="ri-delete-bin-line"></i>
</button>
</div>
`;
}).join('');
}
function _timeAgo(isoStr) {
if (!isoStr) return '';
const date = new Date(isoStr);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return date.toLocaleDateString();
}
async function switchSession(sid) {
if (sid === sessionId) return;
// Close current WebSocket and reset state
if (ws) ws.close();
sessionId = sid;
localStorage.setItem('sessionId', sid);
// Reset UI state
currentMessageEl = null;
currentToolsEl = null;
streamedText = '';
rawStreamedText = '';
isAgentRunning = false;
excalidrawCanvasShown = false;
latestTodos = [];
resetTasksPanel();
messagesContainer.innerHTML = '';
_hideExcalidrawContainer();
// Reconnect and load
connectWebSocket();
await loadSession(sid);
refreshFiles();
renderSessionsList();
}
async function createNewSession() {
// Close current connection
if (ws) ws.close();
sessionId = null;
localStorage.removeItem('sessionId');
// Reset UI
currentMessageEl = null;
currentToolsEl = null;
streamedText = '';
rawStreamedText = '';
isAgentRunning = false;
excalidrawCanvasShown = false;
latestTodos = [];
resetTasksPanel();
_hideExcalidrawContainer();
toolStats = { call_count: 0, tools_used: {}, total_duration_ms: 0 };
configData = null;
checkpoints = [];
// Show welcome screen
messagesContainer.innerHTML = `
<div class="message system">
<div class="message-content">
<div class="welcome-banner">
<h3><i class="ri-search-eye-line"></i> DeepResearch</h3>
<p>Full-featured autonomous research agent with web search, code execution, subagents, plan mode, and Excalidraw diagrams.</p>
<div class="capabilities">
<span><i class="ri-search-line"></i> Web Search</span> \u2022
<span><i class="ri-links-line"></i> URL Reader</span> \u2022
<span><i class="ri-file-text-line"></i> File Ops</span> \u2022
<span><i class="ri-terminal-box-line"></i> Shell Execution</span> \u2022
<span><i class="ri-group-line"></i> Subagents</span> \u2022
<span><i class="ri-draft-line"></i> Plan Mode</span> \u2022
<span><i class="ri-artboard-line"></i> Excalidraw</span> \u2022
<span><i class="ri-image-line"></i> Images</span> \u2022
<span><i class="ri-time-line"></i> Checkpoints</span>
</div>
<div id="welcome-sessions" class="welcome-sessions"></div>
</div>
</div>
</div>
`;
populateWelcomeSessions();
connectWebSocket();
refreshFiles();
renderSessionsList();
}
async function deleteSession(sid) {
if (!confirm('Delete this session permanently?')) return;
try {
await fetch(`/sessions/${encodeURIComponent(sid)}`, { method: 'DELETE' });
if (sid === sessionId) {
await createNewSession();
}
await loadSessionsList();
} catch (e) {
console.error('Failed to delete session:', e);
}
}
function populateWelcomeSessions() {
const container = document.getElementById('welcome-sessions');
if (!container) return;
const recent = sessionsList.slice(0, 5);
if (recent.length === 0) {
container.innerHTML = `
<div class="welcome-sessions-empty">
<i class="ri-chat-smile-2-line"></i>
<span>Start your first research session below</span>
</div>
`;
return;
}
container.innerHTML = `
<div class="welcome-sessions-label">Recent Sessions</div>
<div class="welcome-sessions-grid">
${recent.map(s => {
const title = escapeHtml(s.title || 'New Session');
const time = _timeAgo(s.updated_at);
const count = s.message_count || 0;
return `
<button class="welcome-session-card" onclick="switchSession('${s.session_id}')">
<span class="wsc-title">${title}</span>
<span class="wsc-meta">${time} · ${count} msgs</span>
</button>
`;
}).join('')}
</div>
`;
}