Skip to content

Commit 556b8de

Browse files
committed
feat(ui): WebUI modernization PR3 — faithful session history rendering
Third PR of the WebUI modernization roadmap (Phase 2a: session reload fidelity). Stacked on #110 (feat/webui-modernization-2). Previously, loading a session from the sidebar re-rendered only user and assistant text messages - tool calls, thinking blocks, sub-agent groups, and their results silently disappeared, so the reloaded transcript lied about what the agent actually did. The persisted transcript already carries everything needed (assistant messages with tool_calls + reasoning_content, tool messages with results keyed by tool_call_id), so this is a client-only change with no API modification: - renderSessionHistory walks the full message list: user messages (with attachment-body stripping as before), reasoning_content as collapsed thinking blocks, assistant text, and tool_calls matched to their tool results by tool_call_id. Assistant messages carrying both text and tool_calls render both, matching the live turn layout. - delegate_tasks calls render as completed sub-agent groups with per-task final state (done/error, summary, files, tokens) parsed from the tool result. - Tool-result truncation (600 chars + "show all") is shared with the live path via the extracted appendToolResultContent helper. - Sub-agent result parsing and card finalization are extracted (parseSubagentResults / finalizeSubagentCard) and shared between the live completeSubagents path and historical rendering - no duplicated logic. Verified: node --check, go vet, full cmd/odek test suite.
1 parent a122d29 commit 556b8de

1 file changed

Lines changed: 186 additions & 68 deletions

File tree

cmd/odek/ui/app.js

Lines changed: 186 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,25 @@ function addToolCall(name, data) {
665665
scrollBottom();
666666
}
667667

668+
// appendToolResultContent renders a tool result into a block, truncating
669+
// long output behind a "show all" expander. Shared by the live path
670+
// (addToolResult) and session-history rendering.
671+
function appendToolResultContent(block, output) {
672+
const MAX_RESULT = 600;
673+
const truncated = output && output.length > MAX_RESULT;
674+
const resultEl = document.createElement('div');
675+
resultEl.className = 'tb-result';
676+
block.appendChild(resultEl);
677+
if (truncated) {
678+
resultEl.innerHTML =
679+
escapeHtml(output.slice(0, MAX_RESULT)) +
680+
'<span class="tb-result-more" onclick="expandToolResult(this)" data-full="' +
681+
escapeAttr(output) + '"> …show all (' + output.length + ' chars)</span>';
682+
} else {
683+
resultEl.textContent = output || '';
684+
}
685+
}
686+
668687
function addToolResult(name, output) {
669688
// Route to the matching pending block via FIFO queue.
670689
const queue = toolBlockQueues.get(name);
@@ -682,23 +701,7 @@ function addToolResult(name, output) {
682701
if (latEl) latEl.textContent = ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(1) + 's';
683702
}
684703

685-
// Show result, truncated if long.
686-
const MAX_RESULT = 600;
687-
const truncated = output && output.length > MAX_RESULT;
688-
let resultEl = block.querySelector('.tb-result');
689-
if (!resultEl) {
690-
resultEl = document.createElement('div');
691-
resultEl.className = 'tb-result';
692-
block.appendChild(resultEl);
693-
}
694-
if (truncated) {
695-
resultEl.innerHTML =
696-
escapeHtml(output.slice(0, MAX_RESULT)) +
697-
'<span class="tb-result-more" onclick="expandToolResult(this)" data-full="' +
698-
escapeAttr(output) + '"> …show all (' + output.length + ' chars)</span>';
699-
} else {
700-
resultEl.textContent = output || '';
701-
}
704+
appendToolResultContent(block, output || '');
702705
scrollBottom();
703706
}
704707

@@ -759,13 +762,11 @@ function addSubagentGroup(command) {
759762
scrollBottom();
760763
}
761764

762-
function completeSubagents(output) {
763-
if (!subagentGroup) return;
764-
765-
// Parse sub-agent results from the output text
766-
// The delegate_tasks tool returns formatted text like:
767-
// "📋 Sub-agent results:\n\n─── Task 1: goal ───\n{json}\n\n─── Task 2: ..."
768-
const lines = output.split('\n');
765+
// parseSubagentResults parses delegate_tasks output text of the form
766+
// "📋 Sub-agent results:\n\n─── Task 1: goal ───\n{json}\n\n─── Task 2: ..."
767+
// into a map of task index → parsed result object.
768+
function parseSubagentResults(output) {
769+
const lines = (output || '').split('\n');
769770
let currentTaskIdx = -1;
770771
const taskResults = {};
771772

@@ -790,49 +791,60 @@ function completeSubagents(output) {
790791
}
791792
}
792793
}
794+
return taskResults;
795+
}
793796

