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 `
`;
}
function renderToolTextBlock(text, extraClass = '') {
const rawText = String(text ?? '');
const content = rawText.trim() ? rawText : 'No output';
return ``;
}
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 `
`;
}
const diffLines = [];
for (const line of oldLines) {
diffLines.push(`
-
${escapeHtml(line)}
`);
}
for (const line of newLines) {
diffLines.push(`
+
${escapeHtml(line)}
`);
}
return `
`;
}
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 `
`;
}
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 = `
`;
} 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')}
`;
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();
});