diff --git a/cmd/odek/ui/app.js b/cmd/odek/ui/app.js index 0414937..e5e9c75 100644 --- a/cmd/odek/ui/app.js +++ b/cmd/odek/ui/app.js @@ -665,6 +665,25 @@ function addToolCall(name, data) { 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); @@ -682,23 +701,7 @@ function addToolResult(name, output) { if (latEl) latEl.textContent = ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(1) + 's'; } - // Show result, truncated if long. - const MAX_RESULT = 600; - const truncated = output && output.length > MAX_RESULT; - let resultEl = block.querySelector('.tb-result'); - if (!resultEl) { - 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 || ''; - } + appendToolResultContent(block, output || ''); scrollBottom(); } @@ -759,13 +762,11 @@ function addSubagentGroup(command) { scrollBottom(); } -function completeSubagents(output) { - if (!subagentGroup) return; - - // Parse sub-agent results from the output text - // The delegate_tasks tool returns formatted text like: - // "๐ Sub-agent results:\n\nโโโ Task 1: goal โโโ\n{json}\n\nโโโ Task 2: ..." - const lines = output.split('\n'); +// 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 = {}; @@ -790,49 +791,60 @@ function completeSubagents(output) { } } } + return taskResults; +} - const cards = subagentGroup.querySelectorAll('.subagent-card'); - cards.forEach((card, i) => { - const result = taskResults[i]; - card.querySelector('.sa-icon').textContent = 'โ'; - card.classList.remove('running'); - card.querySelector('.sa-status').textContent = 'done'; - - if (result) { - 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'); - } +// 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'; - // 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 (!result) { + card.classList.add('completed'); + return; + } - if (summary || files.length > 0) { - const meta = details.querySelector('.sa-meta'); - if (tokens) meta.textContent = tokens + ' tokens' + (iters ? ' ยท ' + iters + ' iters' : ''); + 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'); + } - const summaryEl = details.querySelector('.sa-summary'); - summaryEl.textContent = typeof summary === 'string' ? summary : ''; + // 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 (files.length > 0) { - const filesEl = details.querySelector('.sa-files'); - filesEl.innerHTML = files.map(f => '๐' + escapeHtml(f) + '').join(''); - } + if (summary || files.length > 0) { + const meta = details.querySelector('.sa-meta'); + if (tokens) meta.textContent = tokens + ' tokens' + (iters ? ' ยท ' + iters + ' iters' : ''); - details.classList.add('open'); - } - } else { - card.classList.add('completed'); + 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(); @@ -1885,6 +1897,117 @@ sessionListEl.addEventListener('click', (e) => { loadAndRenderSession(sid); }); +// โโ 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 = + '