diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index ff9c6bf..8642c5a 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -1762,6 +1762,17 @@ func handleStatic(wsToken string) http.HandlerFunc { return } entry, ok := staticFiles[r.URL.Path] + if !ok && strings.HasPrefix(r.URL.Path, "/js/") { + // ES modules under /js/ are served from the embedded ui/js + // directory. Sanitize to a flat .js file name so path traversal + // is impossible. + name := strings.TrimPrefix(r.URL.Path, "/js/") + if name != "" && !strings.Contains(name, "..") && + !strings.ContainsAny(name, "/\\") && strings.HasSuffix(name, ".js") { + entry = [2]string{"ui/js/" + name, "application/javascript; charset=utf-8"} + ok = true + } + } if !ok { http.NotFound(w, r) return diff --git a/cmd/odek/ui/app.js b/cmd/odek/ui/app.js index 9a5a43b..794be33 100644 --- a/cmd/odek/ui/app.js +++ b/cmd/odek/ui/app.js @@ -1,2404 +1,4 @@ -(() => { -'use strict'; - -// ── State ── -// Migrate legacy kode_* keys to odek_* -['history', 'model', 'theme', 'thinking'].forEach(k => { - if (!localStorage.getItem('odek_' + k) && localStorage.getItem('kode_' + k)) { - localStorage.setItem('odek_' + k, localStorage.getItem('kode_' + k)); - localStorage.removeItem('kode_' + k); - } -}); - -let ws = null; -let sessionId = null; -let sessionTokens = {}; // session id -> auth token -let busy = false; -let history = JSON.parse(localStorage.getItem('odek_history') || '[]'); -let historyIdx = -1; -let attachedFiles = []; // {name, size, content} -let currentModel = localStorage.getItem('odek_model') || ''; -let availableModels = []; -// Per-query thinking toggle. Persisted so it survives page refresh. -let thinkingEnabled = localStorage.getItem('odek_thinking') === '1'; - -function getSessionToken(sid) { - if (!sid) return ''; - return sessionTokens[sid] || localStorage.getItem('odek_session_token_' + sid) || ''; -} - -function setSessionToken(sid, token) { - if (!sid || !token) return; - sessionTokens[sid] = token; - localStorage.setItem('odek_session_token_' + sid, token); -} - -function clearSessionToken(sid) { - if (!sid) return; - delete sessionTokens[sid]; - localStorage.removeItem('odek_session_token_' + sid); -} - -// ensureSessionToken returns a stored token for sid, bootstrapping one from -// the server (GET /api/sessions/) when missing. Used by session REST -// mutations (rename/delete) whose tokens may predate this browser. Returns -// '' on failure — the server will answer 401 if a token was required. -async function ensureSessionToken(sid) { - let token = getSessionToken(sid); - if (token) return token; - try { - const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), { - headers: apiHeaders() - }); - if (bootstrap.ok) { - const bs = await bootstrap.json(); - token = bootstrap.headers.get('X-Session-Token') || bs.auth_token; - if (token) setSessionToken(sid, token); - } - } catch { /* continue — server will return 401 if token required */ } - return token || ''; -} - -// ── DOM ── -const messagesEl = document.getElementById('messages'); -const promptEl = document.getElementById('prompt'); -const sendBtn = document.getElementById('send-btn'); -const completionEl = document.getElementById('completion'); -const statusEl = document.getElementById('ws-status'); -const dotEl = document.getElementById('ws-dot'); -const modelLabel = document.getElementById('model-label'); -const sessionListEl = document.getElementById('session-list'); -const sidebarSearch = document.getElementById('sidebar-search'); -const emptyState = document.getElementById('empty-state'); -const cancelBtn = document.getElementById('cancel-btn'); -const scrollBottomBtn = document.getElementById('scroll-bottom-btn'); -const skeletonEl = document.getElementById('loading-skeleton'); -const sidebarOverlay = document.getElementById('sidebar-overlay'); - -// ── Streaming ── -let streamBubbleEl = null; -let streamContentEl = null; -let streamBuffer = ''; -let streamRAF = null; -let thinkingContentEl = null; // current thinking block if any - -// ── Tool call state ── -let currentToolBlock = null; -// FIFO queues per tool name so parallel results route to the correct block. -// Map -const toolBlockQueues = new Map(); -// Whether the current turn has started a "tool calls" divider group. -let inToolGroup = false; -// Timestamps for tool latency (name → start ms, queue-based like above). -const toolStartQueues = new Map(); - -// resetTurnState clears all per-turn streaming/tool/sub-agent state. Called -// before a new turn (send), on new session, and when loading a session. -function resetTurnState() { - streamBuffer = ''; - if (streamRAF) { - cancelAnimationFrame(streamRAF); - streamRAF = null; - } - streamBubbleEl = null; - streamContentEl = null; - currentToolBlock = null; - subagentGroup = null; - thinkingContentEl = null; - toolBlockQueues.clear(); - toolStartQueues.clear(); - inToolGroup = false; -} - -// ── Sub-agent state ── -let subagentGroup = null; - -// ── Smart Scroll ── -let scrollRAF = null; -const SCROLL_THRESHOLD = 100; -function isNearBottom() { - return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < SCROLL_THRESHOLD; -} -function scrollBottom() { - if (!isNearBottom()) return; // user is reading up — don't steal scroll - if (scrollRAF) return; - scrollRAF = requestAnimationFrame(() => { - messagesEl.scrollTop = messagesEl.scrollHeight; - scrollRAF = null; - }); -} -function forceScrollBottom() { - if (scrollRAF) { - cancelAnimationFrame(scrollRAF); - scrollRAF = null; - } - messagesEl.scrollTop = messagesEl.scrollHeight; -} - -// ── Toast ── -let toastTimer = null; -function showToast(msg) { - const el = document.getElementById('toast'); - el.textContent = msg; - el.classList.add('show'); - clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('show'), 2500); -} - -// ── Inline Loading Indicator — typographic, no emoji ── -const loadingMessages = [ - 'thinking', - 'reasoning', - 'considering', - 'planning', - 'tracing', - 'searching', - 'composing', -]; -let loadingEl = null; -let loadingTimer = null; - -function showLoading() { - const el = document.createElement('div'); - el.className = 'loading-indicator'; - el.innerHTML = '
thinking
'; - // Insert after the last message (the user message we just added) - messagesEl.appendChild(el); - loadingEl = el; - // Cycle messages - let idx = 0; - loadingTimer = setInterval(() => { - if (!loadingEl) return; - const textEl = loadingEl.querySelector('.li-text'); - if (!textEl) return; - textEl.textContent = loadingMessages[idx % loadingMessages.length]; - idx++; - }, 2000); - pruneMessages(); - // Force scroll to show the indicator (user just sent — they're at bottom) - forceScrollBottom(); -} - -function hideLoading() { - if (loadingEl) { - loadingEl.remove(); - loadingEl = null; - } - if (loadingTimer) { - clearInterval(loadingTimer); - loadingTimer = null; - } -} - -// ── Error message normalization ── -function formatErrorMessage(msg) { - if (!msg) return 'Unknown error'; - // Extract the core message from LiteLLM/provider verbose errors - const match = msg.match(/"message"\s*:\s*"([^"]{0,200})"/) || - msg.match(/BadRequestError[^:]*:\s*(.{0,200})/); - if (match) return match[1].trim(); - return msg.length > 300 ? msg.slice(0, 300) + '…' : msg; -} - -// ── Number formatting ── -function formatNum(n) { - // Coerce to a finite number so a crafted (non-numeric) value can never be - // reinterpreted as HTML when the result lands in an innerHTML assignment. - n = Number(n); - if (!isFinite(n)) n = 0; - if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k'; - return String(n); -} - -// ── Message cap ── -const MAX_MESSAGES = 80; -function pruneMessages() { - const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block'); - if (items.length > MAX_MESSAGES) { - for (let i = 0, n = items.length - MAX_MESSAGES; i < n; i++) { - items[i].remove(); - } - } -} - -// ── Auto-resize ── -promptEl.addEventListener('input', () => { - promptEl.style.height = 'auto'; - promptEl.style.height = Math.min(promptEl.scrollHeight, 200) + 'px'; -}); - -// ── WebSocket ── -function getWsToken() { - const meta = document.querySelector('meta[name="odek-ws-token"]'); - return meta ? meta.getAttribute('content') : ''; -} - -// Build API request headers that include the per-instance CSRF token. The -// server requires this token on every /api/* endpoint to block DNS-rebinding -// and cross-site driven reads. Browser same-origin requests also send the -// cookie, but the header defends-in-depth and works when cookies are blocked. -function apiHeaders(extra) { - const token = getWsToken(); - const headers = token ? { 'X-Odek-Ws-Token': token } : {}; - if (extra) Object.assign(headers, extra); - return headers; -} - -function connect() { - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = getWsToken(); - const protocols = token ? ['odek.' + token] : []; - ws = new WebSocket(proto + '//' + location.host + '/ws', protocols); - - ws.onopen = () => { - dotEl.className = 'dot connected'; - statusEl.textContent = 'connected'; - sendBtn.disabled = false; - // Hide loading skeleton when connected - if (skeletonEl) skeletonEl.classList.remove('visible'); - }; - - ws.onclose = () => { - dotEl.className = 'dot disconnected'; - statusEl.textContent = 'reconnecting...'; - sendBtn.disabled = true; - setTimeout(connect, 2000); - }; - - ws.onerror = () => { ws.close(); }; - - ws.onmessage = (e) => { - let event; - try { event = JSON.parse(e.data); } catch { return; } - - switch (event.type) { - case 'session': - sessionId = event.session_id || null; - if (event.auth_token) setSessionToken(sessionId, event.auth_token); - // Only adopt the server's model on the very first session event - // (no user-selected model yet). After that the user's choice wins. - if (event.model && !currentModel) { - currentModel = event.model; - const picker = document.getElementById('model-picker'); - if (picker && picker.value !== event.model) picker.value = event.model; - } - modelLabel.textContent = currentModel || event.model || ''; - const sandboxBadge = document.getElementById('sandbox-badge'); - if (sandboxBadge) { - sandboxBadge.style.display = event.sandbox ? 'inline-flex' : 'none'; - } - loadSessions(); - break; - - case 'token': - streamToken(event.content); - break; - - case 'thinking': - streamThinking(event.content); - break; - - case 'tool_call': - streamFlush(); - endThinking(); - if (event.name === 'delegate_tasks') { - addSubagentGroup(event.data); - } else { - addToolCall(event.name, event.data); - } - break; - - case 'tool_result': - if (event.name === 'delegate_tasks' && subagentGroup) { - completeSubagents(event.data); - } - addToolResult(event.name, event.data); - break; - - case 'subagent_log': - appendSubagentLog(event.task_idx, event); - break; - - case 'done': - streamFlush(); - endThinking(); - endStream(); - // Append per-message stats to the last assistant bubble - if (event.latency != null) { - const lastAssistant = messagesEl.querySelector('.msg.assistant:last-child .bubble'); - if (lastAssistant) { - const stats = document.createElement('div'); - stats.className = 'msg-stats'; - const lat = Number(event.latency); - const latSafe = isFinite(lat) ? lat : 0; - const spans = []; - spans.push('⚡ ' + (latSafe < 1 ? (latSafe * 1000).toFixed(0) + 'ms' : latSafe.toFixed(1) + 's') + ''); - if (event.contextTokens != null) spans.push('' + formatNum(event.contextTokens) + ' in'); - if (event.outputTokens != null) spans.push('' + formatNum(event.outputTokens) + ' out'); - // Cache metrics — show only when non-zero - if (event.cacheCreationTokens > 0) spans.push('' + formatNum(event.cacheCreationTokens) + ' stored'); - if (event.cacheReadTokens > 0) spans.push('' + formatNum(event.cacheReadTokens) + ' read'); - if (event.cachedTokens > 0) spans.push('' + formatNum(event.cachedTokens) + ' cached'); - stats.innerHTML = spans.join(' · '); - lastAssistant.appendChild(stats); - } - } - // Update session-level token stats in top bar - const sessionStatsEl = document.getElementById('session-stats'); - if (event.sessionContextTokens != null && event.sessionOutputTokens != null) { - const sessSpans = ['∑ ' + formatNum(event.sessionContextTokens) + ' in', '' + formatNum(event.sessionOutputTokens) + ' out']; - if (event.cacheReadTokens > 0 || event.cacheCreationTokens > 0 || event.cachedTokens > 0) { - // Count total session cache stats (accumulated across the session) - // We store cache totals on the session-stats element's dataset - const el = document.getElementById('session-stats'); - const cc = (parseInt(el.dataset.cacheCreate || '0') + (event.cacheCreationTokens || 0)); - const cr = (parseInt(el.dataset.cacheRead || '0') + (event.cacheReadTokens || 0)); - const cd = (parseInt(el.dataset.cached || '0') + (event.cachedTokens || 0)); - el.dataset.cacheCreate = cc; - el.dataset.cacheRead = cr; - el.dataset.cached = cd; - if (cr > 0) sessSpans.push('' + formatNum(cr) + ' read'); - if (cc > 0) sessSpans.push('' + formatNum(cc) + ' stored'); - if (cd > 0) sessSpans.push('' + formatNum(cd) + ' cached'); - } - sessionStatsEl.innerHTML = sessSpans.join(' · '); - sessionStatsEl.classList.add('visible'); - } - if (sessionId) loadSessions(); - break; - - case 'error': - streamFlush(); endThinking(); endStream(); - addSystemMessage('⚠ ' + formatErrorMessage(event.message)); - break; - - case 'approval_request': - queueApproval(event); - break; - - case 'approval_ack': - // The request was answered (by this or another connected client); - // drop it from the queue if it is still shown. - dismissApproval(event.id); - break; - - case 'skill_event': - handleSkillEvent(event); - break; - - case 'memory_event': - handleMemoryEvent(event); - break; - - case 'agent_signal': - handleAgentSignal(event); - break; - } - }; -} - -// ── Thinking ── -function streamThinking(content) { - if (!thinkingContentEl) { - // Remove cursor from any active stream - removeStreamCursor(); - - const block = document.createElement('div'); - block.className = 'thinking-block'; - block.innerHTML = - '
' + - ' reasoning' + - '
' + - '
' + escapeHtml(content) + '
'; - messagesEl.appendChild(block); - - thinkingContentEl = block.querySelector('.thinking-content'); - hideEmptyState(); - pruneMessages(); - scrollBottom(); - } else { - thinkingContentEl.textContent += content; - scrollBottom(); - } -} - -window.toggleThinking = function(el) { - const arrow = el.querySelector('.arrow'); - const content = el.parentElement.querySelector('.thinking-content'); - if (content) { - content.classList.toggle('open'); - arrow.classList.toggle('open'); - // Auto-open on first click - if (content.classList.contains('open')) { - scrollBottom(); - } - } -}; - -function endThinking() { - thinkingContentEl = null; -} - -// ── Streaming ── -function streamToken(content) { - streamBuffer += content; - if (!streamRAF) { - streamRAF = requestAnimationFrame(streamFlushRAF); - } -} - -function streamFlushRAF() { - streamRAF = null; - if (!streamBuffer) return; - appendStreamText(streamBuffer); - streamBuffer = ''; -} - -function streamFlush() { - if (streamRAF) { - cancelAnimationFrame(streamRAF); - streamRAF = null; - } - if (streamBuffer) { - appendStreamText(streamBuffer); - streamBuffer = ''; - } -} - -function ensureStreamBubble() { - if (!streamBubbleEl) { - startStream(); - } -} - -function appendStreamText(text) { - ensureStreamBubble(); - // Convert MD fragments to HTML and append - const html = markdownToHtml(text); - // Use a temp container to handle multi-node fragment - const temp = document.createElement('div'); - temp.innerHTML = html; - while (temp.firstChild) { - streamContentEl.appendChild(temp.firstChild); - } - scrollBottom(); -} - -function removeStreamCursor() { - if (streamBubbleEl) { - const cursor = streamBubbleEl.querySelector('.stream-cursor'); - if (cursor) cursor.remove(); - } -} - -function startStream() { - hideEmptyState(); - endThinking(); - hideLoading(); // remove the inline loading indicator — streaming has started - - const wrapper = document.createElement('div'); - wrapper.className = 'msg assistant'; - wrapper.style.opacity = '1'; - wrapper.innerHTML = - '
' + - '
assistant
' + - '
' + - '
'; - messagesEl.appendChild(wrapper); - - streamBubbleEl = wrapper; - streamContentEl = wrapper.querySelector('#stream-content'); - // Add copy button and collapse check to the stream bubble - const bubble = wrapper.querySelector('.bubble'); - if (bubble) { - addCopyButton(bubble); - checkCollapse(bubble); - } - pruneMessages(); - scrollBottom(); -} - -function endStream() { - removeStreamCursor(); - streamBubbleEl = null; - streamContentEl = null; - currentToolBlock = null; - subagentGroup = null; - toolBlockQueues.clear(); - toolStartQueues.clear(); - inToolGroup = false; - busy = false; - hideLoading(); - hideCancel(); - sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN; - promptEl.disabled = false; - promptEl.focus(); -} - -// ── Message rendering ── -function addMessage(role, content) { - hideEmptyState(); - const wrapper = document.createElement('div'); - wrapper.className = 'msg ' + role; - - let sender = role; - if (role === 'user') sender = 'you'; - - wrapper.innerHTML = - '
' + - '
' + sender + '
' + - '
' + markdownToHtml(escapeHtml(content)) + '
' + - '
'; - messagesEl.appendChild(wrapper); - pruneMessages(); - scrollBottom(); -} - -function addSystemMessage(content) { - hideEmptyState(); - const el = document.createElement('div'); - el.className = 'msg system'; - el.innerHTML = '
' + escapeHtml(content) + '
'; - messagesEl.appendChild(el); - pruneMessages(); - scrollBottom(); -} - -function addDivider(text) { - const el = document.createElement('div'); - el.className = 'msg-divider'; - el.textContent = text || '•'; - messagesEl.appendChild(el); - scrollBottom(); -} - -// ── Tool Helpers ── - -// Matches Go's render.ToolEmoji for consistency. -function toolEmoji(name) { - if (name === 'read_file' || name === 'write_file' || name === 'search_files' || - name === 'patch' || name === 'execute_code' || name === 'multi_grep') return '📝'; - if (name === 'shell' || name === 'terminal' || name === 'process') return '💻'; - if (name === 'web_search' || name === 'web_extract' || name.startsWith('browser_')) return '🌐'; - if (name === 'memory' || name === 'session_search') return '🧠'; - if (name === 'vision_analyze') return '👁️'; - if (name === 'send_message') return '💬'; - if (name === 'delegate_task' || name === 'delegate_tasks') return '👥'; - if (name === 'cronjob') return '⏰'; - if (name === 'todo' || name === 'skill_view' || name === 'skill_manage' || - name === 'skills_list' || name === 'clarify') return '➕'; - if (name === 'transcribe') return '🎙️'; - if (name === 'list_directory' || name === 'create_directory') return '📁'; - return '🔧'; -} - -// Extract a short human-readable preview from tool args JSON. -function buildToolPreview(name, data) { - if (!data) return ''; - try { - const obj = JSON.parse(data); - switch (name) { - case 'read_file': return String(obj.path || '').slice(0, 60); - case 'write_file': return String(obj.path || '').slice(0, 60); - case 'search_files': return (obj.pattern || obj.query || '').slice(0, 50); - case 'multi_grep': return (obj.pattern || '').slice(0, 50); - case 'shell': return (obj.command || '').slice(0, 60); - case 'browser_navigate': case 'web_extract': return (obj.url || '').slice(0, 60); - case 'web_search': return (obj.query || '').slice(0, 60); - default: { - const first = Object.values(obj)[0]; - return first != null ? String(first).slice(0, 50) : ''; - } - } - } catch { return ''; } -} - -// Format tool args for the expanded body — pretty-print JSON or show raw. -function formatToolArgs(data) { - if (!data) return ''; - try { - const obj = JSON.parse(data); - return Object.entries(obj).map(([k, v]) => { - const val = typeof v === 'string' ? v : JSON.stringify(v, null, 2); - return k + ': ' + (val.length > 300 ? val.slice(0, 300) + '…' : val); - }).join('\n'); - } catch { - return data.length > 500 ? data.slice(0, 500) + '…' : data; - } -} - -// ── Tool Calls ── -function addToolCall(name, data) { - removeStreamCursor(); - // Only add the "tool calls" divider once per tool group per turn. - if (!inToolGroup) { - addDivider('tool calls'); - inToolGroup = true; - } - - const emoji = toolEmoji(name); - const preview = buildToolPreview(name, data); - - const el = document.createElement('div'); - el.className = 'tool-block'; - el.innerHTML = - '
' + - '' + - ' ' + emoji + '' + - ' ' + escapeHtml(name) + '' + - (preview ? ' ' + escapeHtml(preview) + '' : '') + - ' ' + - ' ' + - '
' + - '
' + escapeHtml(formatToolArgs(data)) + '
'; - - messagesEl.appendChild(el); - currentToolBlock = el; - - // Push into per-name FIFO queues so parallel results route correctly. - if (!toolBlockQueues.has(name)) toolBlockQueues.set(name, []); - toolBlockQueues.get(name).push(el); - if (!toolStartQueues.has(name)) toolStartQueues.set(name, []); - toolStartQueues.get(name).push(performance.now()); - - pruneMessages(); - scrollBottom(); -} - -// appendToolResultContent renders a tool result into a block, truncating -// long output behind a "show all" expander. Shared by the live path -// (addToolResult) and session-history rendering. -function appendToolResultContent(block, output) { - const MAX_RESULT = 600; - const truncated = output && output.length > MAX_RESULT; - const resultEl = document.createElement('div'); - resultEl.className = 'tb-result'; - block.appendChild(resultEl); - if (truncated) { - resultEl.innerHTML = - escapeHtml(output.slice(0, MAX_RESULT)) + - ' …show all (' + output.length + ' chars)'; - } else { - resultEl.textContent = output || ''; - } -} - -function addToolResult(name, output) { - // Route to the matching pending block via FIFO queue. - const queue = toolBlockQueues.get(name); - const block = (queue && queue.length > 0) ? queue.shift() : currentToolBlock; - if (!block) return; - - // Remove spinner; show latency. - const spinner = block.querySelector('.tb-spinner'); - if (spinner) spinner.classList.remove('running'); - const startQueue = toolStartQueues.get(name); - if (startQueue && startQueue.length > 0) { - const start = startQueue.shift(); - const ms = performance.now() - start; - const latEl = block.querySelector('.tb-latency'); - if (latEl) latEl.textContent = ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(1) + 's'; - } - - appendToolResultContent(block, output || ''); - scrollBottom(); -} - -window.expandToolResult = function(el) { - const full = el.dataset.full || ''; - const resultEl = el.parentElement; - if (resultEl) resultEl.textContent = full; -}; - -window.toggleToolBody = function(header) { - const arrow = header.querySelector('.arrow'); - const body = header.parentElement.querySelector('.tb-body'); - if (body) { - body.classList.toggle('open'); - arrow.classList.toggle('open'); - } -}; - -// ── Sub-agent Cards ── -function addSubagentGroup(command) { - removeStreamCursor(); - if (subagentGroup) return; // only one group at a time - - addDivider('delegated tasks'); - - let tasks = []; - try { - const parsed = JSON.parse(command); - tasks = parsed.tasks || []; - } catch { tasks = []; } - - const group = document.createElement('div'); - group.className = 'subagent-group'; - group.innerHTML = '
Sub-agents
'; - messagesEl.appendChild(group); - subagentGroup = group; - - const grid = group.querySelector('#sa-grid'); - tasks.forEach((task, i) => { - const card = document.createElement('div'); - card.className = 'subagent-card running'; - card.dataset.index = i; - card.innerHTML = - '
' + - '
' + - '
' + escapeHtml(task.goal || 'Task ' + (i+1)) + '
' + - '
running
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
'; - grid.appendChild(card); - }); - - pruneMessages(); - scrollBottom(); -} - -// parseSubagentResults parses delegate_tasks output text of the form -// "📋 Sub-agent results:\n\n─── Task 1: goal ───\n{json}\n\n─── Task 2: ..." -// into a map of task index → parsed result object. -function parseSubagentResults(output) { - const lines = (output || '').split('\n'); - let currentTaskIdx = -1; - const taskResults = {}; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const taskMatch = line.match(/─── Task (\d+):/); - if (taskMatch) { - currentTaskIdx = parseInt(taskMatch[1]) - 1; - // Collect JSON from subsequent lines - let jsonLines = []; - for (let j = i + 1; j < lines.length; j++) { - const nextLine = lines[j]; - if (nextLine.startsWith('─── Task ')) break; - if (nextLine.startsWith('📋')) continue; - jsonLines.push(nextLine); - } - const jsonStr = jsonLines.join('\n').trim(); - try { - taskResults[currentTaskIdx] = JSON.parse(jsonStr); - } catch { - taskResults[currentTaskIdx] = { summary: jsonStr }; - } - } - } - return taskResults; -} - -// finalizeSubagentCard marks a card done/error and fills its details from -// the parsed result. Shared by the live path (completeSubagents) and -// session-history rendering. -function finalizeSubagentCard(card, result) { - card.querySelector('.sa-icon').textContent = '✓'; - card.classList.remove('running'); - card.querySelector('.sa-status').textContent = 'done'; - - if (!result) { - card.classList.add('completed'); - return; - } - - const status = result.status || 'success'; - if (status === 'error') { - card.classList.add('error'); - card.querySelector('.sa-icon').textContent = '✗'; - card.querySelector('.sa-status').textContent = 'error'; - } else { - card.classList.add('completed'); - } - - // Auto-open details for error or when there's a summary - const details = card.querySelector('.sa-details'); - const summary = result.summary || ''; - const files = result.files_changed || []; - const tokens = result.tokens_used || 0; - const iters = result.iterations || 0; - - if (summary || files.length > 0) { - const meta = details.querySelector('.sa-meta'); - if (tokens) meta.textContent = tokens + ' tokens' + (iters ? ' · ' + iters + ' iters' : ''); - - const summaryEl = details.querySelector('.sa-summary'); - summaryEl.textContent = typeof summary === 'string' ? summary : ''; - - if (files.length > 0) { - const filesEl = details.querySelector('.sa-files'); - filesEl.innerHTML = files.map(f => '📄' + escapeHtml(f) + '').join(''); - } - - details.classList.add('open'); - } -} - -function completeSubagents(output) { - if (!subagentGroup) return; - - const taskResults = parseSubagentResults(output); - const cards = subagentGroup.querySelectorAll('.subagent-card'); - cards.forEach((card, i) => finalizeSubagentCard(card, taskResults[i])); - - pruneMessages(); - scrollBottom(); -} - -window.toggleSaDetails = function(el) { - el.classList.toggle('open'); -}; - -function appendSubagentLog(taskIdx, event) { - if (!subagentGroup) return; - const cards = subagentGroup.querySelectorAll('.subagent-card'); - const card = cards[taskIdx]; - if (!card) return; - - // Ensure details are open - const details = card.querySelector('.sa-details'); - if (!details) return; - const summaryEl = details.querySelector('.sa-summary'); - - // Format the log line - let text = ''; - if (event.event === 'tool_call') { - text = '🔧 ' + event.name + (event.data ? '(' + truncateStr(event.data, 60) + ')' : ''); - } else if (event.event === 'tool_result') { - text = '📄 ' + truncateStr(event.data || '', 100); - } - if (!text) return; - - // Append to existing summary content (or create a log container) - let logContainer = card.querySelector('.sa-log'); - if (!logContainer) { - logContainer = document.createElement('div'); - logContainer.className = 'sa-log'; - details.insertBefore(logContainer, summaryEl); - } - const lineEl = document.createElement('div'); - lineEl.className = 'log-line'; - lineEl.textContent = text; - logContainer.appendChild(lineEl); - - details.classList.add('open'); - scrollBottom(); -} - -function truncateStr(s, n) { - if (!s) return ''; - return s.length > n ? s.substring(0, n) + '…' : s; -} - -// ── Approvals ── -// Approval requests are queued and rendered one at a time as an inline -// decision card pinned at the bottom of the message stream — the user can -// scroll back through the transcript while deciding. Cards are fully -// keyboard-operable (a = approve, d = deny, t = trust session) and announce -// themselves to assistive technology via role="alertdialog" + focus move. -// An approval_ack from the server (answer given on another client) dismisses -// the matching request. - -let approvalQueue = []; // approvalRequest events, FIFO -let activeApprovalId = null; // id of the request currently rendered -let activeApprovalCard = null; - -// Risk-class presentation metadata. The server sends the class string -// (system_write, destructive, …); level drives the badge color and the -// "why" line explains the class in plain language. -const APPROVAL_RISK_META = { - local_write: { icon: '🟡', level: 'warn', why: 'Writes or deletes files in the working directory.' }, - system_write: { icon: '⚠️', level: 'warn', why: 'Modifies system files or settings outside the workspace.' }, - destructive: { icon: '🚫', level: 'danger', why: 'Irreversibly destroys data. This cannot be undone.' }, - network_egress: { icon: '🌐', level: 'warn', why: 'Sends data out to the network.' }, - code_execution: { icon: '⚠️', level: 'warn', why: 'Executes arbitrary code.' }, - install: { icon: '📦', level: 'warn', why: 'Installs packages or dependencies.' }, - unknown: { icon: '🚫', level: 'danger', why: 'Unrecognized command — the gate fails closed on these.' }, - blocked: { icon: '🚫', level: 'danger', why: 'Hard-blocked, unrecoverable operation.' }, - safe: { icon: '✅', level: 'ok', why: 'Read-only operation.' }, -}; - -function approvalRiskMeta(risk) { - return APPROVAL_RISK_META[risk] || { icon: '🛡️', level: 'warn', why: 'Requires your explicit approval.' }; -} - -window.queueApproval = function(event) { - approvalQueue.push(event); - if (!activeApprovalId) showNextApproval(); -}; - -// dismissApproval removes a request from the queue (and its card if shown) -// without sending a response — used when the server acks an answer that -// came from another client. -function dismissApproval(id) { - const idx = approvalQueue.findIndex(e => e.id === id); - if (idx >= 0) approvalQueue.splice(idx, 1); - if (activeApprovalId === id) { - removeActiveApprovalCard(); - showNextApproval(); - } -} - -function showNextApproval() { - const event = approvalQueue[0]; - if (!event) { - activeApprovalId = null; - activeApprovalCard = null; - return; - } - activeApprovalId = event.id; - renderApprovalCard(event); -} - -function removeActiveApprovalCard() { - if (activeApprovalCard) { - activeApprovalCard.remove(); - activeApprovalCard = null; - } - activeApprovalId = null; -} - -function renderApprovalCard(event) { - removeActiveApprovalCard(); - const meta = approvalRiskMeta(event.risk); - - const card = document.createElement('div'); - card.className = 'approval-card'; - card.dataset.level = meta.level; - card.setAttribute('role', 'alertdialog'); - card.setAttribute('aria-labelledby', 'ac-title'); - card.setAttribute('aria-describedby', 'ac-why'); - card.tabIndex = -1; - - // Header: icon, titles, risk badge - const head = document.createElement('div'); - head.className = 'ac-head'; - const icon = document.createElement('span'); - icon.className = 'ac-icon'; - icon.textContent = meta.icon; - const titles = document.createElement('div'); - titles.className = 'ac-titles'; - const title = document.createElement('div'); - title.className = 'ac-title'; - title.id = 'ac-title'; - title.textContent = 'Approval required'; - const sub = document.createElement('div'); - sub.className = 'ac-sub'; - sub.textContent = 'the agent wants to run this operation'; - titles.append(title, sub); - const risk = document.createElement('span'); - risk.className = 'ac-risk'; - risk.dataset.level = meta.level; - risk.textContent = event.risk || 'unknown'; - head.append(icon, titles, risk); - - // Plain-language explanation of the risk class - const why = document.createElement('div'); - why.className = 'ac-why'; - why.id = 'ac-why'; - why.textContent = meta.why; - - // The command / operation, verbatim - const command = document.createElement('pre'); - command.className = 'ac-command'; - command.textContent = event.command; - - card.append(head, why, command); - - if (event.description) { - const desc = document.createElement('div'); - desc.className = 'ac-desc'; - desc.textContent = event.description; - card.appendChild(desc); - } - - // Friction mode: after repeated recent approvals of this class, require - // typing the literal word 'approve' (after a 1.5s gate). - let frictionInput = null; - if (event.friction) { - const fr = document.createElement('div'); - fr.className = 'ac-friction'; - const msg = document.createElement('div'); - msg.className = 'ac-friction-msg'; - msg.textContent = '⚠️ You approved ' + (event.friction_approvals || 0) + ' ' + (event.risk || '') + - ' operations in the last minute. Type the word “approve” to proceed.'; - frictionInput = document.createElement('input'); - frictionInput.className = 'ac-friction-input'; - frictionInput.type = 'text'; - frictionInput.placeholder = 'type: approve'; - frictionInput.setAttribute('aria-label', 'Type approve to confirm'); - fr.append(msg, frictionInput); - card.appendChild(fr); - } - - // Actions - const actions = document.createElement('div'); - actions.className = 'ac-actions'; - const denyBtn = document.createElement('button'); - denyBtn.className = 'deny'; - denyBtn.innerHTML = 'deny d'; - denyBtn.title = 'Deny [d]'; - denyBtn.addEventListener('click', () => sendApproval('deny')); - const trustBtn = document.createElement('button'); - trustBtn.className = 'trust'; - trustBtn.innerHTML = 'trust session t'; - trustBtn.title = 'Trust this risk class for the rest of the session [t]'; - trustBtn.addEventListener('click', () => sendApproval('trust')); - const approveBtn = document.createElement('button'); - approveBtn.className = 'approve'; - approveBtn.innerHTML = 'approve a'; - approveBtn.title = 'Approve [a]'; - approveBtn.addEventListener('click', () => sendApproval('approve')); - actions.append(denyBtn, trustBtn, approveBtn); - card.appendChild(actions); - - // Trust shortcut is suppressed for destructive / blocked / unknown. - if (event.allow_trust === false) trustBtn.style.display = 'none'; - - // Queue position indicator - if (approvalQueue.length > 1) { - const pos = document.createElement('div'); - pos.className = 'ac-queue-pos'; - pos.textContent = 'request 1 of ' + approvalQueue.length + ' — more waiting'; - card.appendChild(pos); - } - - // Friction gating: approve stays disabled until the word is typed and - // 1.5s have elapsed. - if (event.friction && frictionInput) { - approveBtn.disabled = true; - setTimeout(() => { - frictionInput.addEventListener('input', () => { - approveBtn.disabled = frictionInput.value.trim().toLowerCase() !== 'approve'; - }); - }, 1500); - frictionInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !approveBtn.disabled) sendApproval('approve'); - e.stopPropagation(); - }); - } - - activeApprovalCard = card; - hideEmptyState(); - messagesEl.appendChild(card); - forceScrollBottom(); - card.focus({ preventScroll: true }); -} - -window.sendApproval = function(action) { - if (!activeApprovalId) return; - const event = approvalQueue[0]; - // Honor the friction gate for keyboard-triggered approvals too. - if (event && event.friction && action === 'approve') { - const input = activeApprovalCard && activeApprovalCard.querySelector('.ac-friction-input'); - if (input && input.value.trim().toLowerCase() !== 'approve') return; - } - ws.send(JSON.stringify({ - type: 'approval_response', - id: activeApprovalId, - action: action - })); - approvalQueue.shift(); - removeActiveApprovalCard(); - showNextApproval(); -}; - -// Keyboard operation while an approval card is active. Ignored when the -// user is typing in an input/textarea (the friction input handles its own -// Enter) or when modifier keys are held. -document.addEventListener('keydown', (e) => { - if (!activeApprovalId) return; - if (e.metaKey || e.ctrlKey || e.altKey) return; - const tag = (e.target && e.target.tagName) || ''; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - const trustVisible = activeApprovalCard && - !activeApprovalCard.querySelector('.trust').style.display; - if (e.key === 'a' || e.key === 'A') { - e.preventDefault(); - sendApproval('approve'); - } else if (e.key === 'd' || e.key === 'D') { - e.preventDefault(); - sendApproval('deny'); - } else if ((e.key === 't' || e.key === 'T') && trustVisible) { - e.preventDefault(); - sendApproval('trust'); - } -}); - -// ── Confirm Dialog ── -window.hideConfirmDialog = function() { - document.getElementById('confirm-overlay').classList.remove('active'); - pendingDeleteId = null; -}; - -window.executeDeleteSession = async function() { - if (!pendingDeleteId) return; - const sid = pendingDeleteId; - pendingDeleteId = null; - document.getElementById('confirm-overlay').classList.remove('active'); - - const token = await ensureSessionToken(sid); - - fetch('/api/sessions/' + encodeURIComponent(sid), { - method: 'DELETE', - headers: apiHeaders(token ? { 'X-Session-Token': token } : {}) - }) - .then(() => { - clearSessionToken(sid); - if (sessionId === sid) newSession(); - loadSessions(); - }) - .catch(() => showToast('Failed to delete session')); -}; - -// ── Theme Toggle ── -window.toggleTheme = function() { - document.body.classList.toggle('light'); - const isLight = document.body.classList.contains('light'); - localStorage.setItem('odek_theme', isLight ? 'light' : 'dark'); - document.getElementById('theme-btn').textContent = isLight ? '☀️' : '🌙'; -}; -// Restore theme on load -if (localStorage.getItem('odek_theme') === 'light') { - document.body.classList.add('light'); - document.getElementById('theme-btn').textContent = '☀️'; -} - -// ── Model Picker ── -const customModelInput = document.getElementById('custom-model-input'); - -async function fetchModels() { - const picker = document.getElementById('model-picker'); - try { - picker.disabled = true; - const resp = await fetch('/api/models', { headers: apiHeaders() }); - if (!resp.ok) { picker.innerHTML = ''; return; } - const models = await resp.json(); - availableModels = models; - if (!models || models.length === 0) { - picker.innerHTML = ''; - return; - } - let html = ''; - models.forEach(m => { - const sel = currentModel === m.id ? ' selected' : ''; - const label = m.current ? '★ ' + (m.description || m.id) : (m.description || m.id); - html += ``; - }); - // "Other..." sentinel opens the free-text input. - html += ''; - picker.innerHTML = html; - if (currentModel) { - picker.value = currentModel; - // If the current model isn't in the list, show the custom input. - if (!picker.value && currentModel) { - picker.value = '__custom__'; - customModelInput.value = currentModel; - customModelInput.style.display = 'inline-block'; - } - } - } catch { - picker.innerHTML = ''; - } finally { - picker.disabled = false; - } -} - -// Called by the select's onchange — handles both known models and the -// "Other…" sentinel that reveals the free-text custom model input. -window.onPickerChange = function(value) { - if (value === '__custom__') { - customModelInput.style.display = 'inline-block'; - customModelInput.focus(); - customModelInput.select(); - return; - } - customModelInput.style.display = 'none'; - customModelInput.value = ''; - if (value) switchModel(value); -}; - -// Commit a custom model ID from the text input on Enter or blur. -customModelInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); commitCustomModel(); } - if (e.key === 'Escape') { - customModelInput.style.display = 'none'; - customModelInput.value = ''; - const picker = document.getElementById('model-picker'); - if (currentModel) picker.value = currentModel; - } -}); -customModelInput.addEventListener('blur', () => { - if (customModelInput.value.trim()) commitCustomModel(); -}); - -function commitCustomModel() { - const id = customModelInput.value.trim(); - if (!id) return; - switchModel(id); - // Add as a selectable option if not already present. - const picker = document.getElementById('model-picker'); - let found = false; - for (const opt of picker.options) { if (opt.value === id) { found = true; break; } } - if (!found) { - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = id; - // Insert before the "Other…" sentinel - const sentinel = picker.querySelector('option[value="__custom__"]'); - picker.insertBefore(opt, sentinel); - } - picker.value = id; - customModelInput.style.display = 'none'; -} - -window.switchModel = function(modelId) { - currentModel = modelId; - if (modelId) { - localStorage.setItem('odek_model', modelId); - } else { - localStorage.removeItem('odek_model'); - } - showToast(modelId ? 'Model: ' + modelId : 'Using default model'); -}; - -// ── Session Rename ── -// Inline edit: swaps the task label for an input; Enter commits, Esc -// cancels, blur commits. No window.prompt. -window.renameSession = function(sid, el) { - const item = el.closest('.session-item'); - if (!item) return; - const taskEl = item.querySelector('.task'); - if (!taskEl || taskEl.querySelector('.si-rename-input')) return; - const currentName = taskEl.textContent; - - const input = document.createElement('input'); - input.className = 'si-rename-input'; - input.type = 'text'; - input.value = currentName === 'untitled' ? '' : currentName; - input.placeholder = 'session name…'; - taskEl.textContent = ''; - taskEl.appendChild(input); - input.focus(); - input.select(); - - let done = false; - const finish = (commit) => { - if (done) return; - done = true; - const newName = input.value.trim(); - taskEl.textContent = currentName; - if (commit && newName && newName !== currentName) { - doRenameSession(sid, newName); - } - }; - input.addEventListener('keydown', (e) => { - e.stopPropagation(); - if (e.key === 'Enter') { e.preventDefault(); finish(true); } - if (e.key === 'Escape') { e.preventDefault(); finish(false); } - }); - input.addEventListener('click', (e) => e.stopPropagation()); - input.addEventListener('blur', () => finish(true)); -}; - -async function doRenameSession(sid, newName) { - const token = await ensureSessionToken(sid); - fetch('/api/sessions/' + encodeURIComponent(sid), { - method: 'POST', - headers: apiHeaders({ - 'Content-Type': 'application/json', - ...(token ? { 'X-Session-Token': token } : {}) - }), - body: JSON.stringify({ name: newName }) - }) - .then(resp => { - if (!resp.ok) throw new Error('rename failed'); - loadSessions(); - showToast('Session renamed'); - }) - .catch(() => showToast('Failed to rename session')); -} - -// ── Skill Events ── -function handleSkillEvent(event) { - switch (event.event) { - case 'saved': - showToast('✓ Skill saved: ' + (event.skill_name || '')); - break; - case 'deleted': - showToast('✗ Skill deleted: ' + (event.skill_name || '')); - break; - case 'suggested': { - // Inline card with save/skip — shown in messages area. - const el = document.createElement('div'); - el.className = 'skill-toast'; - el.innerHTML = - '💡 Skill suggestion: ' + escapeHtml(event.skill_name || '') + - (event.heuristic ? ' — ' + escapeHtml(event.heuristic) + '' : ''); - messagesEl.appendChild(el); - scrollBottom(); - break; - } - case 'loaded': case 'autoloaded': - // Silent — noisy to show every skill load. - break; - } -} - -// ── Memory Events ── -function handleMemoryEvent(event) { - switch (event.event) { - case 'fact_added': - showToast('🧠 Memory fact added (' + (event.target || '') + ')'); - break; - case 'fact_merged': - showToast('🧠 Memory fact merged (' + (event.target || '') + ')'); - break; - case 'fact_replaced': - showToast('🧠 Memory fact updated (' + (event.target || '') + ')'); - break; - case 'fact_removed': - showToast('🧠 Memory fact removed (' + (event.target || '') + ')'); - break; - case 'fact_consolidated': - showToast('🧠 Memory consolidated (' + (event.target || '') + ': ' + - (event.count || 0) + ' → ' + (event.new_count || 0) + ')'); - break; - case 'episode_stored': - // Silent by default — fires after every qualifying session. - break; - case 'episode_promoted': - showToast('💾 ✓ Episode promoted: ' + (event.session_id || '')); - break; - case 'episode_evicted': - showToast('💾 ✗ ' + (event.count || 0) + ' episode(s) evicted'); - break; - case 'episode_pending_review': - showToast('🔒 Episode pending review (untrusted): ' + (event.session_id || '')); - break; - case 'episode_deduped': - // Silent — internal dedup detail. - break; - } -} - -// ── Agent Signals ── -function handleAgentSignal(event) { - switch (event.event) { - case 'context_trimmed': - showToast('✂️ Context trimmed (' + (event.detail || '') + '): ' + - (event.count || 0) + ' group(s) dropped'); - break; - case 'tool_recovery': - showToast('🔁 Tool recovery: ' + (event.tool || '')); - break; - } -} - -// ── New Session ── -// Saved on first load so we can restore the empty state after clearing. -let savedEmptyStateNode = null; -let savedScrollBtnNode = null; - -window.newSession = function() { - sessionId = null; - - // Reset all streaming + tool state. - resetTurnState(); - // Any pending approval belongs to the previous session's run — drop it. - approvalQueue = []; - removeActiveApprovalCard(); - busy = false; - hideLoading(); hideCancel(); - sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN; - promptEl.disabled = false; - - // Clear messages and restore empty state. - messagesEl.innerHTML = ''; - if (savedScrollBtnNode) messagesEl.appendChild(savedScrollBtnNode); - if (savedEmptyStateNode) messagesEl.appendChild(savedEmptyStateNode); - - sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active')); - showToast('New session'); - promptEl.value = ''; - promptEl.style.height = 'auto'; - promptEl.focus(); -}; - -// ── Shortcuts ── -window.toggleShortcuts = function() { - document.getElementById('shortcuts-overlay').classList.toggle('active'); -}; -document.getElementById('shortcuts-overlay').addEventListener('click', (e) => { - if (e.target === e.currentTarget) toggleShortcuts(); -}); - -// ── Hide empty state ── -function hideEmptyState() { - if (emptyState && emptyState.parentNode) { - emptyState.remove(); - } -} - -// ── Escape helpers ── -function escapeHtml(s) { - if (!s) return ''; - return s.replace(/&/g,'&').replace(//g,'>'); -} - -function escapeAttr(s) { - if (!s) return ''; - // & must be replaced first — doing it last double-escapes the entities - // introduced by the quote replacements (" → &quot;). - return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''') - .replace(//g,'>'); -} - -// ── Markdown to HTML (safe, no DOMPurify needed since we control input) ── -function markdownToHtml(text) { - if (!text) return ''; - - let html = escapeHtml(text); - - // Headers (must be at start of line) - html = html.replace(/^#### (.+)$/gm, '

$1

'); - html = html.replace(/^### (.+)$/gm, '

$1

'); - html = html.replace(/^## (.+)$/gm, '

$1

'); - html = html.replace(/^# (.+)$/gm, '

$1

'); - - // Horizontal rules - html = html.replace(/^(---|\*\*\*|___)$/gm, '
'); - - // Code blocks (```lang ... ```) — need to handle BEFORE inline code - html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => { - const langLabel = lang || 'code'; - return '
' + - '
' + - '' + escapeHtml(langLabel) + '' + - '📋 copy' + - '
' + - '
' + escapeHtml(code) + '
' + - '
'; - }); - - // Inline code - html = html.replace(/`([^`]+)`/g, '$1'); - - // Bold - html = html.replace(/\*\*(.+?)\*\*/g, '$1'); - - // Italic - html = html.replace(/\*(.+?)\*/g, '$1'); - - // Strikethrough - html = html.replace(/~~(.+?)~~/g, '$1'); - - // Links — allowlist safe URL schemes to prevent javascript:/data: XSS. - html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { - const trimmed = url.trim(); - const safe = /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(trimmed); - if (!safe) return label; - return '' + label + ''; - }); - - // Unordered lists (simple: lines starting with - or *) - html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '
  • $1
  • '); - html = html.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    '); - - // Paragraphs — wrap remaining non-tag text in

    - // Split by double newlines (paragraph breaks) - const parts = html.split(/\n\n+/); - html = parts.map(part => { - part = part.trim(); - if (!part) return ''; - // Don't wrap if it starts with a block-level tag - if (/^<(h[1-4]|ul|ol|li|pre|div|hr|table)/.test(part)) return part; - // Don't wrap single
    - if (/^$/.test(part)) return part; - return '

    ' + part.replace(/\n/g, '
    ') + '

    '; - }).join('\n'); - - return html; -} - -// ── Copy helpers ── -// copyTextToClipboard writes text to the clipboard, falling back to the -// legacy execCommand path when the async Clipboard API is unavailable or -// denied (non-secure contexts). Returns a Promise. -function copyTextToClipboard(text) { - const fallback = () => { - const ta = document.createElement('textarea'); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - }; - if (!navigator.clipboard || !navigator.clipboard.writeText) { - fallback(); - return Promise.resolve(); - } - return navigator.clipboard.writeText(text).catch(fallback); -} - -window.copyCode = function(el) { - const block = el.closest('.code-block'); - if (!block) return; - const code = block.querySelector('pre code'); - if (!code) return; - copyTextToClipboard(code.textContent).then(() => { - el.textContent = '✓ copied'; - el.classList.add('copied'); - setTimeout(() => { - el.textContent = '📋 copy'; - el.classList.remove('copied'); - }, 2000); - }); -}; - -// ── Send ── -function send() { - let text = promptEl.value.trim(); - - // Build display message (filename chips only — never the file body) and a - // separate attachments payload. The server wraps each attachment with the - // untrusted-content boundary before injecting it into the model context. - let display = text; - let attachments = []; - if (attachedFiles.length > 0) { - const chips = attachedFiles.map(f => '📎 ' + f.name + ' (' + formatFileSize(f.size) + ')').join('\n'); - display = chips + (text ? '\n\n' + text : ''); - attachments = attachedFiles.map(f => ({ name: f.name, content: f.content })); - clearAttachedFiles(); - } - - if ((!text && attachments.length === 0) || busy || !ws || ws.readyState !== WebSocket.OPEN) return; - - history.push(text); - if (history.length > 100) history.shift(); - localStorage.setItem('odek_history', JSON.stringify(history)); - historyIdx = history.length; - - promptEl.value = ''; - promptEl.style.height = 'auto'; - - // Add user message (display-only — file bodies are not rendered) - addMessage('user', display); - - // Reset streaming + tool state for the new turn. - resetTurnState(); - - busy = true; - sendBtn.disabled = true; - promptEl.disabled = true; - - showLoading(); - showCancel(); - - ws.send(JSON.stringify({ - type: 'prompt', - content: text, - attachments: attachments, - session_id: sessionId, - auth_token: getSessionToken(sessionId) || undefined, - model: currentModel || undefined, - thinking: thinkingEnabled ? 'enabled' : '' - })); -} - -// ── File Attachments ── -const fileInput = document.getElementById('file-input'); -const attachBtn = document.getElementById('attach-btn'); -const fileChips = document.getElementById('file-chips'); - -function formatFileSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB'; - return (bytes/(1024*1024)).toFixed(1) + ' MB'; -} - -function addAttachedFile(file) { - // Check total size (max 10MB total) - const totalSize = attachedFiles.reduce((s, f) => s + f.size, 0) + file.size; - if (totalSize > 10 * 1024 * 1024) { - addErrorChip(file.name, 'total attachments exceed 10 MB'); - return; - } - attachedFiles.push(file); - renderFileChips(); -} - -function removeAttachedFile(index) { - attachedFiles.splice(index, 1); - renderFileChips(); -} - -function clearAttachedFiles() { - attachedFiles = []; - renderFileChips(); -} - -function renderFileChips() { - // Build nodes with textContent rather than innerHTML so a file name can - // never be reinterpreted as markup. - fileChips.textContent = ''; - attachedFiles.forEach((f, i) => { - const chip = document.createElement('span'); - chip.className = 'file-chip'; - - const icon = document.createElement('span'); - icon.className = 'chip-icon'; - icon.textContent = '📎'; - - const name = document.createElement('span'); - name.className = 'chip-name'; - name.textContent = f.name; - - const size = document.createElement('span'); - size.className = 'chip-size'; - size.textContent = formatFileSize(f.size); - - const remove = document.createElement('span'); - remove.className = 'chip-remove'; - remove.textContent = '✕'; - remove.addEventListener('click', () => removeAttachedFile(i)); - - chip.append(icon, name, size, remove); - fileChips.appendChild(chip); - }); - - // Total-size meter against the 10 MB cap. - if (attachedFiles.length > 0) { - const total = attachedFiles.reduce((s, f) => s + f.size, 0); - const meter = document.createElement('span'); - meter.className = 'chips-total'; - meter.textContent = formatFileSize(total) + ' / 10 MB'; - if (total > 8 * 1024 * 1024) meter.classList.add('warn'); - fileChips.appendChild(meter); - } -} - -// addErrorChip shows a transient error chip for a file that could not be -// attached (too large, unreadable). Dismisses on click or after 6s. -function addErrorChip(name, reason) { - const chip = document.createElement('span'); - chip.className = 'file-chip error'; - chip.title = reason; - const icon = document.createElement('span'); - icon.className = 'chip-icon'; - icon.textContent = '⚠️'; - const label = document.createElement('span'); - label.className = 'chip-name'; - label.textContent = name + ' — ' + reason; - const remove = document.createElement('span'); - remove.className = 'chip-remove'; - remove.textContent = '✕'; - chip.append(icon, label, remove); - fileChips.appendChild(chip); - const dismiss = () => chip.remove(); - remove.addEventListener('click', dismiss); - setTimeout(dismiss, 6000); -} - -function readFileAsText(file) { - return new Promise((resolve, reject) => { - // Limit individual files to 5MB - if (file.size > 5 * 1024 * 1024) { - reject(new Error('File too large (max 5 MB): ' + file.name)); - return; - } - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsText(file); - }); -} - -function handleFiles(fileList) { - const promises = []; - for (let i = 0; i < fileList.length; i++) { - const file = fileList[i]; - promises.push( - readFileAsText(file).then(content => { - addAttachedFile({name: file.name, size: file.size, content}); - }).catch(err => { - addErrorChip(file.name, err.message || 'could not read file'); - }) - ); - } - return Promise.all(promises); -} - -// Attach button -attachBtn.addEventListener('click', () => fileInput.click()); -fileInput.addEventListener('change', () => { - handleFiles(fileInput.files); - fileInput.value = ''; -}); - -// ── Scroll-to-bottom button visibility ── -messagesEl.addEventListener('scroll', () => { - if (!scrollBottomBtn) return; - const atBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < SCROLL_THRESHOLD; - scrollBottomBtn.classList.toggle('visible', !atBottom); -}); - -// Drag and drop on messages area -messagesEl.addEventListener('dragover', (e) => { - e.preventDefault(); - messagesEl.classList.add('drag-over'); -}); -messagesEl.addEventListener('dragleave', () => { - messagesEl.classList.remove('drag-over'); -}); -messagesEl.addEventListener('drop', (e) => { - e.preventDefault(); - messagesEl.classList.remove('drag-over'); - if (e.dataTransfer.files.length > 0) { - handleFiles(e.dataTransfer.files); - promptEl.focus(); - } -}); - -// ── Input handlers ── -promptEl.addEventListener('keydown', (e) => { - // @-completion keyboard navigation takes precedence while visible: - // ↑/↓ move, Enter/Tab accept, Esc dismisses. - if (completionEl.classList.contains('visible')) { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - moveCompletionSelection(e.key === 'ArrowDown' ? 1 : -1); - return; - } - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - selectCompletion(); - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - completionEl.classList.remove('visible'); - return; - } - } - - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - send(); - return; - } - - if (e.key === 'Enter' && e.shiftKey) { - // Shift+Enter = new line (default behavior) - return; - } - - // ? = toggle shortcuts - if (e.key === '?' && !e.shiftKey && promptEl.value === '') { - e.preventDefault(); - toggleShortcuts(); - return; - } - - // History up/down (only when completion is hidden) - if (completionEl.classList.contains('visible')) return; - - if (e.key === 'ArrowUp') { - if (historyIdx > 0) { - e.preventDefault(); - historyIdx--; - promptEl.value = history[historyIdx] || ''; - promptEl.selectionStart = promptEl.selectionEnd = promptEl.value.length; - } - return; - } - if (e.key === 'ArrowDown') { - if (historyIdx < history.length - 1) { - e.preventDefault(); - historyIdx++; - promptEl.value = history[historyIdx] || ''; - } else { - historyIdx = history.length; - promptEl.value = ''; - } - return; - } -}); - -// ── Send button ── -sendBtn.addEventListener('click', send); - -// ── Textarea + @ key detection ── -let completionTimer = null; - -promptEl.addEventListener('input', () => { - if (completionTimer) clearTimeout(completionTimer); - completionTimer = setTimeout(checkCompletion, 150); -}); - -promptEl.addEventListener('keydown', (e) => { - if (e.key === '@') { - if (completionTimer) clearTimeout(completionTimer); - completionTimer = setTimeout(checkCompletion, 150); - } - - // Tab for completion selection - if (e.key === 'Tab' && completionEl.classList.contains('visible')) { - e.preventDefault(); - selectCompletion(); - return; - } -}); - -// ── @ Completion ── -completionEl.addEventListener('click', (e) => { - const item = e.target.closest('.comp-item'); - if (!item) return; - replaceCompletion(item.dataset.id); - completionEl.classList.remove('visible'); -}); - -completionEl.addEventListener('mousemove', (e) => { - const item = e.target.closest('.comp-item'); - if (!item) return; - completionEl.querySelectorAll('.comp-item').forEach(el => { - el.classList.toggle('selected', el === item); - el.setAttribute('aria-selected', el === item); - }); -}); - -let lastAtIdx = -1; -let lastCursor = -1; -let compQuery = ''; - -async function checkCompletion() { - const val = promptEl.value; - const cursor = promptEl.selectionStart; - const before = val.slice(0, cursor); - - const atIdx = before.lastIndexOf('@'); - if (atIdx < 0) { - completionEl.classList.remove('visible'); - return; - } - - const query = before.slice(atIdx + 1).split(/\s/)[0]; - if (!query) { - completionEl.classList.remove('visible'); - return; - } - - lastAtIdx = atIdx; - lastCursor = cursor; - compQuery = query; - - try { - const resp = await fetch('/api/resources?q=' + encodeURIComponent(query) + '&limit=8', { - headers: apiHeaders() - }); - const results = await resp.json(); - if (!results || results.length === 0) { - completionEl.classList.remove('visible'); - return; - } - - completionEl.innerHTML = results.map((r, i) => - `
    - ${escapeAttr(r.type)} - ${escapeHtml(r.label)} - ${escapeHtml(r.detail || '')} -
    ` - ).join(''); - - completionEl.classList.add('visible'); - } catch { - completionEl.classList.remove('visible'); - } -} - -// moveCompletionSelection moves the .selected marker by delta (+1/-1), -// keeping aria-selected in sync for assistive technology. -function moveCompletionSelection(delta) { - const items = Array.from(completionEl.querySelectorAll('.comp-item')); - if (items.length === 0) return; - let idx = items.findIndex(el => el.classList.contains('selected')); - idx = (idx + delta + items.length) % items.length; - items.forEach((el, i) => { - el.classList.toggle('selected', i === idx); - el.setAttribute('aria-selected', i === idx); - }); -} - -function selectCompletion() { - const selected = completionEl.querySelector('.selected'); - if (!selected) return; - replaceCompletion(selected.dataset.id); - completionEl.classList.remove('visible'); -} - -function replaceCompletion(id) { - promptEl.value = promptEl.value.slice(0, lastAtIdx) + id + ' ' + promptEl.value.slice(lastCursor); - const newPos = lastAtIdx + id.length + 1; - promptEl.selectionStart = promptEl.selectionEnd = newPos; - promptEl.focus(); -} - -// ── Relative time helper ── -function relativeTime(dateStr) { - if (!dateStr) return ''; - const d = new Date(dateStr); - if (isNaN(d)) return ''; - const secs = Math.floor((Date.now() - d) / 1000); - if (secs < 60) return 'just now'; - if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; - if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; - if (secs < 86400 * 7) return Math.floor(secs / 86400) + 'd ago'; - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} - -// ── Sessions ── -let allSessions = []; -let pendingDeleteId = null; -let sessionsSig = ''; - -sessionListEl.addEventListener('click', (e) => { - const item = e.target.closest('.session-item'); - if (!item) return; - const sid = item.dataset.id; - if (!sid) return; - - // Delete button - if (e.target.closest('.del-btn')) { - e.stopPropagation(); - pendingDeleteId = sid; - document.getElementById('confirm-msg').textContent = 'Delete session ' + sid.slice(0, 8) + '...?'; - document.getElementById('confirm-overlay').classList.add('active'); - return; - } - - // Rename button - if (e.target.closest('.rename-btn')) { - e.stopPropagation(); - renameSession(sid, e.target); - return; - } - - // Load and render session (click on the item body) - if (e.target.closest('.si-body')) { - if (sid === sessionId) return; - sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active')); - item.classList.add('active'); - loadAndRenderSession(sid); - } -}); - -// updateActiveSessionItem syncs the .active marker with the current -// sessionId without re-rendering the list. -function updateActiveSessionItem() { - sessionListEl.querySelectorAll('.session-item').forEach(el => { - el.classList.toggle('active', el.dataset.id === sessionId); - }); -} - -async function loadSessions() { - // Skeleton rows only on cold start (empty list); refreshes keep the - // existing items so scroll position and hover state survive. - if (!sessionListEl.querySelector('.session-item')) { - sessionListEl.innerHTML = '
    '.repeat(3); - } - try { - const resp = await fetch('/api/sessions', { headers: apiHeaders() }); - const sessions = await resp.json(); - if (!sessions || !Array.isArray(sessions)) { - sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove()); - return; - } - allSessions = sessions; - - // Skip the full re-render when nothing changed — this runs after every - // turn, and re-rendering would steal scroll position and hover state. - const sig = sessions.map(s => [s.id, s.task, s.turns, s.updated_at, s.model].join('|')).join('\n'); - if (sig === sessionsSig) { - updateActiveSessionItem(); - return; - } - sessionsSig = sig; - - sessionListEl.innerHTML = sessions.map(s => - `
    - - - - - -
    ` - ).join(''); - } catch { - sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove()); - } -} - -// ── Session history rendering ── -// Renders the full persisted transcript on session load: user/assistant -// text, thinking (reasoning_content), tool calls with their results, and -// delegate_tasks sub-agent groups. Previously only user/assistant text was -// re-rendered, so a reloaded session silently dropped most of what happened. -function renderSessionHistory(messages) { - // Index tool results by call id for matching against assistant tool_calls. - const resultsById = new Map(); - messages.forEach(m => { - if (m.role === 'tool' && m.tool_call_id) resultsById.set(m.tool_call_id, m.content || ''); - }); - - let toolDividerShown = false; - messages.forEach(msg => { - if (msg.role === 'user') { - addMessage('user', stripAttachmentBodies(msg.content || '')); - toolDividerShown = false; - return; - } - if (msg.role !== 'assistant') return; // skip system/tool internals - - if (msg.reasoning_content) renderHistoricalThinking(msg.reasoning_content); - - const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; - if (msg.content) { - renderAssistantMessage(msg.content); - } - if (toolCalls.length > 0) { - if (!toolDividerShown) { - addDivider('tool calls'); - toolDividerShown = true; - } - toolCalls.forEach(tc => { - const name = (tc.function && tc.function.name) || 'tool'; - const args = (tc.function && tc.function.arguments) || ''; - const result = resultsById.get(tc.id) || ''; - if (name === 'delegate_tasks') { - renderHistoricalSubagents(args, result); - } else { - renderHistoricalToolBlock(name, args, result); - } - }); - } - }); -} - -// renderHistoricalThinking renders a completed reasoning block (collapsed). -function renderHistoricalThinking(content) { - const block = document.createElement('div'); - block.className = 'thinking-block'; - block.innerHTML = - '
    ' + - ' reasoning' + - '
    ' + - '
    ' + escapeHtml(content) + '
    '; - messagesEl.appendChild(block); -} - -// renderHistoricalToolBlock renders a completed tool call with its result -// (no spinner, no latency — those are live-turn concerns). -function renderHistoricalToolBlock(name, args, result) { - const preview = buildToolPreview(name, args); - const el = document.createElement('div'); - el.className = 'tool-block'; - el.innerHTML = - '
    ' + - '' + - ' ' + toolEmoji(name) + '' + - ' ' + escapeHtml(name) + '' + - (preview ? ' ' + escapeHtml(preview) + '' : '') + - '
    ' + - '
    ' + escapeHtml(formatToolArgs(args)) + '
    '; - messagesEl.appendChild(el); - if (result) appendToolResultContent(el, result); -} - -// renderHistoricalSubagents renders a completed delegate_tasks group with -// per-task final states parsed from the tool result. -function renderHistoricalSubagents(args, output) { - addDivider('delegated tasks'); - - let tasks = []; - try { tasks = JSON.parse(args).tasks || []; } catch { tasks = []; } - const taskResults = parseSubagentResults(output); - - const group = document.createElement('div'); - group.className = 'subagent-group'; - group.innerHTML = '
    Sub-agents
    '; - messagesEl.appendChild(group); - const grid = group.querySelector('.subagent-grid'); - - tasks.forEach((task, i) => { - const card = document.createElement('div'); - card.className = 'subagent-card running'; - card.dataset.index = i; - card.innerHTML = - '
    ' + - '
    ' + - '
    ' + escapeHtml(task.goal || 'Task ' + (i+1)) + '
    ' + - '
    running
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    '; - grid.appendChild(card); - finalizeSubagentCard(card, taskResults[i]); - }); -} - -async function loadAndRenderSession(sid) { - try { - let token = getSessionToken(sid); - const resp = await fetch('/api/sessions/' + encodeURIComponent(sid), { - headers: apiHeaders(token ? { 'X-Session-Token': token } : {}) - }); - if (!resp.ok) { showToast('Failed to load session'); return; } - const sess = await resp.json(); - - // Persist the token returned by the server (bootstrapped for legacy - // sessions, echoed for current ones). - const returnedToken = resp.headers.get('X-Session-Token') || sess.auth_token; - if (returnedToken) setSessionToken(sid, returnedToken); - - // Switch session ID so the next prompt continues this session. - sessionId = sid; - - // Clear current messages and reset all streaming state. - resetTurnState(); - // Pending approvals belong to the previous view — drop them (the - // server-side request times out on its own). - approvalQueue = []; - removeActiveApprovalCard(); - busy = false; hideLoading(); hideCancel(); - sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN; - promptEl.disabled = false; - - messagesEl.innerHTML = ''; - if (savedScrollBtnNode) messagesEl.appendChild(savedScrollBtnNode); - - const messages = sess.messages || []; - // The full transcript renders now (tool calls, thinking, sub-agents); - // the empty check still keys on conversational messages only. - const visible = messages.filter(m => m.role === 'user' || m.role === 'assistant'); - - if (visible.length === 0) { - if (savedEmptyStateNode) messagesEl.appendChild(savedEmptyStateNode); - showToast('Empty session'); - return; - } - - renderSessionHistory(messages); - - forceScrollBottom(); - showToast('Session loaded'); - } catch (err) { - showToast('Error loading session'); - } -} - -// Replace inlined attachment blocks (--- name (size) ---\n...\n--- end name ---) -// with chip-style placeholders so reloaded user messages don't dump file bodies. -function stripAttachmentBodies(content) { - if (!content) return ''; - const re = /^--- (.+?) \(([^)]+)\) ---\n[\s\S]*?\n--- end \1 ---\n?/gm; - return content.replace(re, (m, name, size) => '📎 ' + name + ' (' + size + ')\n'); -} - -// Render a completed assistant message (not streaming) with copy button. -function renderAssistantMessage(content) { - hideEmptyState(); - const wrapper = document.createElement('div'); - wrapper.className = 'msg assistant'; - wrapper.innerHTML = - '
    ' + - '
    assistant
    ' + - '
    ' + markdownToHtml(escapeHtml(content)) + '
    ' + - '
    '; - messagesEl.appendChild(wrapper); - const bubble = wrapper.querySelector('.bubble'); - if (bubble) { addCopyButton(bubble); checkCollapse(bubble); } - pruneMessages(); -} - -// Sidebar search -sidebarSearch.addEventListener('input', () => { - const q = sidebarSearch.value.toLowerCase(); - const items = sessionListEl.querySelectorAll('.session-item'); - items.forEach(item => { - const text = item.textContent.toLowerCase(); - item.style.display = text.includes(q) ? '' : 'none'; - }); -}); - -// ── Init ── -// Save references so newSession() can restore the empty state after clearing. -savedEmptyStateNode = document.getElementById('empty-state'); -savedScrollBtnNode = scrollBottomBtn; - -// ── Thinking mode toggle ────────────────────────────────────────────── -const thinkBtn = document.getElementById('think-btn'); - -function syncThinkBtn() { - if (!thinkBtn) return; - thinkBtn.classList.toggle('active', thinkingEnabled); - thinkBtn.title = thinkingEnabled - ? 'Thinking ON — click to disable extended reasoning' - : 'Thinking OFF — click to enable extended reasoning'; -} - -window.toggleThinkingMode = function() { - thinkingEnabled = !thinkingEnabled; - localStorage.setItem('odek_thinking', thinkingEnabled ? '1' : '0'); - syncThinkBtn(); - showToast(thinkingEnabled ? '🧠 Thinking enabled' : 'Thinking off'); -}; - -syncThinkBtn(); // restore persisted state on load - -connect(); -// Show skeleton while connecting -if (skeletonEl) skeletonEl.classList.add('visible'); -loadSessions(); -fetchModels(); -promptEl.focus(); - -// Handle keyboard shortcuts globally -document.addEventListener('keydown', (e) => { - // Escape closes overlays. Approval cards are deliberately NOT dismissed — - // a decision must be made explicitly. - if (e.key === 'Escape') { - document.getElementById('shortcuts-overlay').classList.remove('active'); - document.getElementById('confirm-overlay').classList.remove('active'); - pendingDeleteId = null; - } - // Ctrl+R refreshes sessions - if (e.key === 'r' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - loadSessions(); - showToast('Sessions refreshed'); - } - // Alt+T toggles thinking mode - if (e.key === 't' && e.altKey && !busy) { - e.preventDefault(); - window.toggleThinkingMode(); - } -}); - - -// ── Phase 1: Cancel Button ── -function showCancel() { - if (cancelBtn) cancelBtn.classList.add('visible'); -} -function hideCancel() { - if (cancelBtn) cancelBtn.classList.remove('visible'); -} -window.cancelAgent = function() { - if (!sessionId) { - hideCancel(); - addSystemMessage('⏹ No active session to cancel'); - return; - } - fetch('/api/cancel?session_id=' + encodeURIComponent(sessionId), { - method: 'POST', - headers: apiHeaders({ 'X-Session-Token': getSessionToken(sessionId) || '' }) - }).catch(function(){}); - hideCancel(); - addSystemMessage('⏹ Canceled'); -}; - -// ── Phase 1: Scroll to Bottom ── -window.scrollToBottom = function() { - messagesEl.scrollTop = messagesEl.scrollHeight; - if (scrollBottomBtn) scrollBottomBtn.classList.remove('visible'); -}; - -// ── Phase 1: Sidebar Toggle (mobile) ── -window.toggleSidebar = function() { - var sidebar = document.getElementById('sidebar'); - if (!sidebar) return; - sidebar.classList.toggle('active'); - if (sidebarOverlay) sidebarOverlay.classList.toggle('active'); -}; - -// ── Phase 1: Collapse Long Messages ── -window.toggleCollapse = function(el) { - var bubble = el.closest('.bubble'); - if (!bubble) return; - bubble.classList.toggle('expanded'); - el.textContent = bubble.classList.contains('expanded') ? 'Show less ▲' : 'Show more ▼'; -}; - -// ── Phase 1: Copy Message ── -window.copyMessage = function(btn, content) { - if (!content) { - var bubble = btn.closest('.bubble'); - if (bubble) { - var contentEl = bubble.querySelector('.content'); - content = contentEl ? contentEl.textContent : ''; - } - } - if (!content) return; - copyTextToClipboard(content).then(function() { - btn.classList.add('copied'); - btn.innerHTML = ' Copied'; - setTimeout(function() { - btn.classList.remove('copied'); - btn.innerHTML = ''; - }, 2000); - }); -}; - -// ── Phase 1: Add copy buttons to rendered messages ── -function addCopyButton(bubble) { - if (bubble.querySelector('.copy-btn')) return; - var btn = document.createElement('button'); - btn.className = 'copy-btn'; - btn.innerHTML = ''; - btn.title = 'Copy message'; - btn.onclick = function() { copyMessage(this); }; - bubble.appendChild(btn); -} - -// ── Phase 1: Collapse long messages after rendering ── -// Must match the max-height of .bubble.collapsible in style.css. -const COLLAPSE_MAX_HEIGHT_PX = 460; -function checkCollapse(bubble) { - var content = bubble.querySelector('.content'); - if (!content || content.scrollHeight <= COLLAPSE_MAX_HEIGHT_PX) return; - bubble.classList.add('collapsible'); - var toggle = document.createElement('div'); - toggle.className = 'collapse-toggle'; - toggle.textContent = 'Show more ▼'; - toggle.onclick = function() { toggleCollapse(this); }; - bubble.appendChild(toggle); -} - -// Patch addMessage to add copy button and collapse check -var origAddMessage = addMessage; -addMessage = function(role, content) { - origAddMessage(role, content); - var msgs = messagesEl.querySelectorAll('.msg .bubble'); - if (msgs.length > 0) { - var last = msgs[msgs.length - 1]; - addCopyButton(last); - checkCollapse(last); - } -}; - - -})(); +// WebUI entry module. The client is split into ES modules under ./js/ +// (loaded natively, no bundler); main.js runs the init sequence and every +// feature module self-registers its listeners. +import './js/main.js'; diff --git a/cmd/odek/ui/index.html b/cmd/odek/ui/index.html index dfc25ed..78b869f 100644 --- a/cmd/odek/ui/index.html +++ b/cmd/odek/ui/index.html @@ -12,7 +12,7 @@
    - odek @@ -25,12 +25,12 @@
    - - - +
    @@ -40,13 +40,13 @@ - +
    @@ -56,7 +56,7 @@

    Sessions

    -
    @@ -65,8 +65,8 @@

    Sessions

    odek

    autonomous agent runtime

    - shortcuts /?/ - refresh sessions + shortcuts /?/ + refresh sessions
    @@ -86,7 +86,7 @@

    Sessions

    - +
    @@ -119,14 +119,14 @@

    Keyboard shortcuts

    Delete session?

    This action cannot be undone.

    - - + +
    - + diff --git a/cmd/odek/ui/js/approvals.js b/cmd/odek/ui/js/approvals.js new file mode 100644 index 0000000..62d0808 --- /dev/null +++ b/cmd/odek/ui/js/approvals.js @@ -0,0 +1,236 @@ +// Approval flow: queued approval requests rendered one at a time as an +// inline decision card pinned at the bottom of the message stream. +import { S } from './state.js'; +import { messagesEl } from './dom.js'; +import { forceScrollBottom } from './utils.js'; +import { hideEmptyState } from './render.js'; + +// Approval requests are queued and rendered one at a time as an inline +// decision card pinned at the bottom of the message stream — the user can +// scroll back through the transcript while deciding. Cards are fully +// keyboard-operable (a = approve, d = deny, t = trust session) and announce +// themselves to assistive technology via role="alertdialog" + focus move. +// An approval_ack from the server (answer given on another client) dismisses +// the matching request. + +// Risk-class presentation metadata. The server sends the class string +// (system_write, destructive, …); level drives the badge color and the +// "why" line explains the class in plain language. +export const APPROVAL_RISK_META = { + local_write: { icon: '🟡', level: 'warn', why: 'Writes or deletes files in the working directory.' }, + system_write: { icon: '⚠️', level: 'warn', why: 'Modifies system files or settings outside the workspace.' }, + destructive: { icon: '🚫', level: 'danger', why: 'Irreversibly destroys data. This cannot be undone.' }, + network_egress: { icon: '🌐', level: 'warn', why: 'Sends data out to the network.' }, + code_execution: { icon: '⚠️', level: 'warn', why: 'Executes arbitrary code.' }, + install: { icon: '📦', level: 'warn', why: 'Installs packages or dependencies.' }, + unknown: { icon: '🚫', level: 'danger', why: 'Unrecognized command — the gate fails closed on these.' }, + blocked: { icon: '🚫', level: 'danger', why: 'Hard-blocked, unrecoverable operation.' }, + safe: { icon: '✅', level: 'ok', why: 'Read-only operation.' }, +}; + +export function approvalRiskMeta(risk) { + return APPROVAL_RISK_META[risk] || { icon: '🛡️', level: 'warn', why: 'Requires your explicit approval.' }; +} + +export function queueApproval(event) { + S.approvalQueue.push(event); + if (!S.activeApprovalId) showNextApproval(); +} + +// dismissApproval removes a request from the queue (and its card if shown) +// without sending a response — used when the server acks an answer that +// came from another client. +export function dismissApproval(id) { + const idx = S.approvalQueue.findIndex(e => e.id === id); + if (idx >= 0) S.approvalQueue.splice(idx, 1); + if (S.activeApprovalId === id) { + removeActiveApprovalCard(); + showNextApproval(); + } +} + +function showNextApproval() { + const event = S.approvalQueue[0]; + if (!event) { + S.activeApprovalId = null; + S.activeApprovalCard = null; + return; + } + S.activeApprovalId = event.id; + renderApprovalCard(event); +} + +export function removeActiveApprovalCard() { + if (S.activeApprovalCard) { + S.activeApprovalCard.remove(); + S.activeApprovalCard = null; + } + S.activeApprovalId = null; +} + +function renderApprovalCard(event) { + removeActiveApprovalCard(); + const meta = approvalRiskMeta(event.risk); + + const card = document.createElement('div'); + card.className = 'approval-card'; + card.dataset.level = meta.level; + card.setAttribute('role', 'alertdialog'); + card.setAttribute('aria-labelledby', 'ac-title'); + card.setAttribute('aria-describedby', 'ac-why'); + card.tabIndex = -1; + + // Header: icon, titles, risk badge + const head = document.createElement('div'); + head.className = 'ac-head'; + const icon = document.createElement('span'); + icon.className = 'ac-icon'; + icon.textContent = meta.icon; + const titles = document.createElement('div'); + titles.className = 'ac-titles'; + const title = document.createElement('div'); + title.className = 'ac-title'; + title.id = 'ac-title'; + title.textContent = 'Approval required'; + const sub = document.createElement('div'); + sub.className = 'ac-sub'; + sub.textContent = 'the agent wants to run this operation'; + titles.append(title, sub); + const risk = document.createElement('span'); + risk.className = 'ac-risk'; + risk.dataset.level = meta.level; + risk.textContent = event.risk || 'unknown'; + head.append(icon, titles, risk); + + // Plain-language explanation of the risk class + const why = document.createElement('div'); + why.className = 'ac-why'; + why.id = 'ac-why'; + why.textContent = meta.why; + + // The command / operation, verbatim + const command = document.createElement('pre'); + command.className = 'ac-command'; + command.textContent = event.command; + + card.append(head, why, command); + + if (event.description) { + const desc = document.createElement('div'); + desc.className = 'ac-desc'; + desc.textContent = event.description; + card.appendChild(desc); + } + + // Friction mode: after repeated recent approvals of this class, require + // typing the literal word 'approve' (after a 1.5s gate). + let frictionInput = null; + if (event.friction) { + const fr = document.createElement('div'); + fr.className = 'ac-friction'; + const msg = document.createElement('div'); + msg.className = 'ac-friction-msg'; + msg.textContent = '⚠️ You approved ' + (event.friction_approvals || 0) + ' ' + (event.risk || '') + + ' operations in the last minute. Type the word “approve” to proceed.'; + frictionInput = document.createElement('input'); + frictionInput.className = 'ac-friction-input'; + frictionInput.type = 'text'; + frictionInput.placeholder = 'type: approve'; + frictionInput.setAttribute('aria-label', 'Type approve to confirm'); + fr.append(msg, frictionInput); + card.appendChild(fr); + } + + // Actions + const actions = document.createElement('div'); + actions.className = 'ac-actions'; + const denyBtn = document.createElement('button'); + denyBtn.className = 'deny'; + denyBtn.innerHTML = 'deny d'; + denyBtn.title = 'Deny [d]'; + denyBtn.addEventListener('click', () => sendApproval('deny')); + const trustBtn = document.createElement('button'); + trustBtn.className = 'trust'; + trustBtn.innerHTML = 'trust session t'; + trustBtn.title = 'Trust this risk class for the rest of the session [t]'; + trustBtn.addEventListener('click', () => sendApproval('trust')); + const approveBtn = document.createElement('button'); + approveBtn.className = 'approve'; + approveBtn.innerHTML = 'approve a'; + approveBtn.title = 'Approve [a]'; + approveBtn.addEventListener('click', () => sendApproval('approve')); + actions.append(denyBtn, trustBtn, approveBtn); + card.appendChild(actions); + + // Trust shortcut is suppressed for destructive / blocked / unknown. + if (event.allow_trust === false) trustBtn.style.display = 'none'; + + // Queue position indicator + if (S.approvalQueue.length > 1) { + const pos = document.createElement('div'); + pos.className = 'ac-queue-pos'; + pos.textContent = 'request 1 of ' + S.approvalQueue.length + ' — more waiting'; + card.appendChild(pos); + } + + // Friction gating: approve stays disabled until the word is typed and + // 1.5s have elapsed. + if (event.friction && frictionInput) { + approveBtn.disabled = true; + setTimeout(() => { + frictionInput.addEventListener('input', () => { + approveBtn.disabled = frictionInput.value.trim().toLowerCase() !== 'approve'; + }); + }, 1500); + frictionInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !approveBtn.disabled) sendApproval('approve'); + e.stopPropagation(); + }); + } + + S.activeApprovalCard = card; + hideEmptyState(); + messagesEl.appendChild(card); + forceScrollBottom(); + card.focus({ preventScroll: true }); +} + +export function sendApproval(action) { + if (!S.activeApprovalId) return; + const event = S.approvalQueue[0]; + // Honor the friction gate for keyboard-triggered approvals too. + if (event && event.friction && action === 'approve') { + const input = S.activeApprovalCard && S.activeApprovalCard.querySelector('.ac-friction-input'); + if (input && input.value.trim().toLowerCase() !== 'approve') return; + } + S.ws.send(JSON.stringify({ + type: 'approval_response', + id: S.activeApprovalId, + action: action + })); + S.approvalQueue.shift(); + removeActiveApprovalCard(); + showNextApproval(); +} + +// Keyboard operation while an approval card is active. Ignored when the +// user is typing in an input/textarea (the friction input handles its own +// Enter) or when modifier keys are held. +document.addEventListener('keydown', (e) => { + if (!S.activeApprovalId) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + const trustVisible = S.activeApprovalCard && + !S.activeApprovalCard.querySelector('.trust').style.display; + if (e.key === 'a' || e.key === 'A') { + e.preventDefault(); + sendApproval('approve'); + } else if (e.key === 'd' || e.key === 'D') { + e.preventDefault(); + sendApproval('deny'); + } else if ((e.key === 't' || e.key === 'T') && trustVisible) { + e.preventDefault(); + sendApproval('trust'); + } +}); diff --git a/cmd/odek/ui/js/dom.js b/cmd/odek/ui/js/dom.js new file mode 100644 index 0000000..8b79c40 --- /dev/null +++ b/cmd/odek/ui/js/dom.js @@ -0,0 +1,22 @@ +// DOM element references. Captured once at module load; the elements are all +// static index.html markup, so the refs stay valid for the page lifetime. +// This module must stay import-free (leaf of the import graph). + +export const messagesEl = document.getElementById('messages'); +export const promptEl = document.getElementById('prompt'); +export const sendBtn = document.getElementById('send-btn'); +export const completionEl = document.getElementById('completion'); +export const statusEl = document.getElementById('ws-status'); +export const dotEl = document.getElementById('ws-dot'); +export const modelLabel = document.getElementById('model-label'); +export const sessionListEl = document.getElementById('session-list'); +export const sidebarSearch = document.getElementById('sidebar-search'); +export const emptyState = document.getElementById('empty-state'); +export const cancelBtn = document.getElementById('cancel-btn'); +export const scrollBottomBtn = document.getElementById('scroll-bottom-btn'); +export const skeletonEl = document.getElementById('loading-skeleton'); +export const sidebarOverlay = document.getElementById('sidebar-overlay'); +export const fileInput = document.getElementById('file-input'); +export const attachBtn = document.getElementById('attach-btn'); +export const fileChips = document.getElementById('file-chips'); +export const thinkBtn = document.getElementById('think-btn'); diff --git a/cmd/odek/ui/js/input.js b/cmd/odek/ui/js/input.js new file mode 100644 index 0000000..97dac8b --- /dev/null +++ b/cmd/odek/ui/js/input.js @@ -0,0 +1,393 @@ +// Prompt handling: send, history navigation, @-completion, file +// attachments, drag-and-drop, auto-resize, and the scroll-bottom button. +import { S, getSessionToken } from './state.js'; +import { apiHeaders } from './net.js'; +import { + messagesEl, promptEl, sendBtn, completionEl, + scrollBottomBtn, fileInput, attachBtn, fileChips, +} from './dom.js'; +import { + escapeHtml, escapeAttr, formatFileSize, scrollToBottom, + showCancel, toggleShortcuts, SCROLL_THRESHOLD, +} from './utils.js'; +import { addMessage, resetTurnState, showLoading } from './render.js'; + +// ── Send ── +export function send() { + const text = promptEl.value.trim(); + + // Build display message (filename chips only — never the file body) and a + // separate attachments payload. The server wraps each attachment with the + // untrusted-content boundary before injecting it into the model context. + let display = text; + let attachments = []; + if (S.attachedFiles.length > 0) { + const chips = S.attachedFiles.map(f => '📎 ' + f.name + ' (' + formatFileSize(f.size) + ')').join('\n'); + display = chips + (text ? '\n\n' + text : ''); + attachments = S.attachedFiles.map(f => ({ name: f.name, content: f.content })); + clearAttachedFiles(); + } + + if ((!text && attachments.length === 0) || S.busy || !S.ws || S.ws.readyState !== WebSocket.OPEN) return; + + S.history.push(text); + if (S.history.length > 100) S.history.shift(); + localStorage.setItem('odek_history', JSON.stringify(S.history)); + S.historyIdx = S.history.length; + + promptEl.value = ''; + promptEl.style.height = 'auto'; + + // Add user message (display-only — file bodies are not rendered) + addMessage('user', display); + + // Reset streaming + tool state for the new turn. + resetTurnState(); + + S.busy = true; + sendBtn.disabled = true; + promptEl.disabled = true; + + showLoading(); + showCancel(); + + S.ws.send(JSON.stringify({ + type: 'prompt', + content: text, + attachments: attachments, + session_id: S.sessionId, + auth_token: getSessionToken(S.sessionId) || undefined, + model: S.currentModel || undefined, + thinking: S.thinkingEnabled ? 'enabled' : '' + })); +} + +// ── File Attachments ── +function addAttachedFile(file) { + // Check total size (max 10MB total) + const totalSize = S.attachedFiles.reduce((s, f) => s + f.size, 0) + file.size; + if (totalSize > 10 * 1024 * 1024) { + addErrorChip(file.name, 'total attachments exceed 10 MB'); + return; + } + S.attachedFiles.push(file); + renderFileChips(); +} + +function removeAttachedFile(index) { + S.attachedFiles.splice(index, 1); + renderFileChips(); +} + +function clearAttachedFiles() { + S.attachedFiles = []; + renderFileChips(); +} + +function renderFileChips() { + // Build nodes with textContent rather than innerHTML so a file name can + // never be reinterpreted as markup. + fileChips.textContent = ''; + S.attachedFiles.forEach((f, i) => { + const chip = document.createElement('span'); + chip.className = 'file-chip'; + + const icon = document.createElement('span'); + icon.className = 'chip-icon'; + icon.textContent = '📎'; + + const name = document.createElement('span'); + name.className = 'chip-name'; + name.textContent = f.name; + + const size = document.createElement('span'); + size.className = 'chip-size'; + size.textContent = formatFileSize(f.size); + + const remove = document.createElement('span'); + remove.className = 'chip-remove'; + remove.textContent = '✕'; + remove.addEventListener('click', () => removeAttachedFile(i)); + + chip.append(icon, name, size, remove); + fileChips.appendChild(chip); + }); + + // Total-size meter against the 10 MB cap. + if (S.attachedFiles.length > 0) { + const total = S.attachedFiles.reduce((s, f) => s + f.size, 0); + const meter = document.createElement('span'); + meter.className = 'chips-total'; + meter.textContent = formatFileSize(total) + ' / 10 MB'; + if (total > 8 * 1024 * 1024) meter.classList.add('warn'); + fileChips.appendChild(meter); + } +} + +// addErrorChip shows a transient error chip for a file that could not be +// attached (too large, unreadable). Dismisses on click or after 6s. +function addErrorChip(name, reason) { + const chip = document.createElement('span'); + chip.className = 'file-chip error'; + chip.title = reason; + const icon = document.createElement('span'); + icon.className = 'chip-icon'; + icon.textContent = '⚠️'; + const label = document.createElement('span'); + label.className = 'chip-name'; + label.textContent = name + ' — ' + reason; + const remove = document.createElement('span'); + remove.className = 'chip-remove'; + remove.textContent = '✕'; + chip.append(icon, label, remove); + fileChips.appendChild(chip); + const dismiss = () => chip.remove(); + remove.addEventListener('click', dismiss); + setTimeout(dismiss, 6000); +} + +function readFileAsText(file) { + return new Promise((resolve, reject) => { + // Limit individual files to 5MB + if (file.size > 5 * 1024 * 1024) { + reject(new Error('File too large (max 5 MB): ' + file.name)); + return; + } + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +function handleFiles(fileList) { + const promises = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + promises.push( + readFileAsText(file).then(content => { + addAttachedFile({name: file.name, size: file.size, content}); + }).catch(err => { + addErrorChip(file.name, err.message || 'could not read file'); + }) + ); + } + return Promise.all(promises); +} + +// Attach button +attachBtn.addEventListener('click', () => fileInput.click()); +fileInput.addEventListener('change', () => { + handleFiles(fileInput.files); + fileInput.value = ''; +}); + +// ── Scroll-to-bottom button visibility ── +messagesEl.addEventListener('scroll', () => { + if (!scrollBottomBtn) return; + const atBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < SCROLL_THRESHOLD; + scrollBottomBtn.classList.toggle('visible', !atBottom); +}); +scrollBottomBtn.addEventListener('click', scrollToBottom); + +// Drag and drop on messages area +messagesEl.addEventListener('dragover', (e) => { + e.preventDefault(); + messagesEl.classList.add('drag-over'); +}); +messagesEl.addEventListener('dragleave', () => { + messagesEl.classList.remove('drag-over'); +}); +messagesEl.addEventListener('drop', (e) => { + e.preventDefault(); + messagesEl.classList.remove('drag-over'); + if (e.dataTransfer.files.length > 0) { + handleFiles(e.dataTransfer.files); + promptEl.focus(); + } +}); + +// ── Auto-resize ── +promptEl.addEventListener('input', () => { + promptEl.style.height = 'auto'; + promptEl.style.height = Math.min(promptEl.scrollHeight, 200) + 'px'; +}); + +// ── Input handlers ── +promptEl.addEventListener('keydown', (e) => { + // @-completion keyboard navigation takes precedence while visible: + // ↑/↓ move, Enter/Tab accept, Esc dismisses. + if (completionEl.classList.contains('visible')) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + moveCompletionSelection(e.key === 'ArrowDown' ? 1 : -1); + return; + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + selectCompletion(); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + completionEl.classList.remove('visible'); + return; + } + } + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + return; + } + + if (e.key === 'Enter' && e.shiftKey) { + // Shift+Enter = new line (default behavior) + return; + } + + // ? = toggle shortcuts + if (e.key === '?' && !e.shiftKey && promptEl.value === '') { + e.preventDefault(); + toggleShortcuts(); + return; + } + + // History up/down (only when completion is hidden) + if (completionEl.classList.contains('visible')) return; + + if (e.key === 'ArrowUp') { + if (S.historyIdx > 0) { + e.preventDefault(); + S.historyIdx--; + promptEl.value = S.history[S.historyIdx] || ''; + promptEl.selectionStart = promptEl.selectionEnd = promptEl.value.length; + } + return; + } + if (e.key === 'ArrowDown') { + if (S.historyIdx < S.history.length - 1) { + e.preventDefault(); + S.historyIdx++; + promptEl.value = S.history[S.historyIdx] || ''; + } else { + S.historyIdx = S.history.length; + promptEl.value = ''; + } + return; + } +}); + +// ── Send button ── +sendBtn.addEventListener('click', send); + +// ── Textarea + @ key detection ── +let completionTimer = null; + +promptEl.addEventListener('input', () => { + if (completionTimer) clearTimeout(completionTimer); + completionTimer = setTimeout(checkCompletion, 150); +}); + +promptEl.addEventListener('keydown', (e) => { + if (e.key === '@') { + if (completionTimer) clearTimeout(completionTimer); + completionTimer = setTimeout(checkCompletion, 150); + } + + // Tab for completion selection + if (e.key === 'Tab' && completionEl.classList.contains('visible')) { + e.preventDefault(); + selectCompletion(); + return; + } +}); + +// ── @ Completion ── +completionEl.addEventListener('click', (e) => { + const item = e.target.closest('.comp-item'); + if (!item) return; + replaceCompletion(item.dataset.id); + completionEl.classList.remove('visible'); +}); + +completionEl.addEventListener('mousemove', (e) => { + const item = e.target.closest('.comp-item'); + if (!item) return; + completionEl.querySelectorAll('.comp-item').forEach(el => { + el.classList.toggle('selected', el === item); + el.setAttribute('aria-selected', el === item); + }); +}); + +async function checkCompletion() { + const val = promptEl.value; + const cursor = promptEl.selectionStart; + const before = val.slice(0, cursor); + + const atIdx = before.lastIndexOf('@'); + if (atIdx < 0) { + completionEl.classList.remove('visible'); + return; + } + + const query = before.slice(atIdx + 1).split(/\s/)[0]; + if (!query) { + completionEl.classList.remove('visible'); + return; + } + + S.lastAtIdx = atIdx; + S.lastCursor = cursor; + S.compQuery = query; + + try { + const resp = await fetch('/api/resources?q=' + encodeURIComponent(query) + '&limit=8', { + headers: apiHeaders() + }); + const results = await resp.json(); + if (!results || results.length === 0) { + completionEl.classList.remove('visible'); + return; + } + + completionEl.innerHTML = results.map((r, i) => + `
    + ${escapeAttr(r.type)} + ${escapeHtml(r.label)} + ${escapeHtml(r.detail || '')} +
    ` + ).join(''); + + completionEl.classList.add('visible'); + } catch { + completionEl.classList.remove('visible'); + } +} + +// moveCompletionSelection moves the .selected marker by delta (+1/-1), +// keeping aria-selected in sync for assistive technology. +function moveCompletionSelection(delta) { + const items = Array.from(completionEl.querySelectorAll('.comp-item')); + if (items.length === 0) return; + let idx = items.findIndex(el => el.classList.contains('selected')); + idx = (idx + delta + items.length) % items.length; + items.forEach((el, i) => { + el.classList.toggle('selected', i === idx); + el.setAttribute('aria-selected', i === idx); + }); +} + +function selectCompletion() { + const selected = completionEl.querySelector('.selected'); + if (!selected) return; + replaceCompletion(selected.dataset.id); + completionEl.classList.remove('visible'); +} + +function replaceCompletion(id) { + promptEl.value = promptEl.value.slice(0, S.lastAtIdx) + id + ' ' + promptEl.value.slice(S.lastCursor); + const newPos = S.lastAtIdx + id.length + 1; + promptEl.selectionStart = promptEl.selectionEnd = newPos; + promptEl.focus(); +} diff --git a/cmd/odek/ui/js/main.js b/cmd/odek/ui/js/main.js new file mode 100644 index 0000000..5ddc939 --- /dev/null +++ b/cmd/odek/ui/js/main.js @@ -0,0 +1,207 @@ +// Entry point: init sequence, theme, model picker, thinking toggle, cancel, +// global keyboard shortcuts. Feature modules self-register their listeners. +import { S, getSessionToken } from './state.js'; +import { apiHeaders } from './net.js'; +import { promptEl, skeletonEl, thinkBtn } from './dom.js'; +import { escapeHtml, escapeAttr, showToast, toggleShortcuts, hideCancel } from './utils.js'; +import { addSystemMessage } from './render.js'; +import { loadSessions } from './sessions.js'; +import { connect } from './ws.js'; +import './input.js'; +import './approvals.js'; + +// ── Init ── +// Save references so newSession() can restore the empty state after clearing. +S.savedEmptyStateNode = document.getElementById('empty-state'); +S.savedScrollBtnNode = document.getElementById('scroll-bottom-btn'); + +// Empty-state hint actions (the saved node is re-appended on session +// switches, so direct listeners persist). +if (S.savedEmptyStateNode) { + const hints = S.savedEmptyStateNode.querySelectorAll('.es-hints span'); + if (hints[0]) hints[0].addEventListener('click', toggleShortcuts); + if (hints[1]) hints[1].addEventListener('click', loadSessions); +} + +// ── Theme Toggle ── +function toggleTheme() { + document.body.classList.toggle('light'); + const isLight = document.body.classList.contains('light'); + localStorage.setItem('odek_theme', isLight ? 'light' : 'dark'); + document.getElementById('theme-btn').textContent = isLight ? '☀️' : '🌙'; +} +document.getElementById('theme-btn').addEventListener('click', toggleTheme); +// Restore theme on load +if (localStorage.getItem('odek_theme') === 'light') { + document.body.classList.add('light'); + document.getElementById('theme-btn').textContent = '☀️'; +} + +// ── Thinking mode toggle ────────────────────────────────────────────── +function syncThinkBtn() { + if (!thinkBtn) return; + thinkBtn.classList.toggle('active', S.thinkingEnabled); + thinkBtn.title = S.thinkingEnabled + ? 'Thinking ON — click to disable extended reasoning' + : 'Thinking OFF — click to enable extended reasoning'; +} + +function toggleThinkingMode() { + S.thinkingEnabled = !S.thinkingEnabled; + localStorage.setItem('odek_thinking', S.thinkingEnabled ? '1' : '0'); + syncThinkBtn(); + showToast(S.thinkingEnabled ? '🧠 Thinking enabled' : 'Thinking off'); +} +thinkBtn.addEventListener('click', toggleThinkingMode); + +syncThinkBtn(); // restore persisted state on load + +// ── Model Picker ── +const customModelInput = document.getElementById('custom-model-input'); + +async function fetchModels() { + const picker = document.getElementById('model-picker'); + try { + picker.disabled = true; + const resp = await fetch('/api/models', { headers: apiHeaders() }); + if (!resp.ok) { picker.innerHTML = ''; return; } + const models = await resp.json(); + S.availableModels = models; + if (!models || models.length === 0) { + picker.innerHTML = ''; + return; + } + let html = ''; + models.forEach(m => { + const sel = S.currentModel === m.id ? ' selected' : ''; + const label = m.current ? '★ ' + (m.description || m.id) : (m.description || m.id); + html += ``; + }); + // "Other..." sentinel opens the free-text input. + html += ''; + picker.innerHTML = html; + if (S.currentModel) { + picker.value = S.currentModel; + // If the current model isn't in the list, show the custom input. + if (!picker.value && S.currentModel) { + picker.value = '__custom__'; + customModelInput.value = S.currentModel; + customModelInput.style.display = 'inline-block'; + } + } + } catch { + picker.innerHTML = ''; + } finally { + picker.disabled = false; + } +} + +// Handles both known models and the "Other…" sentinel that reveals the +// free-text custom model input. +function onPickerChange(value) { + if (value === '__custom__') { + customModelInput.style.display = 'inline-block'; + customModelInput.focus(); + customModelInput.select(); + return; + } + customModelInput.style.display = 'none'; + customModelInput.value = ''; + if (value) switchModel(value); +} +document.getElementById('model-picker').addEventListener('change', (e) => onPickerChange(e.target.value)); + +// Commit a custom model ID from the text input on Enter or blur. +customModelInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commitCustomModel(); } + if (e.key === 'Escape') { + customModelInput.style.display = 'none'; + customModelInput.value = ''; + const picker = document.getElementById('model-picker'); + if (S.currentModel) picker.value = S.currentModel; + } +}); +customModelInput.addEventListener('blur', () => { + if (customModelInput.value.trim()) commitCustomModel(); +}); + +function commitCustomModel() { + const id = customModelInput.value.trim(); + if (!id) return; + switchModel(id); + // Add as a selectable option if not already present. + const picker = document.getElementById('model-picker'); + let found = false; + for (const opt of picker.options) { if (opt.value === id) { found = true; break; } } + if (!found) { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = id; + // Insert before the "Other…" sentinel + const sentinel = picker.querySelector('option[value="__custom__"]'); + picker.insertBefore(opt, sentinel); + } + picker.value = id; + customModelInput.style.display = 'none'; +} + +function switchModel(modelId) { + S.currentModel = modelId; + if (modelId) { + localStorage.setItem('odek_model', modelId); + } else { + localStorage.removeItem('odek_model'); + } + showToast(modelId ? 'Model: ' + modelId : 'Using default model'); +} + +// ── Cancel Button ── +function cancelAgent() { + if (!S.sessionId) { + hideCancel(); + addSystemMessage('⏹ No active session to cancel'); + return; + } + fetch('/api/cancel?session_id=' + encodeURIComponent(S.sessionId), { + method: 'POST', + headers: apiHeaders({ 'X-Session-Token': getSessionToken(S.sessionId) || '' }) + }).catch(() => {}); + hideCancel(); + addSystemMessage('⏹ Canceled'); +} +document.getElementById('cancel-btn').addEventListener('click', cancelAgent); + +// ── Shortcuts overlay ── +document.getElementById('shortcuts-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) toggleShortcuts(); +}); + +// ── Connect + initial load ── +connect(); +// Show skeleton while connecting +if (skeletonEl) skeletonEl.classList.add('visible'); +loadSessions(); +fetchModels(); +promptEl.focus(); + +// Handle keyboard shortcuts globally +document.addEventListener('keydown', (e) => { + // Escape closes overlays. Approval cards are deliberately NOT dismissed — + // a decision must be made explicitly. + if (e.key === 'Escape') { + document.getElementById('shortcuts-overlay').classList.remove('active'); + document.getElementById('confirm-overlay').classList.remove('active'); + S.pendingDeleteId = null; + } + // Ctrl+R refreshes sessions + if (e.key === 'r' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + loadSessions(); + showToast('Sessions refreshed'); + } + // Alt+T toggles thinking mode + if (e.key === 't' && e.altKey && !S.busy) { + e.preventDefault(); + toggleThinkingMode(); + } +}); diff --git a/cmd/odek/ui/js/markdown.js b/cmd/odek/ui/js/markdown.js new file mode 100644 index 0000000..9ce7162 --- /dev/null +++ b/cmd/odek/ui/js/markdown.js @@ -0,0 +1,70 @@ +// Markdown → HTML (safe, no DOMPurify needed since we control input). +// Imports only from utils.js. The copy button on code blocks has no inline +// handler — clicks are delegated on #messages in render.js. +import { escapeHtml } from './utils.js'; + +export function markdownToHtml(text) { + if (!text) return ''; + + let html = escapeHtml(text); + + // Headers (must be at start of line) + html = html.replace(/^#### (.+)$/gm, '

    $1

    '); + html = html.replace(/^### (.+)$/gm, '

    $1

    '); + html = html.replace(/^## (.+)$/gm, '

    $1

    '); + html = html.replace(/^# (.+)$/gm, '

    $1

    '); + + // Horizontal rules + html = html.replace(/^(---|\*\*\*|___)$/gm, '
    '); + + // Code blocks (```lang ... ```) — need to handle BEFORE inline code + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => { + const langLabel = lang || 'code'; + return '
    ' + + '
    ' + + '' + escapeHtml(langLabel) + '' + + '📋 copy' + + '
    ' + + '
    ' + escapeHtml(code) + '
    ' + + '
    '; + }); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + + // Italic + html = html.replace(/\*(.+?)\*/g, '$1'); + + // Strikethrough + html = html.replace(/~~(.+?)~~/g, '$1'); + + // Links — allowlist safe URL schemes to prevent javascript:/data: XSS. + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { + const trimmed = url.trim(); + const safe = /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(trimmed); + if (!safe) return label; + return '' + label + ''; + }); + + // Unordered lists (simple: lines starting with - or *) + html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    '); + + // Paragraphs — wrap remaining non-tag text in

    + // Split by double newlines (paragraph breaks) + const parts = html.split(/\n\n+/); + html = parts.map(part => { + part = part.trim(); + if (!part) return ''; + // Don't wrap if it starts with a block-level tag + if (/^<(h[1-4]|ul|ol|li|pre|div|hr|table)/.test(part)) return part; + // Don't wrap single
    + if (/^$/.test(part)) return part; + return '

    ' + part.replace(/\n/g, '
    ') + '

    '; + }).join('\n'); + + return html; +} diff --git a/cmd/odek/ui/js/net.js b/cmd/odek/ui/js/net.js new file mode 100644 index 0000000..9c575e2 --- /dev/null +++ b/cmd/odek/ui/js/net.js @@ -0,0 +1,18 @@ +// Network helpers shared by every module. Kept import-free so state.js (and +// everyone else) can use them without an import cycle. + +export function getWsToken() { + const meta = document.querySelector('meta[name="odek-ws-token"]'); + return meta ? meta.getAttribute('content') : ''; +} + +// Build API request headers that include the per-instance CSRF token. The +// server requires this token on every /api/* endpoint to block DNS-rebinding +// and cross-site driven reads. Browser same-origin requests also send the +// cookie, but the header defends-in-depth and works when cookies are blocked. +export function apiHeaders(extra) { + const token = getWsToken(); + const headers = token ? { 'X-Odek-Ws-Token': token } : {}; + if (extra) Object.assign(headers, extra); + return headers; +} diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js new file mode 100644 index 0000000..6282ad5 --- /dev/null +++ b/cmd/odek/ui/js/render.js @@ -0,0 +1,799 @@ +// Message rendering: streaming, thinking, tool blocks, sub-agent cards, +// session-history rendering, collapse/copy affordances, and the loading +// indicator. Imports only from state/dom/utils/markdown. +import { S } from './state.js'; +import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js'; +import { + escapeHtml, escapeAttr, truncateStr, copyTextToClipboard, + pruneMessages, scrollBottom, forceScrollBottom, stripAttachmentBodies, + showCancel, hideCancel, +} from './utils.js'; +import { markdownToHtml } from './markdown.js'; + +// ── Turn state ── +// resetTurnState clears all per-turn streaming/tool/sub-agent state. Called +// before a new turn (send), on new session, and when loading a session. +export function resetTurnState() { + S.streamBuffer = ''; + if (S.streamRAF) { + cancelAnimationFrame(S.streamRAF); + S.streamRAF = null; + } + S.streamBubbleEl = null; + S.streamContentEl = null; + S.currentToolBlock = null; + S.subagentGroup = null; + S.thinkingContentEl = null; + S.toolBlockQueues.clear(); + S.toolStartQueues.clear(); + S.inToolGroup = false; +} + +// ── Hide empty state ── +export function hideEmptyState() { + if (emptyState && emptyState.parentNode) { + emptyState.remove(); + } +} + +// ── Inline Loading Indicator — typographic, no emoji ── +const loadingMessages = [ + 'thinking', + 'reasoning', + 'considering', + 'planning', + 'tracing', + 'searching', + 'composing', +]; + +export function showLoading() { + const el = document.createElement('div'); + el.className = 'loading-indicator'; + el.innerHTML = '
    thinking
    '; + // Insert after the last message (the user message we just added) + messagesEl.appendChild(el); + S.loadingEl = el; + // Cycle messages + let idx = 0; + S.loadingTimer = setInterval(() => { + if (!S.loadingEl) return; + const textEl = S.loadingEl.querySelector('.li-text'); + if (!textEl) return; + textEl.textContent = loadingMessages[idx % loadingMessages.length]; + idx++; + }, 2000); + pruneMessages(); + // Force scroll to show the indicator (user just sent — they're at bottom) + forceScrollBottom(); +} + +export function hideLoading() { + if (S.loadingEl) { + S.loadingEl.remove(); + S.loadingEl = null; + } + if (S.loadingTimer) { + clearInterval(S.loadingTimer); + S.loadingTimer = null; + } +} + +// ── Thinking ── +export function streamThinking(content) { + if (!S.thinkingContentEl) { + // Remove cursor from any active stream + removeStreamCursor(); + + const block = document.createElement('div'); + block.className = 'thinking-block'; + block.innerHTML = + '
    ' + + ' reasoning' + + '
    ' + + '
    ' + escapeHtml(content) + '
    '; + messagesEl.appendChild(block); + + S.thinkingContentEl = block.querySelector('.thinking-content'); + hideEmptyState(); + pruneMessages(); + scrollBottom(); + } else { + S.thinkingContentEl.textContent += content; + scrollBottom(); + } +} + +function toggleThinking(el) { + const arrow = el.querySelector('.arrow'); + const content = el.parentElement.querySelector('.thinking-content'); + if (content) { + content.classList.toggle('open'); + arrow.classList.toggle('open'); + // Auto-open on first click + if (content.classList.contains('open')) { + scrollBottom(); + } + } +} + +export function endThinking() { + S.thinkingContentEl = null; +} + +// ── Streaming ── +export function streamToken(content) { + S.streamBuffer += content; + if (!S.streamRAF) { + S.streamRAF = requestAnimationFrame(streamFlushRAF); + } +} + +function streamFlushRAF() { + S.streamRAF = null; + if (!S.streamBuffer) return; + appendStreamText(S.streamBuffer); + S.streamBuffer = ''; +} + +export function streamFlush() { + if (S.streamRAF) { + cancelAnimationFrame(S.streamRAF); + S.streamRAF = null; + } + if (S.streamBuffer) { + appendStreamText(S.streamBuffer); + S.streamBuffer = ''; + } +} + +function ensureStreamBubble() { + if (!S.streamBubbleEl) { + startStream(); + } +} + +function appendStreamText(text) { + ensureStreamBubble(); + // Convert MD fragments to HTML and append + const html = markdownToHtml(text); + // Use a temp container to handle multi-node fragment + const temp = document.createElement('div'); + temp.innerHTML = html; + while (temp.firstChild) { + S.streamContentEl.appendChild(temp.firstChild); + } + scrollBottom(); +} + +function removeStreamCursor() { + if (S.streamBubbleEl) { + const cursor = S.streamBubbleEl.querySelector('.stream-cursor'); + if (cursor) cursor.remove(); + } +} + +function startStream() { + hideEmptyState(); + endThinking(); + hideLoading(); // remove the inline loading indicator — streaming has started + + const wrapper = document.createElement('div'); + wrapper.className = 'msg assistant'; + wrapper.style.opacity = '1'; + wrapper.innerHTML = + '
    ' + + '
    assistant
    ' + + '
    ' + + '
    '; + messagesEl.appendChild(wrapper); + + S.streamBubbleEl = wrapper; + S.streamContentEl = wrapper.querySelector('#stream-content'); + // Add copy button and collapse check to the stream bubble + const bubble = wrapper.querySelector('.bubble'); + if (bubble) { + addCopyButton(bubble); + checkCollapse(bubble); + } + pruneMessages(); + scrollBottom(); +} + +export function endStream() { + removeStreamCursor(); + S.streamBubbleEl = null; + S.streamContentEl = null; + S.currentToolBlock = null; + S.subagentGroup = null; + S.toolBlockQueues.clear(); + S.toolStartQueues.clear(); + S.inToolGroup = false; + S.busy = false; + hideLoading(); + hideCancel(); + sendBtn.disabled = !S.ws || S.ws.readyState !== WebSocket.OPEN; + promptEl.disabled = false; + promptEl.focus(); +} + +// ── Message rendering ── +export function addMessage(role, content) { + hideEmptyState(); + const wrapper = document.createElement('div'); + wrapper.className = 'msg ' + role; + + let sender = role; + if (role === 'user') sender = 'you'; + + wrapper.innerHTML = + '
    ' + + '
    ' + sender + '
    ' + + '
    ' + markdownToHtml(escapeHtml(content)) + '
    ' + + '
    '; + messagesEl.appendChild(wrapper); + // Copy button and collapse check on the freshly appended bubble. + const bubble = wrapper.querySelector('.bubble'); + if (bubble) { + addCopyButton(bubble); + checkCollapse(bubble); + } + pruneMessages(); + scrollBottom(); +} + +export function addSystemMessage(content) { + hideEmptyState(); + const el = document.createElement('div'); + el.className = 'msg system'; + el.innerHTML = '
    ' + escapeHtml(content) + '
    '; + messagesEl.appendChild(el); + pruneMessages(); + scrollBottom(); +} + +export function addDivider(text) { + const el = document.createElement('div'); + el.className = 'msg-divider'; + el.textContent = text || '•'; + messagesEl.appendChild(el); + scrollBottom(); +} + +// Render a completed assistant message (not streaming) with copy button. +export function renderAssistantMessage(content) { + hideEmptyState(); + const wrapper = document.createElement('div'); + wrapper.className = 'msg assistant'; + wrapper.innerHTML = + '
    ' + + '
    assistant
    ' + + '
    ' + markdownToHtml(escapeHtml(content)) + '
    ' + + '
    '; + messagesEl.appendChild(wrapper); + const bubble = wrapper.querySelector('.bubble'); + if (bubble) { addCopyButton(bubble); checkCollapse(bubble); } + pruneMessages(); +} + +// ── Tool Helpers ── + +// Matches Go's render.ToolEmoji for consistency. +function toolEmoji(name) { + if (name === 'read_file' || name === 'write_file' || name === 'search_files' || + name === 'patch' || name === 'execute_code' || name === 'multi_grep') return '📝'; + if (name === 'shell' || name === 'terminal' || name === 'process') return '💻'; + if (name === 'web_search' || name === 'web_extract' || name.startsWith('browser_')) return '🌐'; + if (name === 'memory' || name === 'session_search') return '🧠'; + if (name === 'vision_analyze') return '👁️'; + if (name === 'send_message') return '💬'; + if (name === 'delegate_task' || name === 'delegate_tasks') return '👥'; + if (name === 'cronjob') return '⏰'; + if (name === 'todo' || name === 'skill_view' || name === 'skill_manage' || + name === 'skills_list' || name === 'clarify') return '➕'; + if (name === 'transcribe') return '🎙️'; + if (name === 'list_directory' || name === 'create_directory') return '📁'; + return '🔧'; +} + +// Extract a short human-readable preview from tool args JSON. +function buildToolPreview(name, data) { + if (!data) return ''; + try { + const obj = JSON.parse(data); + switch (name) { + case 'read_file': return String(obj.path || '').slice(0, 60); + case 'write_file': return String(obj.path || '').slice(0, 60); + case 'search_files': return (obj.pattern || obj.query || '').slice(0, 50); + case 'multi_grep': return (obj.pattern || '').slice(0, 50); + case 'shell': return (obj.command || '').slice(0, 60); + case 'browser_navigate': case 'web_extract': return (obj.url || '').slice(0, 60); + case 'web_search': return (obj.query || '').slice(0, 60); + default: { + const first = Object.values(obj)[0]; + return first != null ? String(first).slice(0, 50) : ''; + } + } + } catch { return ''; } +} + +// Format tool args for the expanded body — pretty-print JSON or show raw. +function formatToolArgs(data) { + if (!data) return ''; + try { + const obj = JSON.parse(data); + return Object.entries(obj).map(([k, v]) => { + const val = typeof v === 'string' ? v : JSON.stringify(v, null, 2); + return k + ': ' + (val.length > 300 ? val.slice(0, 300) + '…' : val); + }).join('\n'); + } catch { + return data.length > 500 ? data.slice(0, 500) + '…' : data; + } +} + +// ── Tool Calls ── +export function addToolCall(name, data) { + removeStreamCursor(); + // Only add the "tool calls" divider once per tool group per turn. + if (!S.inToolGroup) { + addDivider('tool calls'); + S.inToolGroup = true; + } + + const emoji = toolEmoji(name); + const preview = buildToolPreview(name, data); + + const el = document.createElement('div'); + el.className = 'tool-block'; + el.innerHTML = + '
    ' + + '' + + ' ' + emoji + '' + + ' ' + escapeHtml(name) + '' + + (preview ? ' ' + escapeHtml(preview) + '' : '') + + ' ' + + ' ' + + '
    ' + + '
    ' + escapeHtml(formatToolArgs(data)) + '
    '; + + messagesEl.appendChild(el); + S.currentToolBlock = el; + + // Push into per-name FIFO queues so parallel results route correctly. + if (!S.toolBlockQueues.has(name)) S.toolBlockQueues.set(name, []); + S.toolBlockQueues.get(name).push(el); + if (!S.toolStartQueues.has(name)) S.toolStartQueues.set(name, []); + S.toolStartQueues.get(name).push(performance.now()); + + pruneMessages(); + scrollBottom(); +} + +// appendToolResultContent renders a tool result into a block, truncating +// long output behind a "show all" expander. Shared by the live path +// (addToolResult) and session-history rendering. +function appendToolResultContent(block, output) { + const MAX_RESULT = 600; + const truncated = output && output.length > MAX_RESULT; + const resultEl = document.createElement('div'); + resultEl.className = 'tb-result'; + block.appendChild(resultEl); + if (truncated) { + resultEl.innerHTML = + escapeHtml(output.slice(0, MAX_RESULT)) + + ' …show all (' + output.length + ' chars)'; + } else { + resultEl.textContent = output || ''; + } +} + +export function addToolResult(name, output) { + // Route to the matching pending block via FIFO queue. + const queue = S.toolBlockQueues.get(name); + const block = (queue && queue.length > 0) ? queue.shift() : S.currentToolBlock; + if (!block) return; + + // Remove spinner; show latency. + const spinner = block.querySelector('.tb-spinner'); + if (spinner) spinner.classList.remove('running'); + const startQueue = S.toolStartQueues.get(name); + if (startQueue && startQueue.length > 0) { + const start = startQueue.shift(); + const ms = performance.now() - start; + const latEl = block.querySelector('.tb-latency'); + if (latEl) latEl.textContent = ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(1) + 's'; + } + + appendToolResultContent(block, output || ''); + scrollBottom(); +} + +function expandToolResult(el) { + const full = el.dataset.full || ''; + const resultEl = el.parentElement; + if (resultEl) resultEl.textContent = full; +} + +function toggleToolBody(header) { + const arrow = header.querySelector('.arrow'); + const body = header.parentElement.querySelector('.tb-body'); + if (body) { + body.classList.toggle('open'); + arrow.classList.toggle('open'); + } +} + +// ── Sub-agent Cards ── +export function addSubagentGroup(command) { + removeStreamCursor(); + if (S.subagentGroup) return; // only one group at a time + + addDivider('delegated tasks'); + + let tasks = []; + try { + const parsed = JSON.parse(command); + tasks = parsed.tasks || []; + } catch { tasks = []; } + + const group = document.createElement('div'); + group.className = 'subagent-group'; + group.innerHTML = '
    Sub-agents
    '; + messagesEl.appendChild(group); + S.subagentGroup = group; + + const grid = group.querySelector('#sa-grid'); + tasks.forEach((task, i) => { + const card = document.createElement('div'); + card.className = 'subagent-card running'; + card.dataset.index = i; + card.innerHTML = + '
    ' + + '
    ' + + '
    ' + escapeHtml(task.goal || 'Task ' + (i+1)) + '
    ' + + '
    running
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    '; + grid.appendChild(card); + }); + + pruneMessages(); + scrollBottom(); +} + +// parseSubagentResults parses delegate_tasks output text of the form +// "📋 Sub-agent results:\n\n─── Task 1: goal ───\n{json}\n\n─── Task 2: ..." +// into a map of task index → parsed result object. +function parseSubagentResults(output) { + const lines = (output || '').split('\n'); + let currentTaskIdx = -1; + const taskResults = {}; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const taskMatch = line.match(/─── Task (\d+):/); + if (taskMatch) { + currentTaskIdx = parseInt(taskMatch[1]) - 1; + // Collect JSON from subsequent lines + const jsonLines = []; + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j]; + if (nextLine.startsWith('─── Task ')) break; + if (nextLine.startsWith('📋')) continue; + jsonLines.push(nextLine); + } + const jsonStr = jsonLines.join('\n').trim(); + try { + taskResults[currentTaskIdx] = JSON.parse(jsonStr); + } catch { + taskResults[currentTaskIdx] = { summary: jsonStr }; + } + } + } + return taskResults; +} + +// finalizeSubagentCard marks a card done/error and fills its details from +// the parsed result. Shared by the live path (completeSubagents) and +// session-history rendering. +function finalizeSubagentCard(card, result) { + card.querySelector('.sa-icon').textContent = '✓'; + card.classList.remove('running'); + card.querySelector('.sa-status').textContent = 'done'; + + if (!result) { + card.classList.add('completed'); + return; + } + + const status = result.status || 'success'; + if (status === 'error') { + card.classList.add('error'); + card.querySelector('.sa-icon').textContent = '✗'; + card.querySelector('.sa-status').textContent = 'error'; + } else { + card.classList.add('completed'); + } + + // Auto-open details for error or when there's a summary + const details = card.querySelector('.sa-details'); + const summary = result.summary || ''; + const files = result.files_changed || []; + const tokens = result.tokens_used || 0; + const iters = result.iterations || 0; + + if (summary || files.length > 0) { + const meta = details.querySelector('.sa-meta'); + if (tokens) meta.textContent = tokens + ' tokens' + (iters ? ' · ' + iters + ' iters' : ''); + + const summaryEl = details.querySelector('.sa-summary'); + summaryEl.textContent = typeof summary === 'string' ? summary : ''; + + if (files.length > 0) { + const filesEl = details.querySelector('.sa-files'); + filesEl.innerHTML = files.map(f => '📄' + escapeHtml(f) + '').join(''); + } + + details.classList.add('open'); + } +} + +export function completeSubagents(output) { + if (!S.subagentGroup) return; + + const taskResults = parseSubagentResults(output); + const cards = S.subagentGroup.querySelectorAll('.subagent-card'); + cards.forEach((card, i) => finalizeSubagentCard(card, taskResults[i])); + + pruneMessages(); + scrollBottom(); +} + +function toggleSaDetails(el) { + el.classList.toggle('open'); +} + +export function appendSubagentLog(taskIdx, event) { + if (!S.subagentGroup) return; + const cards = S.subagentGroup.querySelectorAll('.subagent-card'); + const card = cards[taskIdx]; + if (!card) return; + + // Ensure details are open + const details = card.querySelector('.sa-details'); + if (!details) return; + const summaryEl = details.querySelector('.sa-summary'); + + // Format the log line + let text = ''; + if (event.event === 'tool_call') { + text = '🔧 ' + event.name + (event.data ? '(' + truncateStr(event.data, 60) + ')' : ''); + } else if (event.event === 'tool_result') { + text = '📄 ' + truncateStr(event.data || '', 100); + } + if (!text) return; + + // Append to existing summary content (or create a log container) + let logContainer = card.querySelector('.sa-log'); + if (!logContainer) { + logContainer = document.createElement('div'); + logContainer.className = 'sa-log'; + details.insertBefore(logContainer, summaryEl); + } + const lineEl = document.createElement('div'); + lineEl.className = 'log-line'; + lineEl.textContent = text; + logContainer.appendChild(lineEl); + + details.classList.add('open'); + scrollBottom(); +} + +// ── Session history rendering ── +// Renders the full persisted transcript on session load: user/assistant +// text, thinking (reasoning_content), tool calls with their results, and +// delegate_tasks sub-agent groups. Previously only user/assistant text was +// re-rendered, so a reloaded session silently dropped most of what happened. +export function renderSessionHistory(messages) { + // Index tool results by call id for matching against assistant tool_calls. + const resultsById = new Map(); + messages.forEach(m => { + if (m.role === 'tool' && m.tool_call_id) resultsById.set(m.tool_call_id, m.content || ''); + }); + + let toolDividerShown = false; + messages.forEach(msg => { + if (msg.role === 'user') { + addMessage('user', stripAttachmentBodies(msg.content || '')); + toolDividerShown = false; + return; + } + if (msg.role !== 'assistant') return; // skip system/tool internals + + if (msg.reasoning_content) renderHistoricalThinking(msg.reasoning_content); + + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + if (msg.content) { + renderAssistantMessage(msg.content); + } + if (toolCalls.length > 0) { + if (!toolDividerShown) { + addDivider('tool calls'); + toolDividerShown = true; + } + toolCalls.forEach(tc => { + const name = (tc.function && tc.function.name) || 'tool'; + const args = (tc.function && tc.function.arguments) || ''; + const result = resultsById.get(tc.id) || ''; + if (name === 'delegate_tasks') { + renderHistoricalSubagents(args, result); + } else { + renderHistoricalToolBlock(name, args, result); + } + }); + } + }); +} + +// renderHistoricalThinking renders a completed reasoning block (collapsed). +function renderHistoricalThinking(content) { + const block = document.createElement('div'); + block.className = 'thinking-block'; + block.innerHTML = + '
    ' + + ' reasoning' + + '
    ' + + '
    ' + escapeHtml(content) + '
    '; + messagesEl.appendChild(block); +} + +// renderHistoricalToolBlock renders a completed tool call with its result +// (no spinner, no latency — those are live-turn concerns). +function renderHistoricalToolBlock(name, args, result) { + const preview = buildToolPreview(name, args); + const el = document.createElement('div'); + el.className = 'tool-block'; + el.innerHTML = + '
    ' + + '' + + ' ' + toolEmoji(name) + '' + + ' ' + escapeHtml(name) + '' + + (preview ? ' ' + escapeHtml(preview) + '' : '') + + '
    ' + + '
    ' + escapeHtml(formatToolArgs(args)) + '
    '; + messagesEl.appendChild(el); + if (result) appendToolResultContent(el, result); +} + +// renderHistoricalSubagents renders a completed delegate_tasks group with +// per-task final states parsed from the tool result. +function renderHistoricalSubagents(args, output) { + addDivider('delegated tasks'); + + let tasks = []; + try { tasks = JSON.parse(args).tasks || []; } catch { tasks = []; } + const taskResults = parseSubagentResults(output); + + const group = document.createElement('div'); + group.className = 'subagent-group'; + group.innerHTML = '
    Sub-agents
    '; + messagesEl.appendChild(group); + const grid = group.querySelector('.subagent-grid'); + + tasks.forEach((task, i) => { + const card = document.createElement('div'); + card.className = 'subagent-card running'; + card.dataset.index = i; + card.innerHTML = + '
    ' + + '
    ' + + '
    ' + escapeHtml(task.goal || 'Task ' + (i+1)) + '
    ' + + '
    running
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    '; + grid.appendChild(card); + finalizeSubagentCard(card, taskResults[i]); + }); +} + +// ── Collapse long messages ── +// Must match the max-height of .bubble.collapsible in style.css. +export const COLLAPSE_MAX_HEIGHT_PX = 460; + +function toggleCollapse(el) { + const bubble = el.closest('.bubble'); + if (!bubble) return; + bubble.classList.toggle('expanded'); + el.textContent = bubble.classList.contains('expanded') ? 'Show less ▲' : 'Show more ▼'; +} + +function checkCollapse(bubble) { + const content = bubble.querySelector('.content'); + if (!content || content.scrollHeight <= COLLAPSE_MAX_HEIGHT_PX) return; + bubble.classList.add('collapsible'); + const toggle = document.createElement('div'); + toggle.className = 'collapse-toggle'; + toggle.textContent = 'Show more ▼'; + bubble.appendChild(toggle); +} + +// ── Copy message / code ── +function copyMessage(btn, content) { + if (!content) { + const bubble = btn.closest('.bubble'); + if (bubble) { + const contentEl = bubble.querySelector('.content'); + content = contentEl ? contentEl.textContent : ''; + } + } + if (!content) return; + copyTextToClipboard(content).then(() => { + btn.classList.add('copied'); + btn.innerHTML = ' Copied'; + setTimeout(() => { + btn.classList.remove('copied'); + btn.innerHTML = ''; + }, 2000); + }); +} + +function copyCode(el) { + const block = el.closest('.code-block'); + if (!block) return; + const code = block.querySelector('pre code'); + if (!code) return; + copyTextToClipboard(code.textContent).then(() => { + el.textContent = '✓ copied'; + el.classList.add('copied'); + setTimeout(() => { + el.textContent = '📋 copy'; + el.classList.remove('copied'); + }, 2000); + }); +} + +function addCopyButton(bubble) { + if (bubble.querySelector('.copy-btn')) return; + const btn = document.createElement('button'); + btn.className = 'copy-btn'; + btn.innerHTML = ''; + btn.title = 'Copy message'; + bubble.appendChild(btn); +} + +// ── Click delegation for generated content ── +// All interactive elements inside #messages are handled here — no inline +// onclick attributes anywhere in generated HTML. +messagesEl.addEventListener('click', (e) => { + const t = e.target; + + const tbResultMore = t.closest('.tb-result-more'); + if (tbResultMore) { expandToolResult(tbResultMore); return; } + + const cbCopy = t.closest('.cb-copy'); + if (cbCopy) { copyCode(cbCopy); return; } + + const copyBtn = t.closest('.copy-btn'); + if (copyBtn) { copyMessage(copyBtn); return; } + + const collapseToggle = t.closest('.collapse-toggle'); + if (collapseToggle) { toggleCollapse(collapseToggle); return; } + + const tbHeader = t.closest('.tb-header'); + if (tbHeader) { toggleToolBody(tbHeader); return; } + + const thinkingToggle = t.closest('.thinking-toggle'); + if (thinkingToggle) { toggleThinking(thinkingToggle); return; } + + const saDetails = t.closest('.sa-details'); + if (saDetails) { toggleSaDetails(saDetails); return; } +}); diff --git a/cmd/odek/ui/js/sessions.js b/cmd/odek/ui/js/sessions.js new file mode 100644 index 0000000..b0c7e70 --- /dev/null +++ b/cmd/odek/ui/js/sessions.js @@ -0,0 +1,277 @@ +// Sessions sidebar: list loading/rendering, session switch, rename/delete, +// confirm dialog, sidebar search and mobile toggle. +import { S, getSessionToken, setSessionToken, clearSessionToken, ensureSessionToken } from './state.js'; +import { apiHeaders } from './net.js'; +import { messagesEl, promptEl, sendBtn, sessionListEl, sidebarSearch, sidebarOverlay } from './dom.js'; +import { escapeHtml, escapeAttr, relativeTime, showToast, forceScrollBottom, hideCancel } from './utils.js'; +import { resetTurnState, hideLoading, renderSessionHistory } from './render.js'; +import { removeActiveApprovalCard } from './approvals.js'; + +// updateActiveSessionItem syncs the .active marker with the current +// sessionId without re-rendering the list. +function updateActiveSessionItem() { + sessionListEl.querySelectorAll('.session-item').forEach(el => { + el.classList.toggle('active', el.dataset.id === S.sessionId); + }); +} + +export async function loadSessions() { + // Skeleton rows only on cold start (empty list); refreshes keep the + // existing items so scroll position and hover state survive. + if (!sessionListEl.querySelector('.session-item')) { + sessionListEl.innerHTML = '
    '.repeat(3); + } + try { + const resp = await fetch('/api/sessions', { headers: apiHeaders() }); + const sessions = await resp.json(); + if (!sessions || !Array.isArray(sessions)) { + sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove()); + return; + } + S.allSessions = sessions; + + // Skip the full re-render when nothing changed — this runs after every + // turn, and re-rendering would steal scroll position and hover state. + const sig = sessions.map(s => [s.id, s.task, s.turns, s.updated_at, s.model].join('|')).join('\n'); + if (sig === S.sessionsSig) { + updateActiveSessionItem(); + return; + } + S.sessionsSig = sig; + + sessionListEl.innerHTML = sessions.map(s => + `
    + + + + + +
    ` + ).join(''); + } catch { + sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove()); + } +} + +// ── New Session ── +export function newSession() { + S.sessionId = null; + + // Reset all streaming + tool state. + resetTurnState(); + // Any pending approval belongs to the previous session's run — drop it. + S.approvalQueue = []; + removeActiveApprovalCard(); + S.busy = false; + hideLoading(); hideCancel(); + sendBtn.disabled = !S.ws || S.ws.readyState !== WebSocket.OPEN; + promptEl.disabled = false; + + // Clear messages and restore empty state. + messagesEl.innerHTML = ''; + if (S.savedScrollBtnNode) messagesEl.appendChild(S.savedScrollBtnNode); + if (S.savedEmptyStateNode) messagesEl.appendChild(S.savedEmptyStateNode); + + sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active')); + showToast('New session'); + promptEl.value = ''; + promptEl.style.height = 'auto'; + promptEl.focus(); +} + +export async function loadAndRenderSession(sid) { + try { + const token = getSessionToken(sid); + const resp = await fetch('/api/sessions/' + encodeURIComponent(sid), { + headers: apiHeaders(token ? { 'X-Session-Token': token } : {}) + }); + if (!resp.ok) { showToast('Failed to load session'); return; } + const sess = await resp.json(); + + // Persist the token returned by the server (bootstrapped for legacy + // sessions, echoed for current ones). + const returnedToken = resp.headers.get('X-Session-Token') || sess.auth_token; + if (returnedToken) setSessionToken(sid, returnedToken); + + // Switch session ID so the next prompt continues this session. + S.sessionId = sid; + + // Clear current messages and reset all streaming state. + resetTurnState(); + // Pending approvals belong to the previous view — drop them (the + // server-side request times out on its own). + S.approvalQueue = []; + removeActiveApprovalCard(); + S.busy = false; hideLoading(); hideCancel(); + sendBtn.disabled = !S.ws || S.ws.readyState !== WebSocket.OPEN; + promptEl.disabled = false; + + messagesEl.innerHTML = ''; + if (S.savedScrollBtnNode) messagesEl.appendChild(S.savedScrollBtnNode); + + const messages = sess.messages || []; + // The full transcript renders now (tool calls, thinking, sub-agents); + // the empty check still keys on conversational messages only. + const visible = messages.filter(m => m.role === 'user' || m.role === 'assistant'); + + if (visible.length === 0) { + if (S.savedEmptyStateNode) messagesEl.appendChild(S.savedEmptyStateNode); + showToast('Empty session'); + return; + } + + renderSessionHistory(messages); + + forceScrollBottom(); + showToast('Session loaded'); + } catch (err) { + showToast('Error loading session'); + } +} + +// ── Session Rename ── +// Inline edit: swaps the task label for an input; Enter commits, Esc +// cancels, blur commits. No window.prompt. +export function renameSession(sid, el) { + const item = el.closest('.session-item'); + if (!item) return; + const taskEl = item.querySelector('.task'); + if (!taskEl || taskEl.querySelector('.si-rename-input')) return; + const currentName = taskEl.textContent; + + const input = document.createElement('input'); + input.className = 'si-rename-input'; + input.type = 'text'; + input.value = currentName === 'untitled' ? '' : currentName; + input.placeholder = 'session name…'; + taskEl.textContent = ''; + taskEl.appendChild(input); + input.focus(); + input.select(); + + let done = false; + const finish = (commit) => { + if (done) return; + done = true; + const newName = input.value.trim(); + taskEl.textContent = currentName; + if (commit && newName && newName !== currentName) { + doRenameSession(sid, newName); + } + }; + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('blur', () => finish(true)); +} + +async function doRenameSession(sid, newName) { + const token = await ensureSessionToken(sid); + fetch('/api/sessions/' + encodeURIComponent(sid), { + method: 'POST', + headers: apiHeaders({ + 'Content-Type': 'application/json', + ...(token ? { 'X-Session-Token': token } : {}) + }), + body: JSON.stringify({ name: newName }) + }) + .then(resp => { + if (!resp.ok) throw new Error('rename failed'); + loadSessions(); + showToast('Session renamed'); + }) + .catch(() => showToast('Failed to rename session')); +} + +// ── Confirm Dialog ── +export function hideConfirmDialog() { + document.getElementById('confirm-overlay').classList.remove('active'); + S.pendingDeleteId = null; +} + +export async function executeDeleteSession() { + if (!S.pendingDeleteId) return; + const sid = S.pendingDeleteId; + S.pendingDeleteId = null; + document.getElementById('confirm-overlay').classList.remove('active'); + + const token = await ensureSessionToken(sid); + + fetch('/api/sessions/' + encodeURIComponent(sid), { + method: 'DELETE', + headers: apiHeaders(token ? { 'X-Session-Token': token } : {}) + }) + .then(() => { + clearSessionToken(sid); + if (S.sessionId === sid) newSession(); + loadSessions(); + }) + .catch(() => showToast('Failed to delete session')); +} + +// ── Sidebar Toggle (mobile) ── +export function toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + if (!sidebar) return; + sidebar.classList.toggle('active'); + if (sidebarOverlay) sidebarOverlay.classList.toggle('active'); +} + +// ── Session list click delegation ── +sessionListEl.addEventListener('click', (e) => { + const item = e.target.closest('.session-item'); + if (!item) return; + const sid = item.dataset.id; + if (!sid) return; + + // Delete button + if (e.target.closest('.del-btn')) { + e.stopPropagation(); + S.pendingDeleteId = sid; + document.getElementById('confirm-msg').textContent = 'Delete session ' + sid.slice(0, 8) + '...?'; + document.getElementById('confirm-overlay').classList.add('active'); + return; + } + + // Rename button + if (e.target.closest('.rename-btn')) { + e.stopPropagation(); + renameSession(sid, e.target); + return; + } + + // Load and render session (click on the item body) + if (e.target.closest('.si-body')) { + if (sid === S.sessionId) return; + sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active')); + item.classList.add('active'); + loadAndRenderSession(sid); + } +}); + +// Sidebar search +sidebarSearch.addEventListener('input', () => { + const q = sidebarSearch.value.toLowerCase(); + const items = sessionListEl.querySelectorAll('.session-item'); + items.forEach(item => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(q) ? '' : 'none'; + }); +}); + +// ── Static sidebar / confirm-dialog buttons (formerly inline handlers) ── +document.getElementById('hamburger-btn').addEventListener('click', toggleSidebar); +sidebarOverlay.addEventListener('click', toggleSidebar); +document.querySelector('.new-session-btn').addEventListener('click', newSession); +document.querySelector('#confirm-actions .cancel').addEventListener('click', hideConfirmDialog); +document.getElementById('confirm-delete-btn').addEventListener('click', executeDeleteSession); diff --git a/cmd/odek/ui/js/state.js b/cmd/odek/ui/js/state.js new file mode 100644 index 0000000..88454c4 --- /dev/null +++ b/cmd/odek/ui/js/state.js @@ -0,0 +1,111 @@ +// Central mutable client state. Every module mutates through the single +// exported object S (live-binding friendly, no setters needed). This module +// may only import from net.js — never from render/ws/sessions/input/approvals. +import { apiHeaders } from './net.js'; + +// Migrate legacy kode_* keys to odek_* +['history', 'model', 'theme', 'thinking'].forEach(k => { + if (!localStorage.getItem('odek_' + k) && localStorage.getItem('kode_' + k)) { + localStorage.setItem('odek_' + k, localStorage.getItem('kode_' + k)); + localStorage.removeItem('kode_' + k); + } +}); + +export const S = { + // ── Connection / session ── + ws: null, + sessionId: null, + sessionTokens: {}, // session id -> auth token + busy: false, + history: JSON.parse(localStorage.getItem('odek_history') || '[]'), + historyIdx: -1, + attachedFiles: [], // {name, size, content} + currentModel: localStorage.getItem('odek_model') || '', + availableModels: [], + // Per-query thinking toggle. Persisted so it survives page refresh. + thinkingEnabled: localStorage.getItem('odek_thinking') === '1', + + // ── Streaming ── + streamBubbleEl: null, + streamContentEl: null, + streamBuffer: '', + streamRAF: null, + thinkingContentEl: null, // current thinking block if any + + // ── Tool call state ── + currentToolBlock: null, + // FIFO queues per tool name so parallel results route to the correct block. + // Map + toolBlockQueues: new Map(), + // Whether the current turn has started a "tool calls" divider group. + inToolGroup: false, + // Timestamps for tool latency (name → start ms, queue-based like above). + toolStartQueues: new Map(), + + // ── Sub-agent state ── + subagentGroup: null, + + // ── Smart scroll / toast / loading indicator ── + scrollRAF: null, + toastTimer: null, + loadingEl: null, + loadingTimer: null, + + // ── Approvals ── + approvalQueue: [], // approvalRequest events, FIFO + activeApprovalId: null, // id of the request currently rendered + activeApprovalCard: null, + + // ── Sessions sidebar ── + allSessions: [], + pendingDeleteId: null, + sessionsSig: '', + + // ── @-completion ── + lastAtIdx: -1, + lastCursor: -1, + compQuery: '', + + // ── Saved nodes for restoring the empty state after clearing ── + savedEmptyStateNode: null, + savedScrollBtnNode: null, +}; + +// ── Session token persistence (odek_* localStorage keys) ── + +export function getSessionToken(sid) { + if (!sid) return ''; + return S.sessionTokens[sid] || localStorage.getItem('odek_session_token_' + sid) || ''; +} + +export function setSessionToken(sid, token) { + if (!sid || !token) return; + S.sessionTokens[sid] = token; + localStorage.setItem('odek_session_token_' + sid, token); +} + +export function clearSessionToken(sid) { + if (!sid) return; + delete S.sessionTokens[sid]; + localStorage.removeItem('odek_session_token_' + sid); +} + +// ensureSessionToken returns a stored token for sid, bootstrapping one from +// the server (GET /api/sessions/) when missing. Used by session REST +// mutations (rename/delete) whose tokens may predate this browser. Returns +// '' on failure — the server will answer 401 if a token was required. +export async function ensureSessionToken(sid) { + let token = getSessionToken(sid); + if (token) return token; + try { + const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), { + headers: apiHeaders() + }); + if (bootstrap.ok) { + const bs = await bootstrap.json(); + token = bootstrap.headers.get('X-Session-Token') || bs.auth_token; + if (token) setSessionToken(sid, token); + } + } catch { /* continue — server will return 401 if token required */ } + return token || ''; +} diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js new file mode 100644 index 0000000..ac99f2f --- /dev/null +++ b/cmd/odek/ui/js/utils.js @@ -0,0 +1,149 @@ +// Pure-ish helpers: escaping, formatting, clipboard, toast, scrolling, and +// small DOM toggles. Imports only from state.js and dom.js. +import { S } from './state.js'; +import { messagesEl, scrollBottomBtn, cancelBtn } from './dom.js'; + +// ── Escape helpers ── +export function escapeHtml(s) { + if (!s) return ''; + return s.replace(/&/g,'&').replace(//g,'>'); +} + +export function escapeAttr(s) { + if (!s) return ''; + // & must be replaced first — doing it last double-escapes the entities + // introduced by the quote replacements (" → &quot;). + return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''') + .replace(//g,'>'); +} + +// ── Number formatting ── +export function formatNum(n) { + // Coerce to a finite number so a crafted (non-numeric) value can never be + // reinterpreted as HTML when the result lands in an innerHTML assignment. + n = Number(n); + if (!isFinite(n)) n = 0; + if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k'; + return String(n); +} + +export function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB'; + return (bytes/(1024*1024)).toFixed(1) + ' MB'; +} + +// ── Relative time helper ── +export function relativeTime(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const secs = Math.floor((Date.now() - d) / 1000); + if (secs < 60) return 'just now'; + if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; + if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; + if (secs < 86400 * 7) return Math.floor(secs / 86400) + 'd ago'; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +export function truncateStr(s, n) { + if (!s) return ''; + return s.length > n ? s.substring(0, n) + '…' : s; +} + +// ── Copy helpers ── +// copyTextToClipboard writes text to the clipboard, falling back to the +// legacy execCommand path when the async Clipboard API is unavailable or +// denied (non-secure contexts). Returns a Promise. +export function copyTextToClipboard(text) { + const fallback = () => { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + }; + if (!navigator.clipboard || !navigator.clipboard.writeText) { + fallback(); + return Promise.resolve(); + } + return navigator.clipboard.writeText(text).catch(fallback); +} + +// ── Toast ── +export function showToast(msg) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.add('show'); + clearTimeout(S.toastTimer); + S.toastTimer = setTimeout(() => el.classList.remove('show'), 2500); +} + +// ── Smart Scroll ── +export const SCROLL_THRESHOLD = 100; +export function isNearBottom() { + return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < SCROLL_THRESHOLD; +} +export function scrollBottom() { + if (!isNearBottom()) return; // user is reading up — don't steal scroll + if (S.scrollRAF) return; + S.scrollRAF = requestAnimationFrame(() => { + messagesEl.scrollTop = messagesEl.scrollHeight; + S.scrollRAF = null; + }); +} +export function forceScrollBottom() { + if (S.scrollRAF) { + cancelAnimationFrame(S.scrollRAF); + S.scrollRAF = null; + } + messagesEl.scrollTop = messagesEl.scrollHeight; +} + +// Scroll-to-bottom button handler: jump and hide the button. +export function scrollToBottom() { + messagesEl.scrollTop = messagesEl.scrollHeight; + if (scrollBottomBtn) scrollBottomBtn.classList.remove('visible'); +} + +// ── Message cap ── +const MAX_MESSAGES = 80; +export function pruneMessages() { + const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block'); + if (items.length > MAX_MESSAGES) { + for (let i = 0, n = items.length - MAX_MESSAGES; i < n; i++) { + items[i].remove(); + } + } +} + +// Replace inlined attachment blocks (--- name (size) ---\n...\n--- end name ---) +// with chip-style placeholders so reloaded user messages don't dump file bodies. +export function stripAttachmentBodies(content) { + if (!content) return ''; + const re = /^--- (.+?) \(([^)]+)\) ---\n[\s\S]*?\n--- end \1 ---\n?/gm; + return content.replace(re, (m, name, size) => '📎 ' + name + ' (' + size + ')\n'); +} + +// ── Error message normalization ── +export function formatErrorMessage(msg) { + if (!msg) return 'Unknown error'; + // Extract the core message from LiteLLM/provider verbose errors + const match = msg.match(/"message"\s*:\s*"([^"]{0,200})"/) || + msg.match(/BadRequestError[^:]*:\s*(.{0,200})/); + if (match) return match[1].trim(); + return msg.length > 300 ? msg.slice(0, 300) + '…' : msg; +} + +// ── Small UI toggles (cancel button, shortcuts overlay) ── +export function showCancel() { + if (cancelBtn) cancelBtn.classList.add('visible'); +} +export function hideCancel() { + if (cancelBtn) cancelBtn.classList.remove('visible'); +} + +export function toggleShortcuts() { + document.getElementById('shortcuts-overlay').classList.toggle('active'); +} diff --git a/cmd/odek/ui/js/ws.js b/cmd/odek/ui/js/ws.js new file mode 100644 index 0000000..b085227 --- /dev/null +++ b/cmd/odek/ui/js/ws.js @@ -0,0 +1,241 @@ +// WebSocket connection and the server-event dispatch switch. +import { S, setSessionToken } from './state.js'; +import { getWsToken } from './net.js'; +import { dotEl, statusEl, sendBtn, skeletonEl, messagesEl, modelLabel } from './dom.js'; +import { formatNum, formatErrorMessage, showToast, scrollBottom, escapeHtml } from './utils.js'; +import { + streamToken, streamThinking, streamFlush, endThinking, endStream, + addToolCall, addToolResult, addSubagentGroup, completeSubagents, + appendSubagentLog, addSystemMessage, +} from './render.js'; +import { queueApproval, dismissApproval } from './approvals.js'; +import { loadSessions } from './sessions.js'; + +export function connect() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const token = getWsToken(); + const protocols = token ? ['odek.' + token] : []; + S.ws = new WebSocket(proto + '//' + location.host + '/ws', protocols); + + S.ws.onopen = () => { + dotEl.className = 'dot connected'; + statusEl.textContent = 'connected'; + sendBtn.disabled = false; + // Hide loading skeleton when connected + if (skeletonEl) skeletonEl.classList.remove('visible'); + }; + + S.ws.onclose = () => { + dotEl.className = 'dot disconnected'; + statusEl.textContent = 'reconnecting...'; + sendBtn.disabled = true; + setTimeout(connect, 2000); + }; + + S.ws.onerror = () => { S.ws.close(); }; + + S.ws.onmessage = (e) => { + let event; + try { event = JSON.parse(e.data); } catch { return; } + + switch (event.type) { + case 'session': + S.sessionId = event.session_id || null; + if (event.auth_token) setSessionToken(S.sessionId, event.auth_token); + // Only adopt the server's model on the very first session event + // (no user-selected model yet). After that the user's choice wins. + if (event.model && !S.currentModel) { + S.currentModel = event.model; + const picker = document.getElementById('model-picker'); + if (picker && picker.value !== event.model) picker.value = event.model; + } + modelLabel.textContent = S.currentModel || event.model || ''; + const sandboxBadge = document.getElementById('sandbox-badge'); + if (sandboxBadge) { + sandboxBadge.style.display = event.sandbox ? 'inline-flex' : 'none'; + } + loadSessions(); + break; + + case 'token': + streamToken(event.content); + break; + + case 'thinking': + streamThinking(event.content); + break; + + case 'tool_call': + streamFlush(); + endThinking(); + if (event.name === 'delegate_tasks') { + addSubagentGroup(event.data); + } else { + addToolCall(event.name, event.data); + } + break; + + case 'tool_result': + if (event.name === 'delegate_tasks' && S.subagentGroup) { + completeSubagents(event.data); + } + addToolResult(event.name, event.data); + break; + + case 'subagent_log': + appendSubagentLog(event.task_idx, event); + break; + + case 'done': + streamFlush(); + endThinking(); + endStream(); + // Append per-message stats to the last assistant bubble + if (event.latency != null) { + const lastAssistant = messagesEl.querySelector('.msg.assistant:last-child .bubble'); + if (lastAssistant) { + const stats = document.createElement('div'); + stats.className = 'msg-stats'; + const lat = Number(event.latency); + const latSafe = isFinite(lat) ? lat : 0; + const spans = []; + spans.push('⚡ ' + (latSafe < 1 ? (latSafe * 1000).toFixed(0) + 'ms' : latSafe.toFixed(1) + 's') + ''); + if (event.contextTokens != null) spans.push('' + formatNum(event.contextTokens) + ' in'); + if (event.outputTokens != null) spans.push('' + formatNum(event.outputTokens) + ' out'); + // Cache metrics — show only when non-zero + if (event.cacheCreationTokens > 0) spans.push('' + formatNum(event.cacheCreationTokens) + ' stored'); + if (event.cacheReadTokens > 0) spans.push('' + formatNum(event.cacheReadTokens) + ' read'); + if (event.cachedTokens > 0) spans.push('' + formatNum(event.cachedTokens) + ' cached'); + stats.innerHTML = spans.join(' · '); + lastAssistant.appendChild(stats); + } + } + // Update session-level token stats in top bar + const sessionStatsEl = document.getElementById('session-stats'); + if (event.sessionContextTokens != null && event.sessionOutputTokens != null) { + const sessSpans = ['∑ ' + formatNum(event.sessionContextTokens) + ' in', '' + formatNum(event.sessionOutputTokens) + ' out']; + if (event.cacheReadTokens > 0 || event.cacheCreationTokens > 0 || event.cachedTokens > 0) { + // Count total session cache stats (accumulated across the session) + // We store cache totals on the session-stats element's dataset + const el = document.getElementById('session-stats'); + const cc = (parseInt(el.dataset.cacheCreate || '0') + (event.cacheCreationTokens || 0)); + const cr = (parseInt(el.dataset.cacheRead || '0') + (event.cacheReadTokens || 0)); + const cd = (parseInt(el.dataset.cached || '0') + (event.cachedTokens || 0)); + el.dataset.cacheCreate = cc; + el.dataset.cacheRead = cr; + el.dataset.cached = cd; + if (cr > 0) sessSpans.push('' + formatNum(cr) + ' read'); + if (cc > 0) sessSpans.push('' + formatNum(cc) + ' stored'); + if (cd > 0) sessSpans.push('' + formatNum(cd) + ' cached'); + } + sessionStatsEl.innerHTML = sessSpans.join(' · '); + sessionStatsEl.classList.add('visible'); + } + if (S.sessionId) loadSessions(); + break; + + case 'error': + streamFlush(); endThinking(); endStream(); + addSystemMessage('⚠ ' + formatErrorMessage(event.message)); + break; + + case 'approval_request': + queueApproval(event); + break; + + case 'approval_ack': + // The request was answered (by this or another connected client); + // drop it from the queue if it is still shown. + dismissApproval(event.id); + break; + + case 'skill_event': + handleSkillEvent(event); + break; + + case 'memory_event': + handleMemoryEvent(event); + break; + + case 'agent_signal': + handleAgentSignal(event); + break; + } + }; +} + +// ── Skill Events ── +function handleSkillEvent(event) { + switch (event.event) { + case 'saved': + showToast('✓ Skill saved: ' + (event.skill_name || '')); + break; + case 'deleted': + showToast('✗ Skill deleted: ' + (event.skill_name || '')); + break; + case 'suggested': { + // Inline card with save/skip — shown in messages area. + const el = document.createElement('div'); + el.className = 'skill-toast'; + el.innerHTML = + '💡 Skill suggestion: ' + escapeHtml(event.skill_name || '') + + (event.heuristic ? ' — ' + escapeHtml(event.heuristic) + '' : ''); + messagesEl.appendChild(el); + scrollBottom(); + break; + } + case 'loaded': case 'autoloaded': + // Silent — noisy to show every skill load. + break; + } +} + +// ── Memory Events ── +function handleMemoryEvent(event) { + switch (event.event) { + case 'fact_added': + showToast('🧠 Memory fact added (' + (event.target || '') + ')'); + break; + case 'fact_merged': + showToast('🧠 Memory fact merged (' + (event.target || '') + ')'); + break; + case 'fact_replaced': + showToast('🧠 Memory fact updated (' + (event.target || '') + ')'); + break; + case 'fact_removed': + showToast('🧠 Memory fact removed (' + (event.target || '') + ')'); + break; + case 'fact_consolidated': + showToast('🧠 Memory consolidated (' + (event.target || '') + ': ' + + (event.count || 0) + ' → ' + (event.new_count || 0) + ')'); + break; + case 'episode_stored': + // Silent by default — fires after every qualifying session. + break; + case 'episode_promoted': + showToast('💾 ✓ Episode promoted: ' + (event.session_id || '')); + break; + case 'episode_evicted': + showToast('💾 ✗ ' + (event.count || 0) + ' episode(s) evicted'); + break; + case 'episode_pending_review': + showToast('🔒 Episode pending review (untrusted): ' + (event.session_id || '')); + break; + case 'episode_deduped': + // Silent — internal dedup detail. + break; + } +} + +// ── Agent Signals ── +function handleAgentSignal(event) { + switch (event.event) { + case 'context_trimmed': + showToast('✂️ Context trimmed (' + (event.detail || '') + '): ' + + (event.count || 0) + ' group(s) dropped'); + break; + case 'tool_recovery': + showToast('🔁 Tool recovery: ' + (event.tool || '')); + break; + } +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 48c26a7..d8fad07 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -118,7 +118,10 @@ cmd/odek/ race_on_test.go / race_off_test.go Build-tag-gated `raceEnabled` const for race-sensitive tests ui/ index.html Single-page web UI (vanilla JS + CSS) - app.js, style.css Extracted JS / CSS + app.js ES-module entry point (imports ./js/main.js) + js/ Native ES modules (state, dom, utils, markdown, render, + approvals, sessions, input, ws, main, net) — no build step + style.css Stylesheet docs/ Documentation CLI.md CLI reference API.md Programmatic API (Go library) @@ -195,7 +198,7 @@ CI (`.github/workflows/test.yml`) runs the unit suite under `-race` on every pus - **serve.go**: HTTP server with embedded WebSocket handler, `@` resource API, session list API - **ws/ws.go**: Zero-dependency RFC 6455 WebSocket. Handles upgrade, text frames, close, ping/pong -- **ui/index.html + app.js + style.css**: Vanilla JS + CSS SPA. Streaming, collapsible tool blocks, `@` autocomplete, session sidebar +- **ui/index.html + app.js + js/ + style.css**: Vanilla JS + CSS SPA, split into native ES modules (`ui/js/`, no build step). Streaming, collapsible tool blocks, `@` autocomplete, session sidebar See [docs/WEBUI.md](docs/WEBUI.md) for the WebSocket protocol and full documentation.