let currentConversationId = null; let isStreaming = false; let abortController = null; let currentGroupFilter = null; let groups = []; let expandedGroups = new Set(); let groupLocations = {}; let currentSettingsGroupId = null; let selectedGroupId = null; let agents = []; let selectedAgent = "none"; let llmSettings = null; let activeStreamUi = null; let cancelRequested = false; const conversationRenderState = { pendingToolBubble: null, }; const messageInputMaxLines = 12; function generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatElapsed(ms) { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${String(seconds).padStart(2, '0')}`; } function formatStepLabel(step, total) { if (!Number.isFinite(step) || step < 1) { return ''; } if (Number.isFinite(total) && total > 0) { return `Step ${step} of ${total}`; } return `Step ${step}`; } function formatTokenCount(tokens) { const value = Number(tokens); if (!Number.isFinite(value) || value <= 0) { return ''; } return `${value.toLocaleString('en-US')} ${value === 1 ? 'token' : 'tokens'}`; } function setSettingsError(message) { const errorEl = document.getElementById('settingsFormError'); if (!errorEl) return; if (message) { errorEl.textContent = message; errorEl.classList.remove('hidden'); } else { errorEl.textContent = ''; errorEl.classList.add('hidden'); } } function normalizeRuntimeSettings(settings) { if (!settings) return settings; return { ...settings, reasoning_effort: settings.reasoning_effort ?? '', auto_open_reasoning: settings.auto_open_reasoning !== false, }; } function reasoningAutoOpenEnabled() { return !llmSettings || llmSettings.auto_open_reasoning !== false; } function populateSettingsForm(settings) { if (!settings) return; const normalized = normalizeRuntimeSettings(settings); const fieldMap = { settingsProvider: normalized.provider ?? '', settingsBaseURL: normalized.base_url ?? '', settingsAPIKey: normalized.api_key ?? '', settingsModel: normalized.model ?? '', settingsTemperature: normalized.temperature ?? '', settingsMaxTokens: normalized.max_tokens ?? '', settingsTopP: normalized.top_p ?? '', settingsFrequencyPenalty: normalized.frequency_penalty ?? '', settingsPresencePenalty: normalized.presence_penalty ?? '', settingsServerHost: normalized.server_host ?? '', settingsServerAddr: normalized.server_addr ?? '', settingsContextTokens: normalized.context_tokens ?? '', settingsTriggerRatio: normalized.context_compaction_trigger_ratio ?? '', settingsTargetRatio: normalized.context_compaction_target_ratio ?? '', }; for (const [id, value] of Object.entries(fieldMap)) { const input = document.getElementById(id); if (input) { input.value = value; } } const reasoningEffortInput = document.getElementById('settingsReasoningEffort'); if (reasoningEffortInput) { reasoningEffortInput.value = normalized.reasoning_effort ?? ''; } const autoOpenReasoningInput = document.getElementById('settingsAutoOpenReasoning'); if (autoOpenReasoningInput) { autoOpenReasoningInput.checked = normalized.auto_open_reasoning !== false; } } async function loadLLMSettings() { const res = await fetch('/api/settings'); if (!res.ok) { throw new Error('Failed to load runtime settings'); } llmSettings = normalizeRuntimeSettings(await res.json()); return llmSettings; } async function openLLMSettingsModal() { try { const settings = await loadLLMSettings(); populateSettingsForm(settings); setSettingsError(''); const modal = document.getElementById('settingsModal'); modal.classList.remove('hidden'); modal.classList.add('flex'); const input = document.getElementById('settingsProvider'); setTimeout(() => input.focus(), 50); } catch (err) { console.error('Failed to load runtime settings:', err); setSettingsError('Unable to load settings right now.'); } } function closeLLMSettingsModal() { const modal = document.getElementById('settingsModal'); modal.classList.add('hidden'); modal.classList.remove('flex'); setSettingsError(''); } async function saveLLMSettings() { const providerInput = document.getElementById('settingsProvider'); const baseURLInput = document.getElementById('settingsBaseURL'); const apiKeyInput = document.getElementById('settingsAPIKey'); const modelInput = document.getElementById('settingsModel'); const temperatureInput = document.getElementById('settingsTemperature'); const maxTokensInput = document.getElementById('settingsMaxTokens'); const topPInput = document.getElementById('settingsTopP'); const frequencyPenaltyInput = document.getElementById('settingsFrequencyPenalty'); const presencePenaltyInput = document.getElementById('settingsPresencePenalty'); const reasoningEffortInput = document.getElementById('settingsReasoningEffort'); const autoOpenReasoningInput = document.getElementById('settingsAutoOpenReasoning'); const serverHostInput = document.getElementById('settingsServerHost'); const serverAddrInput = document.getElementById('settingsServerAddr'); const tokensInput = document.getElementById('settingsContextTokens'); const triggerInput = document.getElementById('settingsTriggerRatio'); const targetInput = document.getElementById('settingsTargetRatio'); const provider = providerInput.value.trim(); const baseURL = baseURLInput.value.trim(); const apiKey = apiKeyInput.value; const model = modelInput.value.trim(); const temperature = Number(temperatureInput.value); const maxTokens = Number(maxTokensInput.value); const topP = Number(topPInput.value); const frequencyPenalty = Number(frequencyPenaltyInput.value); const presencePenalty = Number(presencePenaltyInput.value); const reasoningEffort = reasoningEffortInput.value; const autoOpenReasoning = autoOpenReasoningInput.checked; const serverHost = serverHostInput.value.trim(); const serverAddr = serverAddrInput.value.trim(); const contextTokens = Number(tokensInput.value); const triggerRatio = Number(triggerInput.value); const targetRatio = Number(targetInput.value); if (!provider) { setSettingsError('Provider is required.'); return; } if (!baseURL) { setSettingsError('Base URL is required.'); return; } if (!model) { setSettingsError('Model is required.'); return; } if (!Number.isFinite(temperature) || temperature < 0) { setSettingsError('Temperature must be 0 or higher.'); return; } if (!Number.isFinite(maxTokens) || maxTokens <= 0) { setSettingsError('Max tokens must be a positive number.'); return; } if (!Number.isFinite(topP) || topP <= 0 || topP > 1) { setSettingsError('Top P must be greater than 0 and less than or equal to 1.'); return; } if (!Number.isFinite(frequencyPenalty)) { setSettingsError('Frequency penalty must be a number.'); return; } if (!Number.isFinite(presencePenalty)) { setSettingsError('Presence penalty must be a number.'); return; } if (!['', 'low', 'medium', 'high'].includes(reasoningEffort)) { setSettingsError('Reasoning effort must be Default, Low, Medium, or High.'); return; } if (!serverHost) { setSettingsError('Server host is required.'); return; } if (!serverAddr) { setSettingsError('Server address is required.'); return; } if (!Number.isFinite(contextTokens) || contextTokens <= 0) { setSettingsError('Prompt budget must be a positive number.'); return; } if (!Number.isFinite(triggerRatio) || triggerRatio <= 0 || triggerRatio >= 1) { setSettingsError('Compaction trigger must be greater than 0 and less than 1.'); return; } if (!Number.isFinite(targetRatio) || targetRatio <= 0 || targetRatio >= triggerRatio) { setSettingsError('Compaction target must be greater than 0 and less than the trigger.'); return; } try { const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, base_url: baseURL, api_key: apiKey, model, temperature, max_tokens: maxTokens, top_p: topP, frequency_penalty: frequencyPenalty, presence_penalty: presencePenalty, reasoning_effort: reasoningEffort, auto_open_reasoning: autoOpenReasoning, server_host: serverHost, server_addr: serverAddr, context_tokens: contextTokens, context_compaction_trigger_ratio: triggerRatio, context_compaction_target_ratio: targetRatio, }), }); const data = await res.json().catch(() => null); if (!res.ok) { throw new Error((data && data.error) || 'Failed to save settings'); } llmSettings = normalizeRuntimeSettings(data); closeLLMSettingsModal(); } catch (err) { console.error('Failed to save runtime settings:', err); setSettingsError(err.message || 'Failed to save settings'); } } function updateStreamingControls() { const sendBtn = document.getElementById('sendBtn'); const cancelBtn = document.getElementById('cancelBtn'); if (sendBtn) { sendBtn.disabled = isStreaming; sendBtn.textContent = isStreaming ? 'Sending...' : 'Send'; } if (cancelBtn) { cancelBtn.classList.toggle('hidden', !isStreaming); cancelBtn.disabled = !isStreaming; } } // Marked preserves raw HTML, so sanitize the rendered fragment before it hits innerHTML. const safeMarkdownTags = new Set([ 'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'input', 'li', 'ol', 'p', 'pre', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', ]); function isSafeMarkdownUrl(value) { const trimmed = String(value || '').trim(); if (!trimmed) { return false; } try { const url = new URL(trimmed, window.location.href); return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol); } catch (err) { return false; } } function sanitizeRenderedMarkdown(html) { if (!html || typeof document === 'undefined') { return html; } const template = document.createElement('template'); template.innerHTML = html; function sanitizeElement(element) { for (const child of Array.from(element.children)) { sanitizeElement(child); } const tagName = element.tagName.toLowerCase(); if (!safeMarkdownTags.has(tagName)) { const parent = element.parentNode; if (!parent) { return; } while (element.firstChild) { parent.insertBefore(element.firstChild, element); } parent.removeChild(element); return; } for (const attr of Array.from(element.attributes)) { const attrName = attr.name.toLowerCase(); let keepAttr = false; switch (tagName) { case 'a': keepAttr = ['href', 'title', 'target', 'rel'].includes(attrName); if (keepAttr && attrName === 'href' && !isSafeMarkdownUrl(attr.value)) { keepAttr = false; } break; case 'img': keepAttr = ['src', 'alt', 'title', 'width', 'height', 'loading'].includes(attrName); if (keepAttr && attrName === 'src' && !isSafeMarkdownUrl(attr.value)) { keepAttr = false; } break; case 'code': case 'pre': keepAttr = attrName === 'class'; break; case 'input': keepAttr = ['type', 'checked', 'disabled'].includes(attrName); if (keepAttr && attrName === 'type' && String(attr.value).toLowerCase() !== 'checkbox') { keepAttr = false; } break; case 'th': case 'td': keepAttr = ['colspan', 'rowspan'].includes(attrName); break; default: keepAttr = false; break; } if (attrName.startsWith('on') || !keepAttr) { element.removeAttribute(attr.name); } } if (tagName === 'a' && element.hasAttribute('href')) { element.setAttribute('rel', 'noreferrer noopener'); } } for (const child of Array.from(template.content.children)) { sanitizeElement(child); } return template.innerHTML; } function renderMarkdown(text) { marked.setOptions({ breaks: true, gfm: true, }); return sanitizeRenderedMarkdown(marked.parse(text)); } function completeIncompleteBlocks(text) { // Count unclosed code blocks let codeBlockOpen = (text.match(/```/g) || []).length; if (codeBlockOpen % 2 !== 0) { text += '\n```'; } // Complete unclosed list items let lines = text.split('\n'); let lastLine = lines[lines.length - 1]; if (lastLine && lastLine.trim() && !lastLine.trim().endsWith(':') && !lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('.')) { // Check if we're in the middle of a code block or already completed if ((text.match(/```/g) || []).length % 2 === 0) { // Not in a code block, add a newline to help markdown parser text += '\n\n'; } } return text; } function resizeMessageInput(input) { if (!input) return; const style = window.getComputedStyle(input); const lineHeight = parseFloat(style.lineHeight) || 24; const paddingTop = parseFloat(style.paddingTop) || 0; const paddingBottom = parseFloat(style.paddingBottom) || 0; const borderTop = parseFloat(style.borderTopWidth) || 0; const borderBottom = parseFloat(style.borderBottomWidth) || 0; const maxHeight = (lineHeight * messageInputMaxLines) + paddingTop + paddingBottom + borderTop + borderBottom; input.style.height = 'auto'; const nextHeight = Math.min(input.scrollHeight + borderTop + borderBottom, maxHeight); input.style.height = `${nextHeight}px`; input.style.overflowY = input.scrollHeight + borderTop + borderBottom > maxHeight ? 'auto' : 'hidden'; } function resetConversationRenderState() { conversationRenderState.pendingToolBubble = null; } function splitNormalizedLines(text) { const normalized = String(text || '').replace(/\r\n/g, '\n'); if (normalized === '') { return []; } const lines = normalized.split('\n'); if (lines.length > 0 && lines[lines.length - 1] === '') { lines.pop(); } return lines; } function stripReadFileLinePrefix(line) { const match = String(line || '').match(/^\s*\d+:\s?(.*)$/); return match ? match[1] : String(line || ''); } function normalizePatchExpectedLines(expectedText) { const lines = splitNormalizedLines(expectedText); if (lines.length === 0) { return []; } if ( lines.length >= 2 && lines[0].startsWith('File: ') && lines[1].startsWith('Lines ') && lines[1].includes(' of ') ) { return lines.slice(2).map(stripReadFileLinePrefix); } const normalized = []; for (const line of lines) { if (/^\s*\d+:\s?/.test(line)) { normalized.push(stripReadFileLinePrefix(line)); continue; } return lines; } return normalized; } function parseJsonObject(text) { const trimmed = String(text || '').trim(); if (!trimmed) { return null; } try { return JSON.parse(trimmed); } catch (err) { return null; } } function prettyPrintValue(value) { if (typeof value === 'string') { return value; } if (value === null || value === undefined) { return ''; } try { return JSON.stringify(value, null, 2); } catch (err) { return String(value); } } function humanizeToolName(name) { const trimmed = String(name || '').trim(); if (!trimmed) { return 'Tool'; } return trimmed .replace(/_/g, ' ') .replace(/\s+/g, ' ') .replace(/\b\w/g, char => char.toUpperCase()); } function formatToolRangeLabel(startLine, endLine) { const start = Number(startLine); if (!Number.isFinite(start) || start <= 0) { return ''; } if (endLine === -1) { return `${start}-EOF`; } const end = Number(endLine); if (!Number.isFinite(end)) { return `${start}-EOF`; } return `${start}-${end}`; } function renderToolSummaryRows(rows) { const visibleRows = rows.filter(row => row && row[0] && row[1] !== undefined && row[1] !== null && String(row[1]).trim() !== ''); if (visibleRows.length === 0) { return ''; } return `
${visibleRows.map(([label, value]) => `
${escapeHtml(String(label))}
${escapeHtml(String(value))}
`).join('')}
`; } function renderToolTextBlock(text, extraClass = '') { const rawText = String(text ?? ''); const content = rawText.trim() ? rawText : 'No output'; return `
${escapeHtml(content)}
`; } function renderToolPlaceholder(text) { return `
${escapeHtml(String(text || 'Waiting for result...'))}
`; } function renderPatchDiffHtml(args) { const path = String(args?.path || '').trim() || 'patch'; const oldLines = normalizePatchExpectedLines(args?.expected_text || ''); const newLines = splitNormalizedLines(args?.content || ''); const startLine = Number(args?.start_line); const endLine = Number(args?.end_line); const rangeLabel = formatToolRangeLabel(startLine, endLine); const diffWindowLabel = rangeLabel ? `range ${rangeLabel}` : 'requested range'; const header = [ `--- a/${path}`, `+++ b/${path}`, `@@ ${diffWindowLabel} ยท -${oldLines.length} +${newLines.length} @@`, ]; if (oldLines.length === 0 && newLines.length === 0) { return `
${escapeHtml(header.join('\n'))}
No diff content available.
`; } const diffLines = []; for (const line of oldLines) { diffLines.push(`
- ${escapeHtml(line)}
`); } for (const line of newLines) { diffLines.push(`
+ ${escapeHtml(line)}
`); } return `
${escapeHtml(header.join('\n'))}
${diffLines.join('')}
`; } function renderToolExecutionSection(toolName, args, rawContent) { const normalizedName = String(toolName || '').toLowerCase(); const rows = []; let extraHtml = ''; switch (normalizedName) { case 'read_file': case 'write_file': case 'patch_file': if (args && args.path) { rows.push(['Path', args.path]); } if (args && Object.prototype.hasOwnProperty.call(args, 'start_line')) { rows.push(['Range', formatToolRangeLabel(args.start_line, args.end_line) || '1-EOF']); } if (normalizedName === 'patch_file') { rows.push(['Mode', 'Guarded snippet replacement']); extraHtml = renderPatchDiffHtml(args || {}); } break; case 'run_command': if (args && args.command) { rows.push(['Command', args.command]); } if (args && Array.isArray(args.args) && args.args.length > 0) { rows.push(['Args', args.args.map(arg => JSON.stringify(arg)).join(' ')]); } break; case 'grep': if (args && args.search_term) { rows.push(['Search', args.search_term]); } if (args && args.path) { rows.push(['Path', args.path]); } break; case 'search_files': if (args && args.path_pattern) { rows.push(['Pattern', args.path_pattern]); } break; case 'list_directory': case 'create_directory': case 'delete_file': if (args && args.path) { rows.push(['Path', args.path]); } break; default: break; } const summaryHtml = renderToolSummaryRows(rows); if (normalizedName === 'patch_file') { const patchHtml = `${summaryHtml}${extraHtml}`.trim(); if (patchHtml) { if (summaryHtml && extraHtml) { return `${summaryHtml}
${extraHtml}
`; } return patchHtml; } } if (summaryHtml) { return summaryHtml; } const fallbackText = rawContent && rawContent.trim() ? rawContent : prettyPrintValue(args || {}); return renderToolTextBlock(fallbackText); } function parseReadFileResult(contentText) { const lines = splitNormalizedLines(contentText); if (lines.length < 2 || !lines[0].startsWith('File: ')) { return null; } const path = lines[0].slice('File: '.length).trim(); const secondLine = lines[1] || ''; if (!secondLine) { return { path, note: '', contentLines: [] }; } if (secondLine === 'File is empty.') { return { path, note: secondLine, contentLines: [] }; } if (secondLine.startsWith('Requested lines ')) { return { path, note: secondLine, contentLines: [] }; } const match = secondLine.match(/^Lines (\d+)-(\d+|EOF) of (\d+):$/); if (!match) { return { path, note: '', contentLines: lines.slice(1), }; } return { path, rangeLabel: `Lines ${match[1]}-${match[2]} of ${match[3]}`, contentLines: lines.slice(2), }; } function renderReadFileResultHtml(parsed) { const hasContent = Array.isArray(parsed.contentLines) && parsed.contentLines.length > 0; const previewText = hasContent ? parsed.contentLines.join('\n') : ''; const note = parsed.note || ''; return `
${escapeHtml(parsed.path || 'File')}
${parsed.rangeLabel ? `
${escapeHtml(parsed.rangeLabel)}
` : ''}
${hasContent ? ` ` : ''}
${ hasContent ? ` ` : `
${escapeHtml(note || 'No content')}
` }
`; } function renderToolResultSection(toolName, contentText, args, options = {}) { if (options.placeholder) { return renderToolPlaceholder('Waiting for result...'); } const normalizedName = String(toolName || '').toLowerCase(); const rawText = String(contentText || ''); if (normalizedName === 'read_file') { const parsed = parseReadFileResult(rawText); if (parsed) { return renderReadFileResultHtml(parsed); } } const isError = rawText.trim().toLowerCase().startsWith('error:'); return renderToolTextBlock(rawText || 'No output', isError ? 'tool-text-block-error' : ''); } function wireReadFilePreviewToggle(root) { const previewEl = root.querySelector('[data-tool-file-preview]'); const toggleBtn = root.querySelector('.tool-file-toggle'); if (!previewEl || !toggleBtn) { return; } toggleBtn.addEventListener('click', () => { const expanded = previewEl.classList.toggle('is-expanded'); previewEl.classList.toggle('is-collapsed', !expanded); toggleBtn.textContent = expanded ? 'Collapse to 7 lines' : 'Show full file'; toggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); }); } function createToolBubbleState(message) { const toolName = String(message.name || '').trim(); const agent = String(message.agent || '').trim(); const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0; const tokenBadge = totalTokens > 0 ? `${escapeHtml(formatTokenCount(totalTokens))}` : ''; const args = parseJsonObject(message.content) || {}; const bubble = document.createElement('div'); bubble.className = 'mb-4'; bubble.innerHTML = `
${assistantAvatarHtml('bg-slate-700')}
${escapeHtml(humanizeToolName(toolName))} ${agent ? `${escapeHtml(agent)}` : ''} ${tokenBadge}
`; const executionBody = bubble.querySelector('.tool-execution'); const resultBody = bubble.querySelector('.tool-result'); const state = { bubble, executionBody, resultBody, toolName, agent, args, resolved: false, }; executionBody.innerHTML = renderToolExecutionSection(toolName, args, message.content || ''); resultBody.innerHTML = renderToolResultSection(toolName, '', args, { placeholder: true }); return state; } function createStandaloneToolResultBubble(message) { const toolName = String(message.name || '').trim(); const agent = String(message.agent || '').trim(); const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0; const tokenBadge = totalTokens > 0 ? `${escapeHtml(formatTokenCount(totalTokens))}` : ''; const bubble = document.createElement('div'); bubble.className = 'mb-4'; bubble.innerHTML = `
${assistantAvatarHtml('bg-slate-700')}
${escapeHtml(humanizeToolName(toolName))} ${agent ? `${escapeHtml(agent)}` : ''} ${tokenBadge}
`; const executionBody = bubble.querySelector('.tool-execution'); const resultBody = bubble.querySelector('.tool-result'); executionBody.innerHTML = renderToolPlaceholder('Execution details unavailable.'); resultBody.innerHTML = renderToolResultSection(toolName, message.content || '', {}, {}); wireReadFilePreviewToggle(resultBody); return bubble; } function updatePendingToolBubbleResult(state, message) { if (!state || !state.resultBody) { return; } state.resultBody.innerHTML = renderToolResultSection(state.toolName, message.content || '', state.args); state.resolved = true; wireReadFilePreviewToggle(state.resultBody); } function finalizePendingToolBubble() { const pending = conversationRenderState.pendingToolBubble; if (!pending) { return; } if (!pending.resolved && pending.resultBody) { pending.resultBody.innerHTML = renderToolPlaceholder('No result recorded.'); } conversationRenderState.pendingToolBubble = null; } function buildThinkingPanelHtml(content, options = {}) { const label = options.label || 'Reasoning'; const tokenBadge = options.tokenBadge || ''; const openAttr = (options.open ?? reasoningAutoOpenEnabled()) ? ' open' : ''; return `
${escapeHtml(label)} ${tokenBadge}
${renderMarkdown(completeIncompleteBlocks(content || ''))}
`; } async function loadGroups() { try { const res = await fetch('/api/groups'); groups = await res.json(); } catch (err) { console.error('Failed to load groups:', err); } } async function loadConversations() { await loadGroups(); try { const res = await fetch('/api/conversations'); const data = await res.json(); const listEl = document.getElementById('conversationList'); listEl.innerHTML = ''; // Render groups for (const group of groups) { const apiGroup = data.groups.find(g => g.id === group.id); const groupConvs = (apiGroup?.conversations || []).filter(Boolean); const groupContainer = document.createElement('div'); const isExpanded = expandedGroups.has(group.id); // Group header const groupHeader = document.createElement('div'); groupHeader.className = 'flex items-center gap-2 px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-50 rounded-lg transition-colors'; groupHeader.innerHTML = ` ${escapeHtml(group.name)} ${groupConvs.length} ${Number(group.total_tokens || 0) > 0 ? `${escapeHtml(formatTokenCount(group.total_tokens))}` : ''} `; const deleteBtn = groupHeader.querySelector('.group-btn-delete'); deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (confirm(`Delete workspace "${group.name}"?`)) { await deleteGroup(group.id); } }); const newConvBtn = groupHeader.querySelector('.group-btn-new'); newConvBtn.addEventListener('click', async (e) => { e.stopPropagation(); await createConversationForGroup(group.id); }); const settingsBtn = groupHeader.querySelector('.group-btn-settings'); settingsBtn.addEventListener('click', async (e) => { e.stopPropagation(); await openGroupSettings(group.id); }); groupHeader.addEventListener('click', () => { selectedGroupId = group.id; if (expandedGroups.has(group.id)) { expandedGroups.delete(group.id); } else { expandedGroups.add(group.id); } loadConversations(); }); groupContainer.appendChild(groupHeader); // Group conversations (only if expanded) if (isExpanded) { const groupConvList = document.createElement('div'); groupConvList.className = 'ml-4 space-y-1'; for (const conv of groupConvs) { groupConvList.appendChild(createConversationItem(conv, group.id)); } groupContainer.appendChild(groupConvList); } listEl.appendChild(groupContainer); } // Render ungrouped conversations if (data.ungrouped.length > 0) { const ungroupedHeader = document.createElement('div'); ungroupedHeader.className = 'flex items-center gap-2 px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider'; ungroupedHeader.innerHTML = ` Ungrouped ${data.ungrouped.length} `; listEl.appendChild(ungroupedHeader); const ungroupedList = document.createElement('div'); ungroupedList.className = 'space-y-1'; for (const conv of data.ungrouped) { ungroupedList.appendChild(createConversationItem(conv, null)); } listEl.appendChild(ungroupedList); } } catch (err) { console.error('Failed to load conversations:', err); } } function createConversationItem(conv, groupId) { const item = document.createElement('div'); item.className = 'conversation-item px-3 py-2 rounded-lg cursor-pointer text-sm flex items-center gap-2 group transition-colors'; if (conv.id === currentConversationId) { item.classList.add('bg-blue-100', 'text-blue-700'); } else { item.classList.add('hover:bg-gray-100', 'text-gray-700'); } item.dataset.id = conv.id; const icon = document.createElement('span'); icon.className = 'text-gray-400 flex-shrink-0'; icon.innerHTML = ''; const title = document.createElement('span'); title.className = 'flex-1 truncate'; title.textContent = conv.title; const tokenCount = Number(conv.total_tokens || 0); const tokenBadge = document.createElement('span'); tokenBadge.className = 'rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600 whitespace-nowrap'; tokenBadge.textContent = formatTokenCount(tokenCount); if (!tokenBadge.textContent) { tokenBadge.remove(); } const deleteBtn = document.createElement('button'); deleteBtn.className = 'opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0 p-0.5'; deleteBtn.innerHTML = ''; deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (confirm('Delete this conversation?')) { await deleteConversation(conv.id); } }); item.appendChild(icon); item.appendChild(title); if (tokenBadge.textContent) { item.appendChild(tokenBadge); } item.appendChild(deleteBtn); item.addEventListener('click', () => { if (groupId) selectedGroupId = groupId; loadConversation(conv.id, groupId); }); return item; } async function loadConversation(convId, groupId) { currentConversationId = convId; isStreaming = false; if (abortController) { abortController.abort(); abortController = null; } const resultEl = document.getElementById('result'); const resultText = document.getElementById('resultText'); const emptyState = document.getElementById('emptyState'); resultEl.classList.remove('hidden'); emptyState.classList.add('hidden'); resultText.innerHTML = ''; resetConversationRenderState(); try { const res = await fetch(`/api/conversations/${convId}/messages`); const messages = await res.json(); messages.forEach(msg => { appendConversationMessage(msg); }); finalizePendingToolBubble(); const emptyState = document.getElementById('emptyState'); if (messages.length === 0) { emptyState.classList.remove('hidden'); } else { emptyState.classList.add('hidden'); } document.getElementById('messageInput').focus(); } catch (err) { console.error('Failed to load conversation:', err); } await loadConversations(); if (groupId) { await showGroupLocationInHeader(groupId); } else { const locEl = document.getElementById('headerLocation'); locEl.classList.add('hidden'); locEl.classList.remove('flex'); } } async function createConversation() { try { const body = { title: 'New Conversation' }; const groupId = selectedGroupId || currentGroupFilter || undefined; if (groupId) { body.group_id = groupId; } const res = await fetch('/api/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const conv = await res.json(); await loadConversation(conv.id, groupId); } catch (err) { console.error('Failed to create conversation:', err); } } async function deleteConversation(convId) { try { await fetch(`/api/conversations/${convId}`, { method: 'DELETE' }); if (currentConversationId === convId) { currentConversationId = null; const resultEl = document.getElementById('result'); const resultText = document.getElementById('resultText'); resultEl.classList.add('hidden'); resultText.innerHTML = ''; resetConversationRenderState(); document.getElementById('messageInput').value = ''; } await loadConversations(); } catch (err) { console.error('Failed to delete conversation:', err); } } async function createGroup() { const modal = document.getElementById('groupModal'); modal.classList.remove('hidden'); modal.classList.add('flex'); const input = document.getElementById('groupNameInput'); input.value = ''; setTimeout(() => input.focus(), 50); } function closeGroupModal() { const modal = document.getElementById('groupModal'); modal.classList.add('hidden'); modal.classList.remove('flex'); } async function createGroupFromModal() { const input = document.getElementById('groupNameInput'); const name = input.value.trim(); if (!name) return; try { const res = await fetch('/api/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); const group = await res.json(); expandedGroups.add(group.id); currentGroupFilter = group.id; closeGroupModal(); await loadConversations(); await loadGroupLocation(); } catch (err) { console.error('Failed to create group:', err); } } async function createConversationForGroup(groupId) { try { const body = { title: 'New Conversation', group_id: groupId }; const res = await fetch('/api/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const conv = await res.json(); await loadConversation(conv.id, groupId); } catch (err) { console.error('Failed to create conversation:', err); } } async function deleteGroup(groupId) { groups = groups.filter(g => g.id !== groupId); expandedGroups.delete(groupId); if (currentGroupFilter === groupId) { currentGroupFilter = null; } await loadConversations(); } function assistantAvatarHtml(toneClass) { return `
`; } function labelForMessage(kind, name, agent = '') { if (agent) return agent; if (name) return name; switch ((kind || '').toLowerCase()) { case 'planner': return 'Planner'; case 'programmer': return 'Programmer'; case 'qa': return 'QA'; case 'manager': return 'Manager'; default: return ''; } } function toneForMessageKind(kind) { switch ((kind || '').toLowerCase()) { case 'planner': return 'border-indigo-200 bg-indigo-50 text-indigo-950'; case 'programmer': return 'border-blue-200 bg-blue-50 text-blue-950'; case 'qa': return 'border-emerald-200 bg-emerald-50 text-emerald-950'; case 'manager': return 'border-slate-200 bg-slate-50 text-slate-900'; default: return 'border-gray-200 bg-gray-50 text-gray-800'; } } function scrollConversationToBottom() { const chatArea = document.getElementById('chatScrollArea'); if (chatArea) { chatArea.scrollTop = chatArea.scrollHeight; return; } const resultText = document.getElementById('resultText'); if (resultText) { resultText.scrollTop = resultText.scrollHeight; } } function appendConversationMessage(message) { const resultText = document.getElementById('resultText'); if (!resultText) return null; const role = (message.role || 'assistant').toLowerCase(); const kind = (message.kind || 'message').toLowerCase(); const name = message.name || ''; const agent = message.agent || ''; const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0; const content = message.content || ''; const contentText = content.trim(); let msgDiv = document.createElement('div'); msgDiv.className = 'mb-4'; const tokenBadge = totalTokens > 0 ? `${escapeHtml(formatTokenCount(totalTokens))}` : ''; let shouldAppend = true; if (role === 'user' && kind === 'message') { msgDiv.innerHTML = `
${escapeHtml(content)}
`; } else if (kind === 'thinking') { const label = labelForMessage(kind, name, agent) || 'Reasoning'; msgDiv.innerHTML = `
${assistantAvatarHtml('bg-slate-700')}
${buildThinkingPanelHtml(contentText || '', { label, tokenBadge, })}
`; } else if (kind === 'tool_call') { if (conversationRenderState.pendingToolBubble && !conversationRenderState.pendingToolBubble.resolved) { finalizePendingToolBubble(); } const bubbleState = createToolBubbleState(message); conversationRenderState.pendingToolBubble = bubbleState; msgDiv = bubbleState.bubble; } else if (kind === 'tool_result') { const pending = conversationRenderState.pendingToolBubble; const sameTool = pending && !pending.resolved && String(pending.toolName || '').trim() === String(name || '').trim() && String(pending.agent || '').trim() === String(agent || '').trim(); if (sameTool) { updatePendingToolBubbleResult(pending, message); msgDiv = pending.bubble; shouldAppend = false; conversationRenderState.pendingToolBubble = null; } else { if (pending && !pending.resolved) { finalizePendingToolBubble(); } conversationRenderState.pendingToolBubble = null; msgDiv = createStandaloneToolResultBubble(message); } } else if (kind === 'error') { msgDiv.innerHTML = `
${assistantAvatarHtml('bg-red-600')}
Error
${escapeHtml(contentText || 'Unknown error')}
`; } else if (kind === 'cancelled') { msgDiv.innerHTML = `
${assistantAvatarHtml('bg-slate-500')}
Cancelled
${escapeHtml(contentText || 'Request stopped')}
`; } else { const label = labelForMessage(kind, name, agent); const tone = label === 'Manager' ? 'border-slate-200 bg-slate-50 text-slate-900' : toneForMessageKind(kind); const avatarTone = label === 'Manager' || kind === 'manager' ? 'bg-slate-700' : kind === 'planner' ? 'bg-indigo-600' : kind === 'programmer' ? 'bg-blue-600' : kind === 'qa' ? 'bg-emerald-600' : 'bg-gray-800'; msgDiv.innerHTML = `
${assistantAvatarHtml(avatarTone)}
${(label || tokenBadge) ? `
${label ? `${escapeHtml(label)}` : ''}${tokenBadge}
` : ''}
${renderMarkdown(completeIncompleteBlocks(contentText || ''))}
`; } if (shouldAppend) { resultText.appendChild(msgDiv); } if (activeStreamUi && typeof activeStreamUi.moveToEnd === 'function') { activeStreamUi.moveToEnd(); } scrollConversationToBottom(); return msgDiv; } function statusLabelForKind(kind) { switch ((kind || '').toLowerCase()) { case 'planner': return 'Planner'; case 'programmer': return 'Programmer'; case 'qa': return 'QA'; case 'manager': return 'Manager'; case 'tool_call': case 'tool_result': case 'tool': return 'Tool'; case 'streaming': return 'Streaming'; case 'done': return 'Done'; case 'cancelled': return 'Cancelled'; case 'error': return 'Error'; case 'thinking': return 'Thinking'; default: return 'Working'; } } function createStreamingBubble(initialStatus) { const bubble = document.createElement('div'); bubble.className = 'mb-4'; bubble.innerHTML = `
${assistantAvatarHtml('bg-gray-800')}
Working
0:00 elapsed Working...
`; const statusLabelEl = bubble.querySelector('.stream-status-label'); const statusStepEl = bubble.querySelector('.stream-status-step'); const statusTextEl = bubble.querySelector('.stream-status-text'); const statusElapsedEl = bubble.querySelector('.stream-status-elapsed'); const statusTokensEl = bubble.querySelector('.stream-status-tokens'); const statusIndicatorEl = bubble.querySelector('.stream-status-indicator'); const thinkingContainerEl = bubble.querySelector('.stream-thinking'); const contentEl = bubble.querySelector('.stream-content'); const startTime = Date.now(); let timerId = null; let autoHideTimerId = null; let reasoningText = ''; let thinkingPanelEl = null; let thinkingBodyEl = null; let thinkingTokensEl = null; let reasoningCollapsedByUser = false; if (initialStatus) { statusTextEl.textContent = initialStatus; } let contentMode = false; let finalized = false; let lastStep = 0; let lastTotal = 0; function updateElapsed() { if (statusElapsedEl) { statusElapsedEl.textContent = `${formatElapsed(Date.now() - startTime)} elapsed`; } } function updateTokens(totalTokens) { if (!statusTokensEl) return; const label = formatTokenCount(totalTokens); statusTokensEl.textContent = label; statusTokensEl.classList.toggle('hidden', !label); if (thinkingTokensEl) { thinkingTokensEl.textContent = label; thinkingTokensEl.classList.toggle('hidden', !label); } } function updateProgress(step, total) { if (Number.isFinite(step) && step > 0) { lastStep = step; } if (Number.isFinite(total) && total > 0) { lastTotal = total; } if (statusStepEl) { const label = formatStepLabel(lastStep, lastTotal); statusStepEl.textContent = label; statusStepEl.classList.toggle('hidden', !label); } } function clearAutoHideTimer() { if (autoHideTimerId) { window.clearTimeout(autoHideTimerId); autoHideTimerId = null; } } function stopTimer() { if (timerId) { window.clearInterval(timerId); timerId = null; } } function ensureThinkingPanel() { if (!thinkingContainerEl || thinkingPanelEl) { return; } thinkingContainerEl.classList.remove('hidden'); thinkingContainerEl.innerHTML = `
Reasoning
`; thinkingPanelEl = thinkingContainerEl.querySelector('.thinking-panel'); thinkingBodyEl = thinkingContainerEl.querySelector('.thinking-body'); thinkingTokensEl = thinkingContainerEl.querySelector('.thinking-summary-tokens'); if (thinkingPanelEl) { thinkingPanelEl.addEventListener('toggle', () => { if (thinkingPanelEl.open) { reasoningCollapsedByUser = false; } else if (reasoningText) { reasoningCollapsedByUser = true; } }); } } function updateThinkingBody() { if (!thinkingBodyEl) return; thinkingBodyEl.innerHTML = renderMarkdown(completeIncompleteBlocks(reasoningText)); } function appendReasoningChunk(chunk) { if (!chunk) return; reasoningText += chunk; ensureThinkingPanel(); updateThinkingBody(); if (thinkingPanelEl && reasoningAutoOpenEnabled() && !reasoningCollapsedByUser) { thinkingPanelEl.open = true; } api.moveToEnd(); } timerId = window.setInterval(updateElapsed, 1000); updateElapsed(); const api = { bubble, contentEl, hasStructuredMessages: false, moveToEnd() { if (bubble.parentNode) { bubble.parentNode.appendChild(bubble); } }, setStatus(text, kind, step, total) { if (finalized) return; statusLabelEl.textContent = statusLabelForKind(kind); statusTextEl.textContent = text || ''; if (statusIndicatorEl) { const activeKind = (kind || '').toLowerCase(); statusIndicatorEl.classList.toggle('hidden', activeKind === 'done' || activeKind === 'cancelled' || activeKind === 'error'); } updateProgress(step, total); updateElapsed(); api.moveToEnd(); }, setTokenUsage(totalTokens) { updateTokens(totalTokens); api.moveToEnd(); }, appendReasoning(chunk) { appendReasoningChunk(chunk); scrollConversationToBottom(); }, showContent() { if (contentMode) return; contentMode = true; contentEl.classList.remove('hidden'); api.moveToEnd(); }, finish(options = {}) { if (finalized) return; finalized = true; const kind = options.kind || 'done'; statusLabelEl.textContent = statusLabelForKind(kind); statusTextEl.textContent = options.text || 'Completed'; if (statusIndicatorEl) { statusIndicatorEl.classList.add('hidden'); } updateElapsed(); stopTimer(); clearAutoHideTimer(); api.moveToEnd(); if (options.autoHideMs && options.autoHideMs > 0) { autoHideTimerId = window.setTimeout(() => api.destroy(), options.autoHideMs); } }, cancel(text, autoHideMs) { api.finish({ kind: 'cancelled', text: text || 'Stopped by you', autoHideMs: autoHideMs || 0, }); }, fail(text, autoHideMs) { api.finish({ kind: 'error', text: text || 'Request failed', autoHideMs: autoHideMs || 0, }); }, destroy() { clearAutoHideTimer(); stopTimer(); if (bubble.parentNode) { bubble.remove(); } if (activeStreamUi === api) { activeStreamUi = null; } }, }; return api; } async function sendMessage() { const input = document.getElementById('messageInput'); const rawMessage = input.value; const message = rawMessage.trim(); if (!message || isStreaming) return; isStreaming = true; cancelRequested = false; updateStreamingControls(); const resultEl = document.getElementById('result'); const resultText = document.getElementById('resultText'); const emptyState = document.getElementById('emptyState'); resultEl.classList.remove('hidden'); emptyState.classList.add('hidden'); // Append user message appendConversationMessage({ role: 'user', kind: 'message', content: rawMessage }); input.value = ''; resizeMessageInput(input); // Create a transient bubble that can show live status, then morph into streamed content. const streamUi = createStreamingBubble('Waiting for the agent...'); activeStreamUi = streamUi; resultText.appendChild(streamUi.bubble); scrollConversationToBottom(); let structuredStreamSeen = false; abortController = new AbortController(); const requestSignal = abortController.signal; // Get conversation ID or create new one let convId = currentConversationId; if (!convId) { const titleText = message.replace(/\s+/g, ' ').trim(); const body = { title: titleText.substring(0, 50) }; let groupId = selectedGroupId || currentGroupFilter; if (groupId) { body.group_id = groupId; } const res = await fetch('/api/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: requestSignal, }); const conv = await res.json(); convId = conv.id; currentConversationId = convId; await loadConversations(); } const groupId = selectedGroupId || currentGroupFilter || undefined; try { const fetchRes = await fetch('/api/echo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message, conversation_id: convId, group_id: groupId, agent_types: selectedAgent === 'none' ? [] : [selectedAgent], }), signal: requestSignal, }); if (!fetchRes.body) { throw new Error('No response body'); } const reader = fetchRes.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') continue; let parsed = null; try { parsed = JSON.parse(data); } catch (jsonErr) { parsed = null; } if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'conversation_message' && typeof parsed.kind === 'string') { structuredStreamSeen = true; if (activeStreamUi) { activeStreamUi.hasStructuredMessages = true; } appendConversationMessage(parsed); if (activeStreamUi && parsed.kind === 'message') { activeStreamUi.finish({ kind: 'done', text: 'Completed', autoHideMs: 1200, }); } else if (activeStreamUi && parsed.kind === 'error') { activeStreamUi.fail(parsed.content || 'Request failed'); } continue; } if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'status') { if (activeStreamUi && activeStreamUi.setStatus) { activeStreamUi.setStatus(parsed.content || 'Working...', parsed.kind || 'thinking', parsed.step, parsed.total_steps); scrollConversationToBottom(); } continue; } if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'stream_usage') { if (activeStreamUi && activeStreamUi.setTokenUsage) { activeStreamUi.setTokenUsage(parsed.total_tokens || 0); scrollConversationToBottom(); } continue; } if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'thinking') { if (activeStreamUi && activeStreamUi.appendReasoning) { activeStreamUi.appendReasoning(parsed.content || ''); scrollConversationToBottom(); } continue; } if (activeStreamUi && activeStreamUi.showContent) { activeStreamUi.showContent(); } fullContent += data.replace(/\\n/g, '\n'); if (activeStreamUi && activeStreamUi.contentEl) { activeStreamUi.contentEl.innerHTML = renderMarkdown(completeIncompleteBlocks(fullContent)); scrollConversationToBottom(); } } } } if (activeStreamUi) { activeStreamUi.finish({ kind: 'done', text: structuredStreamSeen ? 'Completed' : 'Completed', autoHideMs: structuredStreamSeen ? 1200 : 0, }); } } catch (err) { if (err.name !== 'AbortError') { console.error('Stream error:', err); if (activeStreamUi) { activeStreamUi.fail(`Error: ${err.message}`); } appendConversationMessage({ role: 'assistant', kind: 'error', content: 'Error: ' + err.message }); } else if (cancelRequested && activeStreamUi) { activeStreamUi.cancel('Stopped by you', activeStreamUi.hasStructuredMessages ? 1200 : 0); } else if (activeStreamUi) { activeStreamUi.fail('Request aborted', activeStreamUi.hasStructuredMessages ? 1200 : 0); } } finally { isStreaming = false; abortController = null; cancelRequested = false; activeStreamUi = null; updateStreamingControls(); input.focus(); loadConversations(); } } async function showGroupLocationInHeader(groupId) { const group = groups.find(g => g.id === groupId); if (!group) { const locEl = document.getElementById('headerLocation'); locEl.classList.add('hidden'); locEl.classList.remove('flex'); return; } try { const res = await fetch(`/api/groups/${groupId}?action=get_location`); if (res.ok) { const data = await res.json(); if (data.location) { groupLocations[groupId] = data.location; } else { delete groupLocations[groupId]; } } } catch (err) { console.error('Failed to load group location:', err); } const locEl = document.getElementById('headerLocation'); const locTextEl = document.getElementById('headerLocationText'); if (groupLocations[groupId]) { locEl.classList.remove('hidden'); locEl.classList.add('flex'); locTextEl.textContent = groupLocations[groupId]; } else { locEl.classList.add('hidden'); locEl.classList.remove('flex'); } renderAgentButtons(); } async function loadGroupLocation(groupId) { if (groups.length === 0) return; const targetGroupId = currentSettingsGroupId || groupId || selectedGroupId; const group = targetGroupId ? groups.find(g => g.id === targetGroupId) || groups[0] : groups[0]; try { const res = await fetch(`/api/groups/${group.id}?action=get_location`); if (res.ok) { const data = await res.json(); if (data.location) { groupLocations[group.id] = data.location; showLocation(data.location, true, group.id); } else { delete groupLocations[group.id]; showLocation(null, false, group.id); } renderAgentButtons(); } } catch (err) { console.error('Failed to load group location:', err); } } function showLocation(location, hasLocation, groupId) { // Always update header location const headerLocationEl = document.getElementById('headerLocation'); const headerLocationTextEl = document.getElementById('headerLocationText'); const headerPickBtn = document.getElementById('pickLocationBtn'); const headerChangeBtn = document.getElementById('changeLocationBtn'); if (hasLocation && location) { headerLocationEl.classList.remove('hidden'); headerLocationEl.classList.add('flex'); headerLocationTextEl.textContent = location; headerPickBtn.classList.add('hidden'); headerPickBtn.classList.remove('flex'); headerChangeBtn.classList.remove('hidden'); headerChangeBtn.classList.add('flex'); } else { headerLocationEl.classList.add('hidden'); headerLocationEl.classList.remove('flex'); headerPickBtn.classList.remove('hidden'); headerPickBtn.classList.add('flex'); } if (groupId) { // Per-group location (settings modal) const locationEl = document.getElementById('groupSettingsLocation'); const locationTextEl = document.getElementById('groupSettingsLocationText'); const pickBtn = document.getElementById('groupSettingsPickBtn'); if (hasLocation && location) { locationEl.classList.remove('hidden'); locationEl.classList.add('flex'); locationTextEl.textContent = location; pickBtn.classList.add('hidden'); pickBtn.classList.remove('flex'); } else { locationEl.classList.add('hidden'); locationEl.classList.remove('flex'); pickBtn.classList.remove('hidden'); pickBtn.classList.add('flex'); } } } async function pickFolderLocation(groupId) { if (typeof window.electronAPI !== 'undefined' && window.electronAPI.pickFolder) { try { const result = await window.electronAPI.pickFolder(); if (result && result.path) { await saveGroupLocation(result.path, groupId); } } catch (err) { console.error('Failed to pick folder:', err); } return; } // Try File System Access API (Chromium browsers only) if (typeof window.showDirectoryPicker === 'function') { try { const dirHandle = await window.showDirectoryPicker(); const path = dirHandle.name; if (path) { await saveGroupLocation(path, groupId); } return; } catch (err) { if (err.name !== 'AbortError') { console.error('File System Access API failed:', err); } } } // Fallback: prompt user to enter folder path const path = prompt('Enter the full path to your workspace folder:'); if (path && path.trim()) { await saveGroupLocation(path.trim(), groupId); } } async function saveGroupLocation(location, groupId) { const group = groupId ? groups.find(g => g.id === groupId) || groups[0] : (selectedGroupId ? groups.find(g => g.id === selectedGroupId) || groups[0] : groups[0]); try { const res = await fetch(`/api/groups/${group.id}?action=set_location`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location }), }); if (res.ok) { groupLocations[group.id] = location; if (groupId) { selectedGroupId = groupId; await loadGroupLocation(groupId); } else { await loadGroupLocation(); } await loadConversations(); renderAgentButtons(); } } catch (err) { console.error('Failed to save group location:', err); } } async function changeGroupLocation(groupId) { await pickFolderLocation(groupId); } async function loadAgents() { try { const res = await fetch('/api/agents'); agents = await res.json(); renderAgentButtons(); } catch (err) { console.error('Failed to load agents:', err); } } function renderAgentButtons() { const container = document.getElementById('agentSelector'); if (!container || agents.length === 0) return; container.innerHTML = ''; const noneBtn = document.createElement('button'); noneBtn.type = 'button'; noneBtn.dataset.agent = 'none'; noneBtn.className = `agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors ${selectedAgent === 'none' ? 'bg-blue-50 border-blue-300 text-blue-700' : ''}`; noneBtn.textContent = 'None'; noneBtn.addEventListener('click', () => { selectedAgent = 'none'; renderAgentButtons(); }); container.appendChild(noneBtn); for (const agent of agents) { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.agent = agent.type; const requiresWorkspace = agent.requires_workspace; const hasWorkspace = selectedGroupId && groupLocations[selectedGroupId]; const hasWorkspaceNeeded = requiresWorkspace && !hasWorkspace; const baseClass = `agent-btn px-3 py-1 text-xs font-medium rounded-lg border text-gray-600 hover:bg-gray-100 transition-colors ${selectedAgent === agent.type ? 'bg-blue-50 border-blue-300 text-blue-700' : ''}`; if (hasWorkspaceNeeded) { btn.className = `${baseClass} opacity-50 cursor-not-allowed`; btn.title = `To use ${agent.display_name}, you must set a workspace folder location in workspace settings`; btn.disabled = true; } else { btn.className = baseClass; btn.title = agent.description; btn.addEventListener('click', () => { selectedAgent = agent.type; renderAgentButtons(); }); } btn.innerHTML = ` ${agent.icon} ${agent.display_name} `; container.appendChild(btn); } } async function openGroupSettings(groupId) { currentSettingsGroupId = groupId; const group = groups.find(g => g.id === groupId); if (!group) return; const modal = document.getElementById('groupSettingsModal'); const nameInput = document.getElementById('groupSettingsNameInput'); nameInput.value = group.name; modal.classList.remove('hidden'); modal.classList.add('flex'); setTimeout(() => nameInput.focus(), 50); await loadGroupLocation(groupId); } function closeGroupSettingsModal() { const modal = document.getElementById('groupSettingsModal'); modal.classList.add('hidden'); modal.classList.remove('flex'); currentSettingsGroupId = null; } async function saveGroupSettings() { if (!currentSettingsGroupId) return; const group = groups.find(g => g.id === currentSettingsGroupId); if (!group) return; const nameInput = document.getElementById('groupSettingsNameInput'); const name = nameInput.value.trim(); if (!name) return; try { await fetch(`/api/groups/${group.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); group.name = name; closeGroupSettingsModal(); await loadConversations(); } catch (err) { console.error('Failed to save group settings:', err); } } // Initialize document.addEventListener('DOMContentLoaded', async () => { document.getElementById('messageForm').addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); }); document.getElementById('cancelBtn').addEventListener('click', () => { if (!isStreaming || !abortController) return; cancelRequested = true; if (activeStreamUi) { activeStreamUi.setStatus('Stopping...', 'cancelled'); } abortController.abort(); updateStreamingControls(); }); document.getElementById('newConversationBtn').addEventListener('click', createConversation); document.getElementById('newGroupBtn').addEventListener('click', createGroup); document.getElementById('messageInput').addEventListener('keydown', (e) => { if ( e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !e.isComposing ) { e.preventDefault(); sendMessage(); } }); const messageInput = document.getElementById('messageInput'); if (messageInput) { resizeMessageInput(messageInput); messageInput.addEventListener('input', () => resizeMessageInput(messageInput)); window.addEventListener('resize', () => resizeMessageInput(messageInput)); } // Mobile menu const mobileMenuBtns = document.querySelectorAll('#mobileMenuBtn'); const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebarOverlay'); mobileMenuBtns.forEach(btn => { btn.addEventListener('click', () => { sidebar.classList.toggle('open'); overlay.classList.toggle('open'); }); }); overlay.addEventListener('click', () => { sidebar.classList.remove('open'); overlay.classList.remove('open'); }); // Mobile menu desktop button const mobileMenuBtnDesktop = document.getElementById('mobileMenuBtnDesktop'); if (mobileMenuBtnDesktop) { mobileMenuBtnDesktop.addEventListener('click', () => { sidebar.classList.toggle('open'); overlay.classList.toggle('open'); }); } // Group modal const groupModal = document.getElementById('groupModal'); document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal); document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal); document.getElementById('groupForm').addEventListener('submit', async (e) => { e.preventDefault(); await createGroupFromModal(); }); groupModal.addEventListener('click', (e) => { if (e.target === groupModal) { closeGroupModal(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeGroupModal(); } }); // Group settings modal const groupSettingsModal = document.getElementById('groupSettingsModal'); document.getElementById('closeGroupSettingsModal').addEventListener('click', closeGroupSettingsModal); document.getElementById('cancelGroupSettingsBtn').addEventListener('click', closeGroupSettingsModal); document.getElementById('groupSettingsForm').addEventListener('submit', async (e) => { e.preventDefault(); await saveGroupSettings(); }); groupSettingsModal.addEventListener('click', (e) => { if (e.target === groupSettingsModal) { closeGroupSettingsModal(); } }); document.getElementById('groupSettingsPickBtn').addEventListener('click', () => { pickFolderLocation(currentSettingsGroupId); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeGroupSettingsModal(); } }); // Runtime settings modal const settingsModal = document.getElementById('settingsModal'); document.getElementById('openSettingsBtn').addEventListener('click', openLLMSettingsModal); document.getElementById('closeSettingsModal').addEventListener('click', closeLLMSettingsModal); document.getElementById('cancelSettingsBtn').addEventListener('click', closeLLMSettingsModal); document.getElementById('settingsForm').addEventListener('submit', async (e) => { e.preventDefault(); await saveLLMSettings(); }); settingsModal.addEventListener('click', (e) => { if (e.target === settingsModal) { closeLLMSettingsModal(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeLLMSettingsModal(); } }); // Load runtime settings before rendering history so reasoning panels use the right default. await loadLLMSettings().catch((err) => { console.error('Failed to preload runtime settings:', err); }); // Load and render agents after the initial conversation list is ready. await loadConversations(); await loadGroupLocation(); loadAgents(); // Folder picker button in header document.getElementById('pickLocationBtn').addEventListener('click', pickFolderLocation); // Change location button (shown when location is locked) document.getElementById('changeLocationBtn').addEventListener('click', changeGroupLocation); updateStreamingControls(); });