794-
const cards = subagentGroup.querySelectorAll('.subagent-card');
795-
cards.forEach((card, i) => {
796-
const result = taskResults[i];
797-
card.querySelector('.sa-icon').textContent = '✓';
798-
card.classList.remove('running');
799-
card.querySelector('.sa-status').textContent = 'done';
800-
801-
if (result) {
802-
const status = result.status || 'success';
803-
if (status === 'error') {
804-
card.classList.add('error');
805-
card.querySelector('.sa-icon').textContent = '✗';
806-
card.querySelector('.sa-status').textContent = 'error';
807-
} else {
808-
card.classList.add('completed');
809-
}
797+
// finalizeSubagentCard marks a card done/error and fills its details from
798+
// the parsed result. Shared by the live path (completeSubagents) and
799+
// session-history rendering.
800+
function finalizeSubagentCard(card, result) {
801+
card.querySelector('.sa-icon').textContent = '✓';
802+
card.classList.remove('running');
803+
card.querySelector('.sa-status').textContent = 'done';
810804

811-
// Auto-open details for error or when there's a summary
812-
const details = card.querySelector('.sa-details');
813-
const summary = result.summary || '';
814-
const files = result.files_changed || [];
815-
const tokens = result.tokens_used || 0;
816-
const iters = result.iterations || 0;
805+
if (!result) {
806+
card.classList.add('completed');
807+
return;
808+
}
817809

818-
if (summary || files.length > 0) {
819-
const meta = details.querySelector('.sa-meta');
820-
if (tokens) meta.textContent = tokens + ' tokens' + (iters ? ' · ' + iters + ' iters' : '');
810+
const status = result.status || 'success';
811+
if (status === 'error') {
812+
card.classList.add('error');
813+
card.querySelector('.sa-icon').textContent = '✗';
814+
card.querySelector('.sa-status').textContent = 'error';
815+
} else {
816+
card.classList.add('completed');
817+
}
821818

822-
const summaryEl = details.querySelector('.sa-summary');
823-
summaryEl.textContent = typeof summary === 'string' ? summary : '';
819+
// Auto-open details for error or when there's a summary
820+
const details = card.querySelector('.sa-details');
821+
const summary = result.summary || '';
822+
const files = result.files_changed || [];
823+
const tokens = result.tokens_used || 0;
824+
const iters = result.iterations || 0;
824825

825-
if (files.length > 0) {
826-
const filesEl = details.querySelector('.sa-files');
827-
filesEl.innerHTML = files.map(f => '<span class="file-chip"><span class="icon">📄</span>' + escapeHtml(f) + '</span>').join('');
828-
}
826+
if (summary || files.length > 0) {
827+
const meta = details.querySelector('.sa-meta');
828+
if (tokens) meta.textContent = tokens + ' tokens' + (iters ? ' · ' + iters + ' iters' : '');
829829

830-
details.classList.add('open');
831-
}
832-
} else {
833-
card.classList.add('completed');
830+
const summaryEl = details.querySelector('.sa-summary');
831+
summaryEl.textContent = typeof summary === 'string' ? summary : '';
832+
833+
if (files.length > 0) {
834+
const filesEl = details.querySelector('.sa-files');
835+
filesEl.innerHTML = files.map(f => '<span class="file-chip"><span class="icon">📄</span>' + escapeHtml(f) + '</span>').join('');
834836
}
835-
});
837+
838+
details.classList.add('open');
839+
}
840+
}
841+
842+
function completeSubagents(output) {
843+
if (!subagentGroup) return;
844+
845+
const taskResults = parseSubagentResults(output);
846+
const cards = subagentGroup.querySelectorAll('.subagent-card');
847+
cards.forEach((card, i) => finalizeSubagentCard(card, taskResults[i]));
836848

837849
pruneMessages();
838850
scrollBottom();
@@ -1885,6 +1897,117 @@ sessionListEl.addEventListener('click', (e) => {
18851897
loadAndRenderSession(sid);
18861898
});
18871899

1900+
// ── Session history rendering ──
1901+
// Renders the full persisted transcript on session load: user/assistant
1902+
// text, thinking (reasoning_content), tool calls with their results, and
1903+
// delegate_tasks sub-agent groups. Previously only user/assistant text was
1904+
// re-rendered, so a reloaded session silently dropped most of what happened.
1905+
function renderSessionHistory(messages) {
1906+
// Index tool results by call id for matching against assistant tool_calls.
1907+
const resultsById = new Map();
1908+
messages.forEach(m => {
1909+
if (m.role === 'tool' && m.tool_call_id) resultsById.set(m.tool_call_id, m.content || '');
1910+
});
1911+
1912+
let toolDividerShown = false;
1913+
messages.forEach(msg => {
1914+
if (msg.role === 'user') {
1915+
addMessage('user', stripAttachmentBodies(msg.content || ''));
1916+
toolDividerShown = false;
1917+
return;
1918+
}
1919+
if (msg.role !== 'assistant') return; // skip system/tool internals
1920+
1921+
if (msg.reasoning_content) renderHistoricalThinking(msg.reasoning_content);
1922+
1923+
const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
1924+
if (msg.content) {
1925+
renderAssistantMessage(msg.content);
1926+
}
1927+
if (toolCalls.length > 0) {
1928+
if (!toolDividerShown) {
1929+
addDivider('tool calls');
1930+
toolDividerShown = true;
1931+
}
1932+
toolCalls.forEach(tc => {
1933+
const name = (tc.function && tc.function.name) || 'tool';
1934+
const args = (tc.function && tc.function.arguments) || '';
1935+
const result = resultsById.get(tc.id) || '';
1936+
if (name === 'delegate_tasks') {
1937+
renderHistoricalSubagents(args, result);
1938+
} else {
1939+
renderHistoricalToolBlock(name, args, result);
1940+
}
1941+
});
1942+
}
1943+
});
1944+
}
1945+
1946+
// renderHistoricalThinking renders a completed reasoning block (collapsed).
1947+
function renderHistoricalThinking(content) {
1948+
const block = document.createElement('div');
1949+
block.className = 'thinking-block';
1950+
block.innerHTML =
1951+
'<div class="thinking-toggle" onclick="toggleThinking(this)">' +
1952+
'<span class="arrow">▶</span> reasoning' +
1953+
'</div>' +
1954+
'<div class="thinking-content">' + escapeHtml(content) + '</div>';
1955+
messagesEl.appendChild(block);
1956+
}
1957+
1958+
// renderHistoricalToolBlock renders a completed tool call with its result
1959+
// (no spinner, no latency — those are live-turn concerns).
1960+
function renderHistoricalToolBlock(name, args, result) {
1961+
const preview = buildToolPreview(name, args);
1962+
const el = document.createElement('div');
1963+
el.className = 'tool-block';
1964+
el.innerHTML =
1965+
'<div class="tb-header" onclick="toggleToolBody(this)">' +
1966+
'<span class="arrow">▶</span>' +
1967+
' <span class="tb-emoji">' + toolEmoji(name) + '</span>' +
1968+
' <span class="tb-name">' + escapeHtml(name) + '</span>' +
1969+
(preview ? ' <span class="tb-preview">' + escapeHtml(preview) + '</span>' : '') +
1970+
'</div>' +
1971+
'<div class="tb-body">' + escapeHtml(formatToolArgs(args)) + '</div>';
1972+
messagesEl.appendChild(el);
1973+
if (result) appendToolResultContent(el, result);
1974+
}
1975+
1976+
// renderHistoricalSubagents renders a completed delegate_tasks group with
1977+
// per-task final states parsed from the tool result.
1978+
function renderHistoricalSubagents(args, output) {
1979+
addDivider('delegated tasks');
1980+
1981+
let tasks = [];
1982+
try { tasks = JSON.parse(args).tasks || []; } catch { tasks = []; }
1983+
const taskResults = parseSubagentResults(output);
1984+
1985+
const group = document.createElement('div');
1986+
group.className = 'subagent-group';
1987+
group.innerHTML = '<div class="sg-header">Sub-agents</div><div class="subagent-grid"></div>';
1988+
messagesEl.appendChild(group);
1989+
const grid = group.querySelector('.subagent-grid');
1990+
1991+
tasks.forEach((task, i) => {
1992+
const card = document.createElement('div');
1993+
card.className = 'subagent-card running';
1994+
card.dataset.index = i;
1995+
card.innerHTML =
1996+
'<div class="sa-top">' +
1997+
'<div class="sa-icon">⟳</div>' +
1998+
'<div class="sa-goal" title="' + escapeAttr(task.goal || 'Task ' + (i+1)) + '">' + escapeHtml(task.goal || 'Task ' + (i+1)) + '</div>' +
1999+
'<div class="sa-status">running</div>' +
2000+
'</div>' +
2001+
'<div class="sa-details" onclick="toggleSaDetails(this)">' +
2002+
'<div class="sa-meta"></div>' +
2003+
'<div class="sa-summary"></div>' +
2004+
'<div class="sa-files"></div>' +
2005+
'</div>';
2006+
grid.appendChild(card);
2007+
finalizeSubagentCard(card, taskResults[i]);
2008+
});
2009+
}
2010+
18882011
async function loadAndRenderSession(sid) {
18892012
try {
18902013
let token = getSessionToken(sid);
@@ -1916,7 +2039,8 @@ async function loadAndRenderSession(sid) {
19162039
if (savedScrollBtnNode) messagesEl.appendChild(savedScrollBtnNode);
19172040

19182041
const messages = sess.messages || [];
1919-
// Only render user and assistant messages; skip system/tool internals.
2042+
// The full transcript renders now (tool calls, thinking, sub-agents);
2043+
// the empty check still keys on conversational messages only.
19202044
const visible = messages.filter(m => m.role === 'user' || m.role === 'assistant');
19212045

19222046
if (visible.length === 0) {
@@ -1925,13 +2049,7 @@ async function loadAndRenderSession(sid) {
19252049
return;
19262050
}
19272051

1928-
visible.forEach(msg => {
1929-
if (msg.role === 'user') {
1930-
addMessage('user', stripAttachmentBodies(msg.content || ''));
1931-
} else if (msg.role === 'assistant' && msg.content) {
1932-
renderAssistantMessage(msg.content);
1933-
}
1934-
});
2052+
renderSessionHistory(messages);
19352053

19362054
forceScrollBottom();
19372055
showToast('Session loaded');

0 commit comments

Comments
 (0)