From d2c6f86b1dcf89026f7b3a37171cade26174f012 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:50:15 +0200 Subject: [PATCH 1/4] perf(webui): bounded transcript DOM for long sessions The transcript grew without bound in long sessions: the 80-item cap only counted top-level rows, so one turn's stream (hundreds of tool steps, each with an eagerly rendered result view) was never trimmed, and opening a large session rendered every message before pruning. - js/transcript.js: a 300-row budget that counts rows inside turn streams, never trims live work, defers while the operator reads up (600-row ceiling, scroll position held) and shows a top-of-transcript note. - Session load renders the newest ~150-row window; "Show earlier" renders the previous window in place. Trimmed history rows return to the backlog as whole messages; rows trimmed from a live turn come back via a session reload once the turn ends. - Tool result views render on first expand. - Older answers are measured for folding once instead of on every reply. - Reasoning blocks keep their newest 400 lines; sub-agent chip logs 200. - The Activity list repaints once per frame and tracks its byte total. Co-Authored-By: Claude Opus 5.5 --- cmd/odek/ui/js/render.js | 340 ++++++++++++++++++++---- cmd/odek/ui/js/sessions.js | 10 + cmd/odek/ui/js/transcript.js | 207 +++++++++++++++ cmd/odek/ui/js/transcript.test.js | 414 ++++++++++++++++++++++++++++++ cmd/odek/ui/js/utils.js | 11 - cmd/odek/ui/js/workspace.js | 21 +- cmd/odek/ui/style.css | 6 + docs/WEBUI.md | 7 +- 8 files changed, 942 insertions(+), 74 deletions(-) create mode 100644 cmd/odek/ui/js/transcript.js create mode 100644 cmd/odek/ui/js/transcript.test.js diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index e38fd14f..f75d02d3 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -6,9 +6,10 @@ import { S } from './state.js'; import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js'; import { escapeHtml, escapeAttr, truncateStr, copyTextToClipboard, - pruneMessages, scrollBottom, forceScrollBottom, historyUserText, + scrollBottom, forceScrollBottom, historyUserText, showCancel, hideCancel, teach, } from './utils.js'; +import { pruneMessages, syncTrimMarker, setHistoryBacklog, trimState, HISTORY_UNITS } from './transcript.js'; import { markdownToHtml, CODE_COPY_LABEL, CODE_COPIED_LABEL } from './markdown.js'; import { parseUntrusted } from './untrusted.js'; import { classifyToolResult, chipsHtml, prettyToolBody, collectReceipt, formatReceipt } from './tools.js'; @@ -417,6 +418,27 @@ function newThinkingLine(text) { line.textContent = text || ''; S.thinkingContentEl.appendChild(line); S.thinkingLineEl = line; + capThinkingLines(S.thinkingContentEl); +} + +// A reasoning block keeps its most recent rows; older ones collapse into a +// single note (the persisted session still holds the full reasoning). +export const MAX_THINKING_LINES = 400; +function capThinkingLines(host) { + const rows = host.children || []; + let note = rows[0] && hasClass(rows[0], 'thinking-trim') ? rows[0] : null; + let excess = rows.length - (note ? 1 : 0) - MAX_THINKING_LINES; + if (excess <= 0) return; + if (!note) { + note = document.createElement('div'); + note.className = 'thinking-trim'; + note.dataset.trimmed = '0'; + host.insertBefore(note, rows[0] || null); + } + const trimmed = Number(note.dataset.trimmed || 0) + excess; + while (excess-- > 0) host.children[1].remove(); + note.dataset.trimmed = String(trimmed); + note.textContent = '… ' + trimmed + ' earlier lines not shown'; } function appendThinkingFragment(content) { @@ -724,6 +746,7 @@ export function endStream(reason = "interrupted") { promptEl.disabled = false; setSendMode(sendBtn, 'send'); S.refreshSupervision?.(); + syncTrimMarker(); } function sealTurnStream() { @@ -909,17 +932,31 @@ export function addToolCall(name, data, callId = '') { // appendToolResultContent renders a tool result into the collapsed .tb-body // (same toggle as args), truncating long output behind a "show all" expander. // Shared by the live path (addToolResult) and session-history rendering. -function appendToolResultContent(block, output) { +// The result view (toolbar, search, line rows) is built on the first expand: +// most steps are never opened, and eagerly rendered bodies were the bulk of +// a long session's DOM. Until then the output waits on the block itself. +function appendToolResultContent(block, output, record = true) { const view = toolView(block.dataset.toolName || '', resultText(output), block.dataset.toolArgs || ''); const preview = block.querySelector('.tb-preview'); if (view && preview) { preview.textContent = view.summary; preview.title = view.summary; } + block.pendingResult = output; + const body = block.querySelector('.tb-body'); + if (!body || body.classList.contains('open')) renderPendingResult(block); + if (record && typeof S.recordResult === 'function') S.recordResult(block, output); +} + +function renderPendingResult(block) { + const output = block.pendingResult; + if (output == null) return; + block.pendingResult = null; const resultEl = document.createElement('div'); resultEl.className = 'tb-result'; const body = block.querySelector('.tb-body'); - (body || block).appendChild(resultEl); + const inspect = body && body.querySelector('.result-inspect'); + if (inspect) body.insertBefore(resultEl, inspect); + else (body || block).appendChild(resultEl); renderResult(resultEl, { name: block.dataset.toolName || '', output, args:block.dataset.toolArgs || '', compact: true }); - if (typeof S.recordResult === 'function') S.recordResult(block, output); } // fillToolResult renders tool output into resultEl. The server sends raw, @@ -1015,6 +1052,7 @@ function toggleToolBody(header) { const arrow = header.querySelector('.arrow'); const body = header.parentElement.querySelector('.tb-body'); if (body) { + if (!body.classList.contains('open')) renderPendingResult(header.parentElement); body.classList.toggle('open'); arrow.classList.toggle('open'); header.setAttribute('aria-expanded', body.classList.contains('open')); @@ -1414,61 +1452,244 @@ export function appendSubagentLog(taskIdx, event) { lineEl.className = 'log-line'; lineEl.textContent = text; logContainer.appendChild(lineEl); + // A long-running sub-agent keeps only its recent beats in the chip log. + while (logContainer.children.length > MAX_SUBAGENT_LOG_LINES) logContainer.children[0].remove(); } +export const MAX_SUBAGENT_LOG_LINES = 200; // ── Session history rendering ── -// Renders the full persisted transcript on session load: user/assistant -// text, thinking (reasoning_content), tool calls with their results, and +// Renders the 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. - let historyTurnId=''; - messages.forEach((msg, messageIndex) => { - // Correlate within this assistant group: some providers reuse IDs across turns. - const resultsById = new Map(); - const outcomesById = new Map(); - for(let index=messageIndex+1;index= 0; i--) { + const n = historyUnits(messages[i]); + if (n && fit < end && units + n > budget) { + return turnStart >= 0 && turnUnits * 2 >= budget ? turnStart : fit; } - if (msg.role !== 'assistant') return; // skip system/tool internals + units += n; + if (n) fit = i; + if (messages[i] && messages[i].role === 'user') { turnStart = i; turnUnits = units; } + } + return 0; +} + +// historyToolCalls lists one assistant message's tool calls paired with +// their results. Results are correlated within the assistant group: some +// providers reuse ids across turns. +function historyToolCalls(messages, messageIndex) { + const msg = messages[messageIndex]; + const resultsById = new Map(); + const outcomesById = new Map(); + for (let index = messageIndex + 1; index < messages.length && messages[index] && messages[index].role === 'tool'; index++) { + const result = messages[index]; + const key = result.tool_call_id || 'position-' + (index - messageIndex - 1); + resultsById.set(key, result.content || ''); outcomesById.set(key, result.tool_outcome || 'unknown'); + } + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + return toolCalls.map((tc, toolIndex) => { + const key = (tc && tc.id) || 'position-' + toolIndex; + return { + id: (tc && tc.id) || '', + name: (tc && tc.function && tc.function.name) || 'tool', + args: (tc && tc.function && tc.function.arguments) || '', + result: resultsById.get(key) || '', + outcome: outcomesById.get(key), + toolIndex, + }; + }); +} + +function historyTurnIdBefore(messages, from) { + for (let i = from - 1; i >= 0; i--) { + const m = messages[i]; + if (m && m.role === 'user') return m.turn_id || 'history-' + i; + } + return ''; +} - if (msg.reasoning_content) renderHistoricalThinking(msg.reasoning_content); +// recordHistoricalResults feeds tool results outside the rendered window +// to the Activity panel in transcript order, so its evidence summary covers +// the session and not only the visible window. The stub block is never +// attached; "Show in conversation" already skips disconnected blocks. +function recordHistoricalResults(messages, from, to) { + if (typeof S.recordResult !== 'function') return; + let turnId = historyTurnIdBefore(messages, from); + for (let i = from; i < to; i++) { + const msg = messages[i]; + if (!msg) continue; + if (msg.role === 'user') { turnId = msg.turn_id || 'history-' + i; continue; } + if (msg.role !== 'assistant') continue; + historyToolCalls(messages, i).forEach((call) => { + if (call.name === 'delegate_tasks' || !call.result) return; + const stub = document.createElement('div'); + stub.dataset.toolName = call.name; + stub.dataset.toolArgs = call.args; + stub.dataset.outcome = call.outcome || 'unknown'; + stub.dataset.turnId = msg.turn_id || turnId; + stub.dataset.callId = call.id; + S.recordResult(stub, call.result); + }); + } +} - // Content before tools: the model streams the visible reply first, - // then acts. Putting tools first parked every partial after the log. - if (msg.content) { - const wrapper = renderHistoricalAssistant(msg.content); - if (msg.superseded) foldDraft(wrapper, msg.superseded_reason); - } +// renderHistoryRange renders messages[from, to) into a detached container +// and returns its rows, ready to attach. +function renderHistoryRange(messages, from, to) { + historyHost = document.createElement('div'); + try { + let historyTurnId = historyTurnIdBefore(messages, from); + for (let messageIndex = from; messageIndex < to; messageIndex++) { + const msg = messages[messageIndex]; + if (!msg) continue; + historyIdx = messageIndex; + if (msg.role === 'user') { + historyTurnId = msg.turn_id || 'history-' + messageIndex; + // System-initiated records (bg-wake preamble, bg-notice, return-after-break) + // are not text the operator typed. + if (typeof msg.name === 'string' && (msg.name.startsWith('bg-') || msg.name === 'return-after-break')) continue; + renderHistoricalUser(historyUserText(msg.content, msg.principal_prompt)); + continue; + } + if (msg.role !== 'assistant') continue; // skip system/tool internals + + if (msg.reasoning_content) renderHistoricalThinking(msg.reasoning_content); + + // Content before tools: the model streams the visible reply first, + // then acts. Putting tools first parked every partial after the log. + if (msg.content) { + const wrapper = renderHistoricalAssistant(msg.content); + if (msg.superseded) foldDraft(wrapper, msg.superseded_reason); + } - const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; - if (toolCalls.length > 0) { - toolCalls.forEach((tc, toolIndex) => { - const name = (tc.function && tc.function.name) || 'tool'; - const args = (tc.function && tc.function.arguments) || ''; - const key=tc.id || 'position-'+toolIndex; - const result = resultsById.get(key) || ''; - if (name === 'delegate_tasks') { - renderHistoricalSubagents(args, result, 'history-'+messageIndex+'-'+toolIndex); + historyToolCalls(messages, messageIndex).forEach((call) => { + if (call.name === 'delegate_tasks') { + renderHistoricalSubagents(call.args, call.result, 'history-' + messageIndex + '-' + call.toolIndex); } else { - renderHistoricalToolBlock(name, args, result, outcomesById.get(key), msg.turn_id || historyTurnId, tc.id || ''); + renderHistoricalToolBlock(call.name, call.args, call.result, call.outcome, msg.turn_id || historyTurnId, call.id); } }); } + return Array.from(historyHost.children); + } finally { + historyHost = null; + historyIdx = null; + } +} + +// historyMessages lists the .msg rows among freshly rendered nodes. +function historyMessages(nodes) { + const out = []; + nodes.forEach((node) => { + if (hasClass(node, 'msg')) out.push(node); + else if (node.querySelectorAll) out.push(...Array.from(node.querySelectorAll('.msg'))); + }); + return out; +} + +// settleHistoryMessages folds long bubbles once the rows are attached (a +// detached bubble has no height). The latest reply stays open. +function settleHistoryMessages(msgs) { + const latest = lastAssistantBubble(); + msgs.forEach((msg) => { + const bubble = msg.querySelector('.bubble'); + if (bubble && bubble !== latest) checkCollapse(bubble, { latest: false }); }); } +export function renderSessionHistory(messages) { + const list = Array.isArray(messages) ? messages : []; + hideEmptyState(); + const start = historyWindowStart(list, list.length); + recordHistoricalResults(list, 0, start); + const nodes = renderHistoryRange(list, start, list.length); + nodes.forEach((node) => messagesEl.appendChild(node)); + settleHistoryMessages(historyMessages(nodes)); + setHistoryBacklog(list, start); +} + +// showEarlierHistory renders the window before the oldest rendered history +// message under the trim marker, keeping the visible rows in place. +export function showEarlierHistory() { + const backlog = trimState().backlog; + if (!backlog || backlog.end <= 0) return; + const end = backlog.end; + const start = historyWindowStart(backlog.messages, end); + historyRecord = false; // already recorded when the session loaded + let nodes; + try { nodes = renderHistoryRange(backlog.messages, start, end); } finally { historyRecord = true; } + const msgs = historyMessages(nodes); + const kids = Array.from(messagesEl.children || []); + const marker = kids.find((el) => hasClass(el, 'transcript-trim')); + const ref = marker ? (kids[kids.indexOf(marker) + 1] || null) : (kids[0] || null); + const before = messagesEl.scrollHeight; + // A window boundary inside a turn splits its stream; rejoin the halves so + // the turn reads as one log, as a full render would draw it. + const tail = nodes[nodes.length - 1]; + if (tail && ref && hasClass(tail, 'turn-stream') && hasClass(ref, 'turn-stream')) { + const first = ref.children[0] || null; + Array.from(tail.children).forEach((row) => ref.insertBefore(row, first)); + nodes.pop(); + } + nodes.forEach((node) => messagesEl.insertBefore(node, ref)); + settleHistoryMessages(msgs); + messagesEl.scrollTop += messagesEl.scrollHeight - before; + backlog.end = start; + syncTrimMarker(); +} +S.showEarlierHistory = showEarlierHistory; + +function renderHistoricalUser(content) { + const wrapper = document.createElement('div'); + wrapper.className = 'msg user'; + wrapper.innerHTML = + '
' + + '
❯ you
' + + '
' + markdownToHtml(content) + '
' + + '
'; + historyTarget().appendChild(historyRow(wrapper)); + const bubble = wrapper.querySelector('.bubble'); + if (bubble) addCopyButton(bubble); +} + function renderHistoricalAssistant(raw) { const [content, unverified] = stripVerifyMarker(raw); const wrapper = document.createElement('div'); @@ -1479,11 +1700,10 @@ function renderHistoricalAssistant(raw) { '
' + markdownToHtml(content) + '
' + ''; attachWakeChip(wrapper); - ensureHistoryStream().appendChild(wrapper); + ensureHistoryStream().appendChild(historyRow(wrapper)); const bubble = wrapper.querySelector('.bubble'); if (bubble) addCopyButton(bubble); if (bubble && unverified) markUnverified(bubble); - compactOlderAnswers(wrapper); return wrapper; } @@ -1497,24 +1717,32 @@ function renderHistoricalThinking(content) { '▶ thinking' + '' + '
'; - ensureHistoryStream().appendChild(block); + ensureHistoryStream().appendChild(historyRow(block)); const host = block.querySelector('.thinking-content'); - String(content || '').split('\n').forEach((line) => { - if (!line) return; + const lines = String(content || '').split('\n').filter(Boolean); + lines.slice(-MAX_THINKING_LINES).forEach((line) => { const row = document.createElement('div'); row.className = 'thinking-line'; row.textContent = line; host.appendChild(row); }); + if (lines.length > MAX_THINKING_LINES) { + const note = document.createElement('div'); + note.className = 'thinking-trim'; + note.dataset.trimmed = String(lines.length - MAX_THINKING_LINES); + note.textContent = '… ' + note.dataset.trimmed + ' earlier lines not shown'; + host.insertBefore(note, host.children[0] || null); + } } function ensureHistoryStream() { - const kids = messagesEl.children || []; + const host = historyTarget(); + const kids = host.children || []; const last = kids.length ? kids[kids.length - 1] : null; if (last && hasClass(last, 'turn-stream')) return last; const stream = document.createElement('div'); stream.className = 'turn-stream'; - messagesEl.appendChild(stream); + host.appendChild(stream); return stream; } @@ -1539,8 +1767,8 @@ function renderHistoricalToolBlock(name, args, result, outcome = 'unknown', turn el.dataset.outcome=outcome;el.dataset.turnId=turnId;el.dataset.callId=callId; const status = el.querySelector('.tb-status'); if (status) { status.textContent = outcome === 'failed' ? '✗' : outcome === 'completed' ? '✓' : '·'; status.title = outcome === 'unknown' ? 'Historical execution outcome unavailable' : 'Tool ' + outcome; status.classList.toggle('err', outcome === 'failed'); } - ensureHistoryStream().appendChild(el); - if (result) appendToolResultContent(el, result); + ensureHistoryStream().appendChild(historyRow(el)); + if (result) appendToolResultContent(el, result, historyRecord); } // renderHistoricalSubagents renders a completed delegate_tasks group with @@ -1553,7 +1781,7 @@ function renderHistoricalSubagents(args, output, groupId) { const group = document.createElement('div'); group.className = 'subagent-group'; group.innerHTML = subagentHeadHTML(tasks.length) + '
'; - ensureHistoryStream().appendChild(group); + ensureHistoryStream().appendChild(historyRow(group)); const grid = group.querySelector('.subagent-grid'); tasks.forEach((task, i) => { @@ -1630,6 +1858,10 @@ function checkCollapse(host, opts) { } const content = bubble.querySelector('.content'); if (!content) return; + // Older bubbles no longer change, so each is measured once: re-reading + // scrollHeight across the whole transcript forced a layout per bubble + // every time a reply opened. + if (bubble.dataset) bubble.dataset.collapseChecked = '1'; const existing = bubble.querySelector('.collapse-toggle'); if (content.scrollHeight <= COLLAPSE_MAX_HEIGHT_PX) { releaseCollapse(bubble); @@ -1655,7 +1887,7 @@ export function compactOlderAnswers(keep) { assistantMessages().forEach((msg) => { if (keep && (msg === keep || nodeHolds(msg, keep))) return; const bubble = msg.querySelector('.bubble'); - if (bubble) checkCollapse(bubble, { latest: false }); + if (bubble && !(bubble.dataset && bubble.dataset.collapseChecked === '1')) checkCollapse(bubble, { latest: false }); }); } diff --git a/cmd/odek/ui/js/sessions.js b/cmd/odek/ui/js/sessions.js index ce201c9c..e2a272e4 100644 --- a/cmd/odek/ui/js/sessions.js +++ b/cmd/odek/ui/js/sessions.js @@ -7,6 +7,7 @@ import { listSessions, getSession, renameSession as renameSessionAPI, deleteSess import { messagesEl, promptEl, sendBtn, sessionListEl, sidebarSearch, cancelBtn } from './dom.js'; import { escapeHtml, escapeAttr, relativeTime, formatNum, showToast, forceScrollBottom, hideCancel, announce, openDialog, closeDialog, isDialogOpen } from './utils.js'; import { resetTurnState, hideLoading, renderSessionHistory } from './render.js'; +import { resetTranscriptTrim } from './transcript.js'; import { clearApprovals } from './approvals.js'; import { clearClarify } from './clarify.js'; import { metricsFromSession, resetMetrics } from './metrics.js'; @@ -268,6 +269,7 @@ export function newSession() { // Clear messages and restore empty state. messagesEl.innerHTML = ''; + resetTranscriptTrim(); if (S.savedEmptyStateNode) messagesEl.appendChild(S.savedEmptyStateNode); sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active')); @@ -339,6 +341,7 @@ export async function loadAndRenderSession(sid) { promptEl.disabled = false; messagesEl.innerHTML = ''; + resetTranscriptTrim(); const messages = sess.messages || []; // The full transcript renders now (tool calls, thinking, sub-agents); @@ -363,6 +366,13 @@ export async function loadAndRenderSession(sid) { } } +// The transcript-trim marker's reload: rows trimmed from a live turn exist +// only in the persisted session. Never while a turn runs. +S.reloadSession = () => { + if (S.busy || !S.sessionId) return; + loadAndRenderSession(S.sessionId); +}; + // ── Session Rename ── // Inline edit: swaps the task label for an input; Enter commits, Esc // cancels, blur commits. No window.prompt. diff --git a/cmd/odek/ui/js/transcript.js b/cmd/odek/ui/js/transcript.js new file mode 100644 index 00000000..5d791566 --- /dev/null +++ b/cmd/odek/ui/js/transcript.js @@ -0,0 +1,207 @@ +// Transcript DOM budget. A long session must not grow #messages without +// bound: style recalculation, layout and every querySelectorAll over the +// transcript scale with its size. The budget counts rendered units — each +// top-level row, and each row inside a turn stream (a single turn can hold +// hundreds of tool steps) — and trims the oldest ones. Live work is never +// trimmed: the streaming answer, the open reasoning block, the running +// sub-agent group and tool steps still waiting for their result. +// +// Trimmed rows are not lost: a marker at the top of the transcript offers +// them back. Rows rendered from a loaded session carry their message index +// (data-hidx) so the history backlog re-renders exactly what was trimmed; +// rows trimmed from a live turn come back by reloading the persisted session. +import { S } from './state.js'; +import { messagesEl } from './dom.js'; + +export const MAX_UNITS = 300; // units kept while the view follows the bottom +export const HARD_UNITS = 600; // ceiling while the operator reads further up +export const HISTORY_UNITS = 150; // units a session load or "Show earlier" renders + +const TOP_UNITS = ['msg', 'turn-stream', 'tool-block', 'subagent-group', 'thinking-block', 'msg-divider']; + +function hasClass(el, name) { + if (!el) return false; + if (el.classList && el.classList.contains) return el.classList.contains(name); + return (' ' + (el.className || '') + ' ').indexOf(' ' + name + ' ') !== -1; +} + +function kids(el) { + return el && el.children ? Array.from(el.children) : []; +} + +function holds(parent, node) { + if (!parent || !node) return false; + if (parent === node) return true; + if (parent.contains) return parent.contains(node); + for (let el = node; el; el = el.parentNode) if (el === parent) return true; + return false; +} + +function isStream(el) { return hasClass(el, 'turn-stream'); } +function isTopUnit(el) { return TOP_UNITS.some((c) => hasClass(el, c)); } + +// isLive reports a unit that still receives frames for the running turn. +function isLive(el) { + if (el.dataset && el.dataset.outcome === 'running') return true; + if (hasClass(el, 'live')) return true; + const refs = [S.streamBubbleEl, S.thinkingContentEl, S.subagentGroup, S.loadingEl]; + return refs.some((ref) => holds(el, ref)); +} + +export function countUnits() { + let n = 0; + for (const el of kids(messagesEl)) { + if (isStream(el)) n += kids(el).length; + else if (isTopUnit(el)) n++; + } + return n; +} + +export function trimState() { + S.transcriptTrim ||= { live: 0, backlog: null }; + return S.transcriptTrim; +} + +// resetTranscriptTrim forgets trimmed rows and the history backlog. Called +// whenever the transcript is cleared (new session, session load). +export function resetTranscriptTrim() { + S.transcriptTrim = { live: 0, backlog: null }; + const marker = findMarker(); + if (marker) marker.remove(); +} + +// setHistoryBacklog records the loaded session's messages and the index +// where the rendered window starts; messages before it are offered back. +export function setHistoryBacklog(messages, end) { + trimState().backlog = end > 0 ? { messages, end } : null; + syncTrimMarker(); +} + +// firstHistoryIndex returns the message index of the first rendered row +// that came from session history, or -1. +function firstHistoryIndex() { + for (const el of kids(messagesEl)) { + const rows = isStream(el) ? kids(el) : [el]; + for (const row of rows) { + if (row.dataset && row.dataset.hidx != null && row.dataset.hidx !== '') return Number(row.dataset.hidx); + } + } + return -1; +} + +// pruneMessages trims the oldest units once the budget is exceeded. While +// the operator reads up the trim waits for the hard ceiling, and the view +// is held in place so rows vanishing above it do not move what is read. +export function pruneMessages() { + if (!messagesEl) return; + let units = countUnits(); + if (units <= MAX_UNITS) return; + const reading = !S.stickBottom; + if (reading && units <= HARD_UNITS) return; + + const before = messagesEl.scrollHeight; + const state = trimState(); + let lastHidx = null; + let removedHistory = false; + // A loaded message renders as several rows (reply, reasoning, tool steps); + // they leave together so a re-render from the backlog never duplicates or + // drops part of a message. + const drop = (row) => { + const hidx = row.dataset ? row.dataset.hidx : undefined; + const sameMessage = lastHidx != null && hidx === lastHidx; + if (units <= MAX_UNITS && !sameMessage) return false; + if (isLive(row)) return false; + row.remove(); + units--; + if (hidx != null && hidx !== '') { lastHidx = hidx; removedHistory = true; } + else { lastHidx = null; state.live++; } + return true; + }; + + for (const el of kids(messagesEl)) { + if (units <= MAX_UNITS && lastHidx == null) break; + if (isStream(el)) { + for (const row of kids(el)) { + if (!drop(row) && units <= MAX_UNITS) break; + } + if (!kids(el).length && el !== S.turnStreamEl) el.remove(); + } else if (isTopUnit(el)) { + if (!drop(el) && units <= MAX_UNITS) break; + } + } + + if (removedHistory && state.backlog) { + const first = firstHistoryIndex(); + state.backlog.end = first >= 0 ? first : state.backlog.messages.length; + } + if (reading) messagesEl.scrollTop -= before - messagesEl.scrollHeight; + syncTrimMarker(); +} + +// earlierTurns counts the operator turns before the rendered window (the +// persisted list also holds tool results and system records, which would +// inflate a raw message count). Cached per window position. +function earlierTurns(backlog) { + if (backlog.turnsEnd !== backlog.end) { + let n = 0; + for (let i = 0; i < backlog.end; i++) if (backlog.messages[i] && backlog.messages[i].role === 'user') n++; + backlog.turns = n; + backlog.turnsEnd = backlog.end; + } + return backlog.turns; +} + +function findMarker() { + return kids(messagesEl).find((el) => hasClass(el, 'transcript-trim')) || null; +} + +// syncTrimMarker paints the top-of-transcript marker. Rows trimmed from a +// live turn exist only in the persisted session, so they come back through +// a reload, which waits for the running turn to finish. +export function syncTrimMarker() { + if (!messagesEl) return; + const state = trimState(); + const backlog = state.backlog && state.backlog.end > 0 ? state.backlog : null; + let marker = findMarker(); + if (!state.live && !backlog) { + if (marker) marker.remove(); + return; + } + if (!marker) { + marker = document.createElement('div'); + marker.className = 'transcript-trim'; + marker.setAttribute('role', 'note'); + const text = document.createElement('span'); + text.className = 'tt-text'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'tt-more'; + marker.appendChild(text); + marker.appendChild(btn); + } + const first = kids(messagesEl)[0]; + if (first !== marker) messagesEl.insertBefore(marker, first || null); + const text = marker.querySelector('.tt-text'); + const btn = marker.querySelector('.tt-more'); + if (state.live) { + text.textContent = 'Earlier activity is hidden to keep the page responsive.'; + btn.textContent = S.busy ? 'Reload full history after this turn' : 'Reload full history'; + btn.disabled = !!S.busy || !S.sessionId; + btn.dataset.action = 'reload'; + } else { + const turns = earlierTurns(backlog); + text.textContent = turns + ? turns + (turns === 1 ? ' earlier turn is' : ' earlier turns are') + ' not shown.' + : 'Earlier activity is not shown.'; + btn.textContent = 'Show earlier'; + btn.disabled = false; + btn.dataset.action = 'earlier'; + } +} + +messagesEl?.addEventListener?.('click', (e) => { + const btn = e.target && e.target.closest ? e.target.closest('.tt-more') : null; + if (!btn || btn.disabled) return; + if (btn.dataset.action === 'reload') S.reloadSession?.(); + else S.showEarlierHistory?.(); +}); diff --git a/cmd/odek/ui/js/transcript.test.js b/cmd/odek/ui/js/transcript.test.js new file mode 100644 index 00000000..1666782e --- /dev/null +++ b/cmd/odek/ui/js/transcript.test.js @@ -0,0 +1,414 @@ +// Transcript DOM budget: long sessions keep #messages bounded. Covers the +// unit-based trim (rows inside a long turn count), live-work protection, +// the reading-up deferral, windowed session-history load with +// "Show earlier", lazy tool-result bodies, one-time collapse measurement, +// and the reasoning / sub-agent log row caps. Run: +// node --test cmd/odek/ui/js/ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +class ClassList { + constructor(el) { this.el = el; } + get set() { return new Set((this.el.className || '').split(/\s+/).filter(Boolean)); } + add(...cs) { const s = this.set; cs.forEach(c => s.add(c)); this.el.className = [...s].join(' '); } + remove(...cs) { const s = this.set; cs.forEach(c => s.delete(c)); this.el.className = [...s].join(' '); } + contains(c) { return this.set.has(c); } + toggle(c, force) { + const want = force === undefined ? !this.contains(c) : !!force; + if (want) this.add(c); else this.remove(c); + return want; + } +} + +class FakeEl { + constructor(tag = 'div') { + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentNode = null; + this.className = ''; + this.classList = new ClassList(this); + this.dataset = {}; + this.textContent = ''; + this.title = ''; + this.disabled = false; + this.hidden = false; + this.scrollTop = 0; + this.clientHeight = 50; + this._scrollHeight = 100; + this._listeners = {}; + this._attrs = {}; + this._innerHTML = ''; + this.style = {}; + const shim = this; + Object.defineProperty(this, 'innerHTML', { + get() { return shim._innerHTML; }, + set(html) { + shim._innerHTML = html; + shim.children = []; + const tokens = html.match(/<\/?[a-z-]+[^>]*>|[^<]+/g) || []; + const stack = [shim]; + for (const tok of tokens) { + const parent = stack[stack.length - 1]; + if (tok.startsWith(' 1) stack.pop(); continue; } + if (tok.startsWith('<')) { + const el = new FakeEl((tok.match(/^<([a-z-]+)/) || [null, 'div'])[1]); + const cls = tok.match(/class="([^"]*)"/); + if (cls) el.className = cls[1]; + const idm = tok.match(/id="([^"]*)"/); + if (idm) el.id = idm[1]; + parent.appendChild(el); + if (!/^<(br|input|img)\b/.test(tok)) stack.push(el); + continue; + } + parent.textContent += tok; + } + }, + }); + } + get scrollHeight() { return this._scrollHeight; } + set scrollHeight(v) { this._scrollHeight = v; } + get id() { return this._id || ''; } + set id(v) { this._id = v; } + get isConnected() { return true; } + setAttribute(k, v) { this._attrs[k] = String(v); } + getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; } + removeAttribute(k) { delete this._attrs[k]; } + matches(sel) { + if (sel.startsWith('.')) return sel.slice(1).split('.').every((c) => c && this.classList.contains(c)); + if (sel.startsWith('#')) return this.id === sel.slice(1); + if (sel.startsWith('[')) return false; + return this.tagName === sel.toUpperCase(); + } + closest(sel) { + for (let el = this; el; el = el.parentNode) if (el.matches && el.matches(sel)) return el; + return null; + } + querySelector(sel) { return this.querySelectorAll(sel)[0] || null; } + querySelectorAll(sel) { + const out = []; + const parts = sel.split(',').map(s => s.trim()); + const walk = (el) => { + for (const c of el.children) { + if (parts.some(p => c.matches(p))) out.push(c); + walk(c); + } + }; + walk(this); + return out; + } + get lastElementChild() { return this.children[this.children.length - 1] || null; } + appendChild(c) { + if (c.parentNode) c.remove(); + c.parentNode = this; + this.children.push(c); + return c; + } + get parentElement() { return this.parentNode; } + append(...cs) { cs.forEach(c => this.appendChild(c)); } + insertAdjacentElement(_pos, node) { return this.appendChild(node); } + insertBefore(c, ref) { + if (c.parentNode) c.remove(); + c.parentNode = this; + const i = ref ? this.children.indexOf(ref) : -1; + if (i >= 0) this.children.splice(i, 0, c); else this.children.push(c); + return c; + } + remove() { + if (!this.parentNode) return; + const i = this.parentNode.children.indexOf(this); + if (i >= 0) this.parentNode.children.splice(i, 1); + this.parentNode = null; + } + addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); } + dispatch(type, ev = {}) { + for (let el = this; el; el = el.parentNode) { + for (const fn of [...(el._listeners[type] || [])]) { + fn({ target: this, preventDefault() {}, stopPropagation() {}, ...ev }); + } + } + } + focus() {} + click() { if (!this.disabled) this.dispatch('click'); } +} + +const byId = {}; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +globalThis.sessionStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +globalThis.document = { + getElementById: (id) => (byId[id] ||= Object.assign(new FakeEl('div'), { id })), + createElement: (tag) => new FakeEl(tag), + querySelector: () => new FakeEl('div'), + querySelectorAll: () => [], + addEventListener: () => {}, + body: new FakeEl('body'), + activeElement: null, + hidden: false, +}; +globalThis.location = { protocol: 'http:', host: 'test.local' }; +globalThis.window = globalThis; +globalThis.WebSocket = class { static get OPEN() { return 1; } }; +globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +globalThis.cancelAnimationFrame = (t) => clearTimeout(t); + +const S = (await import('./state.js')).S; +const render = await import('./render.js'); +const transcript = await import('./transcript.js'); +const messages = byId.messages; + +// Height follows the rendered row count so scroll compensation is observable. +Object.defineProperty(messages, 'scrollHeight', { get: () => transcript.countUnits() * 10 }); + +function rows() { + const out = []; + for (const el of messages.children) { + if (el.classList.contains('turn-stream')) out.push(...el.children); + else if (!el.classList.contains('transcript-trim')) out.push(el); + } + return out; +} +function textOf(el) { + return [el.textContent, ...el.children.map(textOf)].join(' '); +} +function marker() { return messages.querySelector('.transcript-trim'); } +function finishedTool(i) { + render.addToolCall('shell', JSON.stringify({ command: 'echo ' + i }), 'c' + i); + render.addToolResult('shell', 'out ' + i, 'c' + i, 'completed'); +} +// A session of `turns` turns, each: user prompt → assistant reply with one +// tool call → tool result → final reply. Five rows per turn. +function session(turns) { + const list = [{ role: 'system', content: '' }]; + for (let t = 0; t < turns; t++) { + list.push({ role: 'user', content: 'q' + t, turn_id: 't' + t }); + list.push({ role: 'assistant', content: 'looking ' + t, reasoning_content: 'think ' + t, tool_calls: [{ id: 'k' + t, function: { name: 'shell', arguments: '{}' } }], turn_id: 't' + t }); + list.push({ role: 'tool', tool_call_id: 'k' + t, content: 'result ' + t, tool_outcome: 'completed' }); + list.push({ role: 'assistant', content: 'answer ' + t, turn_id: 't' + t }); + } + return list; +} +function hidxOrder() { + const seen = []; + rows().forEach((r) => { const h = Number(r.dataset.hidx); if (seen[seen.length - 1] !== h) seen.push(h); }); + return seen; +} + +beforeEach(() => { + messages.children.length = 0; + messages.scrollTop = 0; + render.resetTurnState(); + transcript.resetTranscriptTrim(); + S.subagentGroup = null; + S.currentToolBlock = null; + S.currentTurnId = null; + S.loadingEl = null; + S.busy = false; + S.stickBottom = true; + S.sessionId = 'sess-1'; + S.recordResult = undefined; +}); + +test('rows inside one long turn count against the budget and the oldest are trimmed', () => { + for (let i = 0; i < transcript.MAX_UNITS + 50; i++) finishedTool(i); + assert.equal(messages.children.filter(c => c.classList.contains('turn-stream')).length, 1, 'one turn stream'); + assert.equal(transcript.countUnits(), transcript.MAX_UNITS, 'trimmed to the budget'); + const first = rows()[0]; + assert.equal(first.dataset.callId, 'c50', 'the oldest steps left first'); + assert.ok(marker(), 'a trim marker tells the operator rows are hidden'); + assert.equal(marker().querySelector('.tt-more').dataset.action, 'reload'); +}); + +test('live work is never trimmed: pending tools and the streaming answer survive', () => { + render.addToolCall('shell', '{"command":"slow"}', 'pending'); + for (let i = 0; i < transcript.MAX_UNITS + 20; i++) finishedTool(i); + const pending = rows().find(r => r.dataset.callId === 'pending'); + assert.ok(pending, 'the step still waiting for its result stays'); + assert.equal(S.toolBlockQueues.get('pending')[0], pending); + render.addToolResult('shell', 'finally', 'pending', 'completed'); + assert.equal(pending.dataset.outcome, 'completed', 'its late result still lands'); +}); + +test('while the operator reads up the trim waits for the hard ceiling and holds the view', () => { + S.stickBottom = false; + for (let i = 0; i < transcript.MAX_UNITS + 40; i++) finishedTool(i); + assert.equal(transcript.countUnits(), transcript.MAX_UNITS + 40, 'nothing trimmed below the hard ceiling'); + assert.equal(marker(), null); + messages.scrollTop = 1000; + for (let i = transcript.countUnits(); i <= transcript.HARD_UNITS; i++) finishedTool('x' + i); + assert.equal(transcript.countUnits(), transcript.MAX_UNITS, 'the hard ceiling trims back to the budget'); + assert.ok(messages.scrollTop < 1000, 'scroll position compensates for rows removed above the view'); +}); + +test('marker reload waits for the running turn', () => { + S.busy = true; + for (let i = 0; i < transcript.MAX_UNITS + 5; i++) finishedTool(i); + const btn = marker().querySelector('.tt-more'); + assert.equal(btn.disabled, true, 'no reload mid-turn'); + let reloads = 0; + S.reloadSession = () => { reloads++; }; + S.busy = false; + render.endStream('completed'); + assert.equal(btn.disabled, false, 'turn end re-enables reload'); + btn.click(); + assert.equal(reloads, 1); +}); + +test('historyWindowStart snaps to a turn start and falls back inside one oversized turn', () => { + const list = session(100); + const start = render.historyWindowStart(list, list.length, 20); + assert.equal(list[start].role, 'user', 'window opens on a turn'); + const units = list.slice(start).reduce((n, m) => n + render.historyUnits(m), 0); + assert.ok(units <= 20 && units >= 10, 'window within budget: ' + units); + + const huge = [{ role: 'user', content: 'go' }, ...Array.from({ length: 50 }, (_, i) => ({ role: 'assistant', tool_calls: [{ id: 'h' + i, function: { name: 'shell', arguments: '{}' } }] }))]; + const s2 = render.historyWindowStart(huge, huge.length, 10); + assert.equal(huge.length - s2, 10, 'a turn larger than the budget renders its newest messages'); + assert.equal(render.historyWindowStart(huge.slice(0, 3), 3, 10), 0, 'small sessions render whole'); +}); + +test('a long session loads only its newest window; Show earlier restores the rest in order', () => { + const list = session(120); // 600 rows + render.renderSessionHistory(list); + assert.ok(transcript.countUnits() <= transcript.HISTORY_UNITS, 'initial render bounded: ' + transcript.countUnits()); + const m = marker(); + assert.ok(m, 'marker offers the earlier messages'); + assert.equal(m.querySelector('.tt-more').dataset.action, 'earlier'); + assert.equal(textOf(rows()[rows().length - 1]).includes('answer 119'), true, 'newest message rendered'); + + let guard = 0; + while (marker() && guard++ < 20) marker().querySelector('.tt-more').click(); + assert.equal(marker(), null, 'marker gone once everything is shown'); + const order = hidxOrder(); + const expected = list.map((msg, i) => (render.historyUnits(msg) ? i : -1)).filter(i => i >= 0); + assert.deepEqual(order, expected, 'every message rendered once, in transcript order'); + assert.equal(messages.children.filter(c => c.classList.contains('turn-stream')).length, 120, 'split turn streams rejoined'); +}); + +test('Show earlier keeps the visible rows in place', () => { + render.renderSessionHistory(session(60)); + messages.scrollTop = 5; + const before = transcript.countUnits(); + render.showEarlierHistory(); + const added = transcript.countUnits() - before; + assert.ok(added > 0); + assert.equal(messages.scrollTop, 5 + added * 10, 'scroll offset grows by the inserted height'); +}); + +test('history rows trimmed by the budget return to the backlog as whole messages', () => { + render.renderSessionHistory(session(40)); // 200 rows, window of ~150 + for (let i = 0; i < transcript.MAX_UNITS; i++) finishedTool(i); + const state = transcript.trimState(); + assert.equal(state.backlog.end, state.backlog.messages.length, 'every history row was trimmed back to the backlog'); + assert.equal(rows().some(r => r.dataset.hidx), false); + + messages.children.length = 0; + transcript.resetTranscriptTrim(); + const list = session(40); + render.renderSessionHistory(list); + for (let i = 0; i < 160; i++) finishedTool(i); + const first = rows().find(r => r.dataset.hidx); + const end = transcript.trimState().backlog.end; + assert.equal(Number(first.dataset.hidx), end, 'backlog resumes at the first remaining message'); + const same = rows().filter(r => r.dataset.hidx === first.dataset.hidx); + const expectedRows = render.historyUnits(list[end]); + assert.equal(same.length, expectedRows, 'no message is left half-rendered'); +}); + +test('tool result bodies render on first expand, once', () => { + render.addToolCall('shell', '{"command":"ls"}', 'lazy'); + const block = S.currentToolBlock; + const inspect = document.createElement('button'); + inspect.className = 'result-inspect'; + S.recordResult = (b) => b.querySelector('.tb-body').appendChild(inspect); + render.addToolResult('shell', 'a\nb\nc', 'lazy', 'completed'); + assert.equal(block.querySelector('.tb-result'), null, 'collapsed result has no rendered body'); + const header = block.querySelector('.tb-header'); + header.click(); + const body = block.querySelector('.tb-body'); + assert.ok(body.classList.contains('open')); + const results = block.querySelectorAll('.tb-result'); + assert.equal(results.length, 1, 'rendered on expand'); + assert.ok(body.children.indexOf(results[0]) < body.children.indexOf(inspect), 'result sits before the inspect action'); + header.click(); header.click(); + assert.equal(block.querySelectorAll('.tb-result').length, 1, 'never rendered twice'); +}); + +test('older answers are measured for folding once, not on every new reply', () => { + let reads = 0; + render.addMessage('assistant', 'first'); + const content = messages.querySelector('.msg.assistant').querySelector('.content'); + Object.defineProperty(content, 'scrollHeight', { get: () => { reads++; return 10; } }); + for (let i = 0; i < 5; i++) render.addMessage('assistant', 'reply ' + i); + assert.equal(reads, 1, 'scrollHeight read once for the older bubble'); +}); + +test('a reasoning block keeps its newest rows behind a trim note', () => { + S.currentTurnId = 't-think'; + const total = render.MAX_THINKING_LINES + 25; + for (let i = 0; i < total; i++) render.streamThinking((i ? '\n' : '') + 'line ' + i); + const host = messages.querySelector('.thinking-content'); + const lines = host.querySelectorAll('.thinking-line'); + assert.equal(lines.length, render.MAX_THINKING_LINES); + assert.equal(lines[lines.length - 1].textContent, 'line ' + (total - 1)); + assert.match(host.children[0].textContent, /25 earlier lines not shown/); + + messages.children.length = 0; + render.renderSessionHistory([{ role: 'assistant', reasoning_content: Array.from({ length: total }, (_, i) => 'h' + i).join('\n') }]); + const hist = messages.querySelector('.thinking-content'); + assert.equal(hist.querySelectorAll('.thinking-line').length, render.MAX_THINKING_LINES); + assert.match(hist.children[0].textContent, /25 earlier lines not shown/); +}); + +test('a sub-agent chip log keeps only its recent lines', () => { + render.addSubagentGroup(JSON.stringify({ tasks: [{ goal: 'g' }] })); + for (let i = 0; i < render.MAX_SUBAGENT_LOG_LINES + 30; i++) render.appendSubagentLog(0, { event: 'tool_call', name: 'shell', data: 'n' + i }); + const log = messages.querySelector('.sa-log'); + assert.equal(log.children.length, render.MAX_SUBAGENT_LOG_LINES); + assert.match(log.children[log.children.length - 1].textContent, /n229/); +}); + +test('results outside the loaded window still reach the Activity panel in order; Show earlier does not re-record', () => { + const recorded = []; + S.recordResult = (block, output) => recorded.push({ output, attached: !!block.parentNode, outcome: block.dataset.outcome }); + const list = session(60); + render.renderSessionHistory(list); + assert.equal(recorded.length, 60, 'every historical result recorded once'); + assert.deepEqual(recorded.map(r => r.output), list.filter(m => m.role === 'tool').map(m => m.content), 'transcript order'); + assert.ok(recorded.slice(0, 10).every(r => !r.attached && r.outcome === 'completed'), 'pre-window results use detached stubs'); + render.showEarlierHistory(); + assert.equal(recorded.length, 60, 'earlier windows are not recorded again'); +}); + +test('Activity results repaint once per frame and keep a bounded byte total', async () => { + await import('./workspace.js'); + const list = byId['output-list']; + let paints = 0; + const origAppend = list.appendChild.bind(list); + list.appendChild = (c) => { if (c.classList.contains('output-item')) paints++; return origAppend(c); }; + S.clearResults(); + await new Promise((r) => setTimeout(r, 5)); + paints = 0; + for (let i = 0; i < 5; i++) { + const block = document.createElement('div'); + block.dataset.toolName = 'shell'; + block.dataset.toolArgs = '{}'; + S.recordResult(block, 'output ' + i); + } + assert.equal(paints, 0, 'no synchronous repaint per result'); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(paints, 5, 'one coalesced repaint lists every result'); + S.clearResults(); +}); + +test('the trim marker reload is refused mid-turn and reloads the session when idle', async () => { + await import('./sessions.js'); + const fetched = []; + globalThis.fetch = async (url) => { fetched.push(String(url)); return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ messages: [] }), text: async () => '' }; }; + S.busy = true; + S.reloadSession(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(fetched.length, 0, 'no reload while a turn runs'); + S.busy = false; + S.reloadSession(); + await new Promise((r) => setTimeout(r, 5)); + assert.ok(fetched.some((u) => u.includes('sess-1')), 'idle reload fetches the persisted session: ' + fetched.join(',')); +}); diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index 4bf7e42a..c86fdf1c 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -152,17 +152,6 @@ export function scrollToBottom() { if (scrollBottomBtn) scrollBottomBtn.classList.remove('visible'); } -// ── Message cap ── -const MAX_MESSAGES = 80; -export function pruneMessages() { - const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .turn-stream, :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(); - } - } -} - // ── Error message normalization ── export function formatErrorMessage(msg) { if (!msg) return 'Unknown error'; diff --git a/cmd/odek/ui/js/workspace.js b/cmd/odek/ui/js/workspace.js index 5cdefac2..070c80cf 100644 --- a/cmd/odek/ui/js/workspace.js +++ b/cmd/odek/ui/js/workspace.js @@ -10,8 +10,9 @@ function textNode(tag, cls, text) { const el = document.createElement(tag); el.className = cls; el.textContent = text; return el; } const outputs = [];S.activityPruned=false; +let outputBytes = 0; export function clearResults() { - outputs.length = 0;S.activityPruned=false; + outputs.length = 0;outputBytes = 0;S.activityPruned=false; const budget=byId("budget-view");if(budget){budget.textContent="Budget usage appears after the first iteration.";budget.classList.remove("budget-exhausted");} S.resetArtifacts?.(); S.resetSupervision?.();S.clearDecisionReceipts?.(); @@ -19,6 +20,14 @@ export function clearResults() { const list=byId('output-list');if(list)list.hidden=false; paintResults(); } +// A burst of results (a parallel tool batch, a session load) repaints the +// list once per frame instead of rebuilding up to 300 rows per result. +let paintRAF = null; +function schedulePaintResults() { + if (paintRAF != null) return; + const run = () => { paintRAF = null; paintResults(); }; + paintRAF = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 0); +} function paintResults() { const list = byId('output-list'); if (!list) return; @@ -30,7 +39,7 @@ function paintResults() { if(query && !(item.name+' '+item.preview+' '+item.outcome).toLowerCase().includes(query))continue; const button = textNode('button', 'output-item', ''); button.type = 'button'; button.dataset.outcome=item.outcome; - button.append(textNode('span','output-outcome',item.outcome==='completed'?'Returned':item.outcome==='failed'?'Failed':'Unknown'),textNode('span', 'output-kind', resultKind(item.name, item.output)), textNode('strong', 'output-name', item.name), textNode('span', 'output-path', item.preview)); + button.append(textNode('span','output-outcome',item.outcome==='completed'?'Returned':item.outcome==='failed'?'Failed':'Unknown'),textNode('span', 'output-kind', item.kind ||= resultKind(item.name, item.output)), textNode('strong', 'output-name', item.name), textNode('span', 'output-path', item.preview)); button.addEventListener('click', () => inspect(item)); list.appendChild(button); } paintEvidence(); @@ -59,13 +68,13 @@ function openActivityDetails() { S.openActivityDetails = openActivityDetails; S.recordResult = (block, output) => { - const item = { block, output, outcome:block.dataset.outcome || 'unknown', turnId:block.dataset.turnId || '', callId:block.dataset.callId || '', name: block.dataset.toolName || 'tool', args: block.dataset.toolArgs || '', preview: toolPreview(block.dataset.toolName || '', block.dataset.toolArgs || '') }; + const item = { block, output: String(output || ''), outcome:block.dataset.outcome || 'unknown', turnId:block.dataset.turnId || '', callId:block.dataset.callId || '', name: block.dataset.toolName || 'tool', args: block.dataset.toolArgs || '', preview: toolPreview(block.dataset.toolName || '', block.dataset.toolArgs || '') }; item.stepId=(S.plan?.steps || []).find(step=>step.status==='in_progress')?.id || ''; - outputs.push(item); + outputs.push(item);outputBytes+=item.output.length; let pruned=false; - while(outputs.length>300 || outputs.reduce((n,result)=>n+result.output.length,0)>16*1024*1024){outputs.shift();pruned=true;} + while(outputs.length>300 || outputBytes>16*1024*1024){outputBytes-=outputs.shift().output.length;pruned=true;} if(pruned)S.activityPruned=true; - paintResults(); + schedulePaintResults(); const body = block.querySelector('.tb-body'); if (body) { const button = textNode('button', 'result-inspect', 'Inspect result ↗'); button.type = 'button'; button.addEventListener('click', () => inspect(item)); body.appendChild(button); } }; diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index 305a5429..1c35cca1 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -1248,6 +1248,11 @@ body.workspace-wide #session-rail { box-shadow: none; animation: none; } .turn-receipt { margin-left: 8px; font-size: var(--fs-2xs); font-weight: 400; color: var(--text-3); } .msg-divider { display: flex; align-items: center; gap: 12px; margin: var(--space-6) 0 var(--space-4); font-size: var(--fs-2xs); font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--text-3); user-select: none; } .msg-divider::before, .msg-divider::after { content: ''; flex: 1; height: 1px; background: var(--line); } +.transcript-trim { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px 12px; margin: 0 0 var(--space-5); font-size: var(--fs-2xs); color: var(--text-3); } +.tt-text { text-align: center; } +.tt-more { padding: 2px 10px; border: 1px solid var(--line); border-radius: var(--r-sm); background: transparent; color: var(--amber); font: inherit; font-weight: 600; cursor: pointer; } +.tt-more:hover:not(:disabled) { color: var(--amber-hi); } +.tt-more:disabled { color: var(--text-3); cursor: default; } .wake-turn .bubble { border-left: 0; } .wake-chip { display: none; } @@ -1314,6 +1319,7 @@ body.workspace-wide #session-rail { box-shadow: none; animation: none; } .thinking-content.open { display: block; animation: fade-slide var(--dur) var(--ease) both; } .thinking-line { display: block; margin: 0 0 .5em; } .thinking-line:last-child { margin-bottom: 0; } +.thinking-trim { display: block; margin: 0 0 .5em; color: var(--text-3); font-style: italic; } .stream-cursor { display: inline-block; diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 0a9523e8..5c400a88 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -297,7 +297,7 @@ The dropdown fetches from `GET /api/resources?q=&limit=8`. Results includ - **Rename**: ✎ inline-edits the session label - **Export**: ⇩ downloads the transcript — markdown by default, JSON with `Shift` held (`GET /api/sessions/{id}/export`) - **Delete**: ✕ with a confirmation dialog -- **Switching**: clicking a session renders the full transcript (tool calls, reasoning, sub-agents) and sends a `session_switch` message so the connection's agent adopts the session (memory-buffer restore) before you type +- **Switching**: clicking a session renders its newest window of the transcript (tool calls, reasoning, sub-agents; **Show earlier** brings back older turns) and sends a `session_switch` message so the connection's agent adopts the session (memory-buffer restore) before you type - **Session data**: stored in `~/.odek/sessions/` as JSON files, same format used by `odek session` ## REST endpoints @@ -984,12 +984,13 @@ match as plain text. The bundled WebUI implements this in ### Frontend (`cmd/odek/ui/`) -- Vanilla JS + CSS SPA split into native ES modules under `js/` — no build step, no bundler, no CDN. Module map: `main` (init/theme/keyboard) · `commands` (⌘K palette + 5 composer slash verbs) · `tools` (typed result chips) · `ws` (protocol v2 + `turn_started`/`bg_job`) · `api` (typed REST client) · `sessions` · `panels` (inspector workspaces: sessions / now / results / memory / ops / manage) · `plan` · `health` (heartbeat + notifications) · `render`/`markdown`/`untrusted` · `approvals` · `input` (send, queue, `@`, attachments) · `state`/`dom`/`utils`/`net`/`escape` +- Vanilla JS + CSS SPA split into native ES modules under `js/` — no build step, no bundler, no CDN. Module map: `main` (init/theme/keyboard) · `commands` (⌘K palette + 5 composer slash verbs) · `tools` (typed result chips) · `ws` (protocol v2 + `turn_started`/`bg_job`) · `api` (typed REST client) · `sessions` · `panels` (inspector workspaces: sessions / now / results / memory / ops / manage) · `plan` · `health` (heartbeat + notifications) · `render`/`markdown`/`untrusted` · `transcript` (DOM budget, history windows) · `approvals` · `input` (send, queue, `@`, attachments) · `state`/`dom`/`utils`/`net`/`escape` - **Escaping**: all server-controlled strings are inserted escaped (`escapeHtml`/`escapeAttr`/`textContent`); `markdownToHtml` HTML-escapes all input by default and allowlists link schemes — see "Content sanitization contract" above. No inline scripts or handlers anywhere (CSP `script-src 'self'`); generated content uses event delegation - **Untrusted envelope**: `js/untrusted.js` unwraps the model-facing `` envelope before display (body shown; source discarded) - **Design**: self-contained themes — Match system (the default: Ember dark or Porcelain light, following the OS colour scheme live), Ember, Porcelain, Midnight and High contrast. Locally bundled Geist serves reading/interface text, Space Grotesk the display headline and Geist Mono code (ligatures off, 4-space tabs). Reading text defaults to 15px (17/19px selectable); compact density tightens the interface scale. One header row on every width (52px, 44px in compact density); below 1100px the session rail and inspector are drawers with a scrim that closes them, from 1100px they dock as columns and the inspector is resizable. One token set per theme defines colours, spacing, radii and type; reduced motion is respected. Structured tool views render `glob` as a file list and `tree` as an indented outline (Raw shows the original JSON). No CDN or font network request is required. - **Streaming**: fragments (`token_delta`/`thinking_delta`) and bulk `token` events share one rAF-batched render pipeline -- **DOM budget**: the message list is capped at 80 elements (`MAX_MESSAGES`); older messages are pruned +- **DOM budget** (`js/transcript.js`): the transcript is capped at 300 rendered rows (`MAX_UNITS`), counting every row inside a turn's stream as well as top-level messages, so one long turn with hundreds of tool steps stays bounded. The oldest rows are trimmed first; live work (the streaming answer, open reasoning, running sub-agents, tool steps still waiting for a result) is never trimmed. While you are scrolled up reading, trimming waits until 600 rows and keeps your view in place. A note at the top says when earlier rows are hidden +- **Long sessions**: opening a session renders only its newest ~150 rows; **Show earlier** at the top renders the previous window above, without moving what is on screen. Rows trimmed from a live turn come back with **Reload full history** once the turn finishes (the session is persisted every step). Tool output is rendered when a step is first expanded, reasoning blocks keep their newest 400 lines and sub-agent chip logs their newest 200; the persisted session always holds everything - **Resilience**: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s `keepalive`. A drop is visible: amber top-bar word, sticky `#conn-banner` with retry countdown, one transcript line per outage, and a composer toast if you send while down. - **Tests**: `node --test cmd/odek/ui/js/` (markdown + untrusted-envelope goldens, and api.js request-shape E2E against a mocked fetch) plus Go-side WebUI E2E (`cmd/odek/webui_e2e_test.go`): asset/header/CSP contract, token injection, JS↔HTML id and JS↔CSS class contracts, and full client journeys (streamed WS run, headless run with the remote-approval bridge, kick, pin/export) through the production mux (`newServeMux` — the same constructor `serveCmd` uses, so tests cannot drift from the real mounting) From bbe307bc0c0eed8c1d8a877320bac3c3585e8681 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:04:24 +0200 Subject: [PATCH 2/4] fix(webui): hold the reader's view across transcript trims Review fixes for the transcript budget: - Scroll correction measured a surviving row instead of the height delta. Chrome and Firefox scroll anchoring had already compensated, so Show earlier while scrolled mid-transcript jumped by the inserted height. - While reading up, the ceiling trim removes only rows wholly above the view (never splitting a loaded message); 1,200 rows is the hard bound. - Steps settled by a cancelled or interrupted turn are no longer treated as running, so the budget can trim them. - Steps re-rendered by Show earlier keep their Inspect result action. - The marker text is a polite live region, written only when it changes; focus moves into the transcript when the last window removes it. Co-Authored-By: Claude Opus 5.5 --- cmd/odek/ui/js/render.js | 38 ++++++++---- cmd/odek/ui/js/transcript.js | 98 ++++++++++++++++++++++++++----- cmd/odek/ui/js/transcript.test.js | 54 +++++++++++++++++ cmd/odek/ui/js/workspace.js | 16 ++++- docs/WEBUI.md | 2 +- 5 files changed, 176 insertions(+), 32 deletions(-) diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index f75d02d3..aa305058 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -9,7 +9,7 @@ import { scrollBottom, forceScrollBottom, historyUserText, showCancel, hideCancel, teach, } from './utils.js'; -import { pruneMessages, syncTrimMarker, setHistoryBacklog, trimState, HISTORY_UNITS } from './transcript.js'; +import { pruneMessages, syncTrimMarker, setHistoryBacklog, trimState, holdView, HISTORY_UNITS } from './transcript.js'; import { markdownToHtml, CODE_COPY_LABEL, CODE_COPIED_LABEL } from './markdown.js'; import { parseUntrusted } from './untrusted.js'; import { classifyToolResult, chipsHtml, prettyToolBody, collectReceipt, formatReceipt } from './tools.js'; @@ -692,6 +692,9 @@ export function settlePendingWork(reason = 'interrupted') { const latency = block.querySelector('.tb-latency'); if (latency) latency.textContent = label; block.dataset.pendingState = reason; + // Once the turn is over no result can arrive: the step is settled, so + // the transcript budget may trim it like any finished step. + if (!stopping) block.dataset.outcome = 'unknown'; } } if (S.subagentGroup) { @@ -944,6 +947,7 @@ function appendToolResultContent(block, output, record = true) { const body = block.querySelector('.tb-body'); if (!body || body.classList.contains('open')) renderPendingResult(block); if (record && typeof S.recordResult === 'function') S.recordResult(block, output); + else if (!record && typeof S.attachInspect === 'function') S.attachInspect(block, output); } function renderPendingResult(block) { @@ -1651,6 +1655,8 @@ export function renderSessionHistory(messages) { export function showEarlierHistory() { const backlog = trimState().backlog; if (!backlog || backlog.end <= 0) return; + const active = document.activeElement; + const fromMarker = !!(active && active.closest && active.closest('.transcript-trim')); const end = backlog.end; const start = historyWindowStart(backlog.messages, end); historyRecord = false; // already recorded when the session loaded @@ -1660,20 +1666,26 @@ export function showEarlierHistory() { const kids = Array.from(messagesEl.children || []); const marker = kids.find((el) => hasClass(el, 'transcript-trim')); const ref = marker ? (kids[kids.indexOf(marker) + 1] || null) : (kids[0] || null); - const before = messagesEl.scrollHeight; - // A window boundary inside a turn splits its stream; rejoin the halves so - // the turn reads as one log, as a full render would draw it. - const tail = nodes[nodes.length - 1]; - if (tail && ref && hasClass(tail, 'turn-stream') && hasClass(ref, 'turn-stream')) { - const first = ref.children[0] || null; - Array.from(tail.children).forEach((row) => ref.insertBefore(row, first)); - nodes.pop(); - } - nodes.forEach((node) => messagesEl.insertBefore(node, ref)); - settleHistoryMessages(msgs); - messagesEl.scrollTop += messagesEl.scrollHeight - before; + holdView(ref, () => { + // A window boundary inside a turn splits its stream; rejoin the halves so + // the turn reads as one log, as a full render would draw it. + const tail = nodes[nodes.length - 1]; + if (tail && ref && hasClass(tail, 'turn-stream') && hasClass(ref, 'turn-stream')) { + const first = ref.children[0] || null; + Array.from(tail.children).forEach((row) => ref.insertBefore(row, first)); + nodes.pop(); + } + nodes.forEach((node) => messagesEl.insertBefore(node, ref)); + settleHistoryMessages(msgs); + }); backlog.end = start; syncTrimMarker(); + // The last window removes the marker; keep keyboard focus in the + // transcript instead of dropping it to the page. + if (fromMarker && active.isConnected === false) { + const target = msgs.length && msgs[0].querySelector ? msgs[0].querySelector('button, [role="button"]') : null; + target?.focus?.(); + } } S.showEarlierHistory = showEarlierHistory; diff --git a/cmd/odek/ui/js/transcript.js b/cmd/odek/ui/js/transcript.js index 5d791566..4cd1fd75 100644 --- a/cmd/odek/ui/js/transcript.js +++ b/cmd/odek/ui/js/transcript.js @@ -15,6 +15,7 @@ import { messagesEl } from './dom.js'; export const MAX_UNITS = 300; // units kept while the view follows the bottom export const HARD_UNITS = 600; // ceiling while the operator reads further up +export const ABSOLUTE_UNITS = 1200; // past this, rows on screen are trimmed too export const HISTORY_UNITS = 150; // units a session load or "Show earlier" renders const TOP_UNITS = ['msg', 'turn-stream', 'tool-block', 'subagent-group', 'thinking-block', 'msg-divider']; @@ -99,14 +100,23 @@ export function pruneMessages() { const reading = !S.stickBottom; if (reading && units <= HARD_UNITS) return; - const before = messagesEl.scrollHeight; const state = trimState(); + // While reading, only rows wholly above the view leave, so the row being + // read stays put; the absolute ceiling bounds an operator parked at the top. + const limit = reading && units <= ABSOLUTE_UNITS ? firstRowInView() : null; + // The view is held on a row that survives: the first one on screen, or + // the newest row (a turn stream itself is no anchor — trimmed rows live + // inside it, so its top does not move). + const anchor = reading ? (limit || newestRow()) : null; let lastHidx = null; let removedHistory = false; // A loaded message renders as several rows (reply, reasoning, tool steps); // they leave together so a re-render from the backlog never duplicates or // drops part of a message. + let blocked = false; const drop = (row) => { + if (row === limit) blocked = true; + if (blocked) return false; const hidx = row.dataset ? row.dataset.hidx : undefined; const sameMessage = lastHidx != null && hidx === lastHidx; if (units <= MAX_UNITS && !sameMessage) return false; @@ -118,26 +128,76 @@ export function pruneMessages() { return true; }; - for (const el of kids(messagesEl)) { - if (units <= MAX_UNITS && lastHidx == null) break; + const trim = () => { for (const el of kids(messagesEl)) { + if ((units <= MAX_UNITS && lastHidx == null) || blocked) break; if (isStream(el)) { for (const row of kids(el)) { - if (!drop(row) && units <= MAX_UNITS) break; + if (!drop(row) && (units <= MAX_UNITS || blocked)) break; } if (!kids(el).length && el !== S.turnStreamEl) el.remove(); } else if (isTopUnit(el)) { - if (!drop(el) && units <= MAX_UNITS) break; + if (!drop(el) && (units <= MAX_UNITS || blocked)) break; } - } + } }; + if (reading) holdView(anchor, trim); + else trim(); if (removedHistory && state.backlog) { const first = firstHistoryIndex(); state.backlog.end = first >= 0 ? first : state.backlog.messages.length; } - if (reading) messagesEl.scrollTop -= before - messagesEl.scrollHeight; syncTrimMarker(); } +function newestRow() { + const top = kids(messagesEl).filter((el) => isStream(el) || isTopUnit(el)); + const last = top[top.length - 1]; + if (!last) return null; + if (!isStream(last)) return last; + const inner = kids(last); + return inner[inner.length - 1] || null; +} + +// firstRowInView returns the first row not wholly above the visible part +// of the transcript — or, when that row belongs to a loaded message, the +// message's first row, so a message is never split by a trim. Null when +// layout is unavailable (every row then counts as above the view). +function firstRowInView() { + if (!messagesEl.getBoundingClientRect) return null; + const viewTopPx = messagesEl.getBoundingClientRect().top; + let groupStart = null; + let groupHidx = null; + for (const el of kids(messagesEl)) { + const rows = isStream(el) ? kids(el) : (isTopUnit(el) ? [el] : []); + for (const row of rows) { + const hidx = row.dataset ? row.dataset.hidx : undefined; + if (hidx == null || hidx === '') { groupStart = null; groupHidx = null; } + else if (hidx !== groupHidx) { groupStart = row; groupHidx = hidx; } + if (!row.getBoundingClientRect || row.getBoundingClientRect().bottom > viewTopPx) return groupStart || row; + } + } + return null; +} + +function viewTop(el) { + return el && el.getBoundingClientRect && el.isConnected !== false ? el.getBoundingClientRect().top : null; +} + +// holdView keeps `anchor` (a row that survives the change) at the same +// viewport offset while rows are added or removed above it. It corrects only +// what is left after the browser's own scroll anchoring, which Chrome and +// Firefox apply and Safari does not; correcting by the height delta alone +// doubled the shift where anchoring had already run. Without a layout API +// the height delta is the whole correction. +export function holdView(anchor, change) { + const top = viewTop(anchor); + const height = messagesEl.scrollHeight; + change(); + const after = viewTop(anchor); + if (top != null && after != null) messagesEl.scrollTop += after - top; + else messagesEl.scrollTop += messagesEl.scrollHeight - height; +} + // earlierTurns counts the operator turns before the rendered window (the // persisted list also holds tool results and system records, which would // inflate a raw message count). Cached per window position. @@ -173,6 +233,7 @@ export function syncTrimMarker() { marker.setAttribute('role', 'note'); const text = document.createElement('span'); text.className = 'tt-text'; + text.setAttribute('aria-live', 'polite'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tt-more'; @@ -183,20 +244,27 @@ export function syncTrimMarker() { if (first !== marker) messagesEl.insertBefore(marker, first || null); const text = marker.querySelector('.tt-text'); const btn = marker.querySelector('.tt-more'); + let label, action, caption, disabled; if (state.live) { - text.textContent = 'Earlier activity is hidden to keep the page responsive.'; - btn.textContent = S.busy ? 'Reload full history after this turn' : 'Reload full history'; - btn.disabled = !!S.busy || !S.sessionId; - btn.dataset.action = 'reload'; + label = 'Earlier activity is hidden to keep the page responsive.'; + caption = S.busy ? 'Reload full history after this turn' : 'Reload full history'; + disabled = !!S.busy || !S.sessionId; + action = 'reload'; } else { const turns = earlierTurns(backlog); - text.textContent = turns + label = turns ? turns + (turns === 1 ? ' earlier turn is' : ' earlier turns are') + ' not shown.' : 'Earlier activity is not shown.'; - btn.textContent = 'Show earlier'; - btn.disabled = false; - btn.dataset.action = 'earlier'; + caption = 'Show earlier'; + disabled = false; + action = 'earlier'; } + // The marker re-syncs on every trim during a busy turn; writing unchanged + // text would still mutate the live region and cost a re-announcement. + if (text.textContent !== label) text.textContent = label; + if (btn.textContent !== caption) btn.textContent = caption; + if (btn.disabled !== disabled) btn.disabled = disabled; + if (btn.dataset.action !== action) btn.dataset.action = action; } messagesEl?.addEventListener?.('click', (e) => { diff --git a/cmd/odek/ui/js/transcript.test.js b/cmd/odek/ui/js/transcript.test.js index 1666782e..9b646b1d 100644 --- a/cmd/odek/ui/js/transcript.test.js +++ b/cmd/odek/ui/js/transcript.test.js @@ -412,3 +412,57 @@ test('the trim marker reload is refused mid-turn and reloads the session when id await new Promise((r) => setTimeout(r, 5)); assert.ok(fetched.some((u) => u.includes('sess-1')), 'idle reload fetches the persisted session: ' + fetched.join(',')); }); + +test('steps settled by a stopped or interrupted turn become trimmable', () => { + render.addToolCall('shell', '{"command":"sleep"}', 'orphan'); + const block = S.currentToolBlock; + render.requestTurnStop(); + assert.equal(block.dataset.outcome, 'running', 'a stopping step can still receive its result'); + render.endStream('cancelled'); + assert.equal(block.dataset.outcome, 'unknown', 'after the turn ends nothing can arrive'); + for (let i = 0; i < transcript.MAX_UNITS + 5; i++) finishedTool(i); + assert.equal(block.parentNode, null, 'the settled step is trimmed like any finished one'); +}); + +test('Show earlier steps keep their inspect action without re-recording', () => { + const recorded = []; + const inspected = []; + S.recordResult = (block, output) => recorded.push(output); + S.attachInspect = (block, output) => inspected.push(output); + render.renderSessionHistory(session(60)); + const n = recorded.length; + render.showEarlierHistory(); + assert.equal(recorded.length, n, 'not recorded again'); + assert.ok(inspected.length > 0, 'inspect action attached to re-rendered steps'); + S.attachInspect = undefined; +}); + +test('holdView corrects only what browser scroll anchoring left over', () => { + const anchor = new FakeEl('div'); + let top = 400; + anchor.getBoundingClientRect = () => ({ top }); + messages.scrollTop = 1000; + // The browser already anchored: the row did not move, so nothing to add. + transcript.holdView(anchor, () => {}); + assert.equal(messages.scrollTop, 1000); + // No anchoring: rows inserted above pushed the anchor down by 250px. + transcript.holdView(anchor, () => { top = 650; }); + assert.equal(messages.scrollTop, 1250); +}); + +test('while reading up, rows on screen and below are never trimmed', () => { + S.stickBottom = false; + messages.getBoundingClientRect = () => ({ top: 0 }); + try { + for (let i = 0; i < transcript.HARD_UNITS; i++) finishedTool(i); + // Rows 0..99 lie above the view; row 100 onward is on screen or below. + rows().forEach((row, i) => { row.getBoundingClientRect = () => ({ top: (i - 100) * 10, bottom: (i - 99) * 10 }); }); + const reading = rows()[100]; + finishedTool('over'); + assert.ok(reading.parentNode, 'the row being read survives the ceiling trim'); + assert.equal(rows()[0], reading, 'only rows above the view were trimmed'); + assert.equal(transcript.countUnits(), transcript.HARD_UNITS + 1 - 100); + } finally { + delete messages.getBoundingClientRect; + } +}); diff --git a/cmd/odek/ui/js/workspace.js b/cmd/odek/ui/js/workspace.js index 070c80cf..14f915c8 100644 --- a/cmd/odek/ui/js/workspace.js +++ b/cmd/odek/ui/js/workspace.js @@ -67,16 +67,26 @@ function openActivityDetails() { } S.openActivityDetails = openActivityDetails; +function resultItem(block, output) { + return { block, output: String(output || ''), outcome:block.dataset.outcome || 'unknown', turnId:block.dataset.turnId || '', callId:block.dataset.callId || '', name: block.dataset.toolName || 'tool', args: block.dataset.toolArgs || '', preview: toolPreview(block.dataset.toolName || '', block.dataset.toolArgs || '') }; +} +function addInspectButton(block, item) { + const body = block.querySelector('.tb-body'); + if (body) { const button = textNode('button', 'result-inspect', 'Inspect result ↗'); button.type = 'button'; button.addEventListener('click', () => inspect(item)); body.appendChild(button); } +} +// attachInspect gives a re-rendered history step (Show earlier) its inspect +// action without recording it again: the Activity list already holds the +// result in transcript order from the session load. +S.attachInspect = (block, output) => addInspectButton(block, resultItem(block, output)); S.recordResult = (block, output) => { - const item = { block, output: String(output || ''), outcome:block.dataset.outcome || 'unknown', turnId:block.dataset.turnId || '', callId:block.dataset.callId || '', name: block.dataset.toolName || 'tool', args: block.dataset.toolArgs || '', preview: toolPreview(block.dataset.toolName || '', block.dataset.toolArgs || '') }; + const item = resultItem(block, output); item.stepId=(S.plan?.steps || []).find(step=>step.status==='in_progress')?.id || ''; outputs.push(item);outputBytes+=item.output.length; let pruned=false; while(outputs.length>300 || outputBytes>16*1024*1024){outputBytes-=outputs.shift().output.length;pruned=true;} if(pruned)S.activityPruned=true; schedulePaintResults(); - const body = block.querySelector('.tb-body'); - if (body) { const button = textNode('button', 'result-inspect', 'Inspect result ↗'); button.type = 'button'; button.addEventListener('click', () => inspect(item)); body.appendChild(button); } + addInspectButton(block, item); }; S.clearResults = clearResults; diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 5c400a88..629ce17e 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -989,7 +989,7 @@ match as plain text. The bundled WebUI implements this in - **Untrusted envelope**: `js/untrusted.js` unwraps the model-facing `` envelope before display (body shown; source discarded) - **Design**: self-contained themes — Match system (the default: Ember dark or Porcelain light, following the OS colour scheme live), Ember, Porcelain, Midnight and High contrast. Locally bundled Geist serves reading/interface text, Space Grotesk the display headline and Geist Mono code (ligatures off, 4-space tabs). Reading text defaults to 15px (17/19px selectable); compact density tightens the interface scale. One header row on every width (52px, 44px in compact density); below 1100px the session rail and inspector are drawers with a scrim that closes them, from 1100px they dock as columns and the inspector is resizable. One token set per theme defines colours, spacing, radii and type; reduced motion is respected. Structured tool views render `glob` as a file list and `tree` as an indented outline (Raw shows the original JSON). No CDN or font network request is required. - **Streaming**: fragments (`token_delta`/`thinking_delta`) and bulk `token` events share one rAF-batched render pipeline -- **DOM budget** (`js/transcript.js`): the transcript is capped at 300 rendered rows (`MAX_UNITS`), counting every row inside a turn's stream as well as top-level messages, so one long turn with hundreds of tool steps stays bounded. The oldest rows are trimmed first; live work (the streaming answer, open reasoning, running sub-agents, tool steps still waiting for a result) is never trimmed. While you are scrolled up reading, trimming waits until 600 rows and keeps your view in place. A note at the top says when earlier rows are hidden +- **DOM budget** (`js/transcript.js`): the transcript is capped at 300 rendered rows (`MAX_UNITS`), counting every row inside a turn's stream as well as top-level messages, so one long turn with hundreds of tool steps stays bounded. The oldest rows are trimmed first; live work (the streaming answer, open reasoning, running sub-agents, tool steps still waiting for a result) is never trimmed. While you are scrolled up reading, trimming waits until 600 rows, then removes only rows wholly above the screen and keeps your view in place (past 1,200 rows the oldest go regardless). A note at the top says when earlier rows are hidden - **Long sessions**: opening a session renders only its newest ~150 rows; **Show earlier** at the top renders the previous window above, without moving what is on screen. Rows trimmed from a live turn come back with **Reload full history** once the turn finishes (the session is persisted every step). Tool output is rendered when a step is first expanded, reasoning blocks keep their newest 400 lines and sub-agent chip logs their newest 200; the persisted session always holds everything - **Resilience**: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s `keepalive`. A drop is visible: amber top-bar word, sticky `#conn-banner` with retry countdown, one transcript line per outage, and a composer toast if you send while down. - **Tests**: `node --test cmd/odek/ui/js/` (markdown + untrusted-envelope goldens, and api.js request-shape E2E against a mocked fetch) plus Go-side WebUI E2E (`cmd/odek/webui_e2e_test.go`): asset/header/CSP contract, token injection, JS↔HTML id and JS↔CSS class contracts, and full client journeys (streamed WS run, headless run with the remote-approval bridge, kick, pin/export) through the production mux (`newServeMux` — the same constructor `serveCmd` uses, so tests cannot drift from the real mounting) From 0b9ba5fbf39fa5558167e84ebbe9d2032c702601 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:15:00 +0200 Subject: [PATCH 3/4] fix(webui): review fixes for the transcript budget - History from a session that fit one window stays recoverable after the budget trims it (the backlog is kept even when nothing was hidden). - Show earlier holds the first row on screen when the older half of a split turn joins its stream (the stream's own top does not move; Safari has no scroll anchoring to hide this). - Activity items recorded at load are rebound to steps Show earlier renders, so "Show in conversation" reaches them. - Several results on one unopened step all render on expand. - A bubble measured at zero height (hidden pane) is measured again. - System-initiated user records use no window budget and never open a window. - The in-view search while reading up is a binary search. - Reload from the trim marker opens at the top of its window. - hasClass/nodeHolds live in utils.js; message and reasoning-note markup each have one builder. Co-Authored-By: Claude Opus 5.5 --- cmd/odek/ui/js/render.js | 129 +++++++++++++----------------- cmd/odek/ui/js/sessions.js | 9 ++- cmd/odek/ui/js/transcript.js | 62 +++++++------- cmd/odek/ui/js/transcript.test.js | 109 ++++++++++++++++++++++++- cmd/odek/ui/js/utils.js | 14 ++++ cmd/odek/ui/js/workspace.js | 11 ++- docs/WEBUI.md | 2 +- 7 files changed, 225 insertions(+), 111 deletions(-) diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index aa305058..8c9a83de 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -7,7 +7,7 @@ import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js'; import { escapeHtml, escapeAttr, truncateStr, copyTextToClipboard, scrollBottom, forceScrollBottom, historyUserText, - showCancel, hideCancel, teach, + showCancel, hideCancel, teach, hasClass, nodeHolds, } from './utils.js'; import { pruneMessages, syncTrimMarker, setHistoryBacklog, trimState, holdView, HISTORY_UNITS } from './transcript.js'; import { markdownToHtml, CODE_COPY_LABEL, CODE_COPIED_LABEL } from './markdown.js'; @@ -301,12 +301,6 @@ export function hideLoading() { // replies, not reasoning, and each burst is sealed when a tool starts // so the next tokens open a new row instead of concatenating the whole // turn into one bubble. Reasoning stays behind ▶ thinking until opened. -function hasClass(el, name) { - if (!el) return false; - if (el.classList && el.classList.contains) return el.classList.contains(name); - return (' ' + (el.className || '') + ' ').indexOf(' ' + name + ' ') !== -1; -} - function liveAnswerEl() { const el = S.streamBubbleEl; return (el && el.parentNode === messagesEl) ? el : null; @@ -430,13 +424,22 @@ function capThinkingLines(host) { let excess = rows.length - (note ? 1 : 0) - MAX_THINKING_LINES; if (excess <= 0) return; if (!note) { - note = document.createElement('div'); - note.className = 'thinking-trim'; - note.dataset.trimmed = '0'; + note = thinkingTrimNote(); host.insertBefore(note, rows[0] || null); } const trimmed = Number(note.dataset.trimmed || 0) + excess; while (excess-- > 0) host.children[1].remove(); + setThinkingTrim(note, trimmed); +} + +function thinkingTrimNote() { + const note = document.createElement('div'); + note.className = 'thinking-trim'; + note.dataset.trimmed = '0'; + return note; +} + +function setThinkingTrim(note, trimmed) { note.dataset.trimmed = String(trimmed); note.textContent = '… ' + trimmed + ' earlier lines not shown'; } @@ -760,19 +763,24 @@ function sealTurnStream() { } // ── Message rendering ── -export function addMessage(role, content) { - hideEmptyState(); +// buildMessage renders a user or assistant row; live and history rendering +// share it so the bubble markup stays in one place. +function buildMessage(role, content) { const wrapper = document.createElement('div'); wrapper.className = 'msg ' + role; - const sender = role === 'user' ? '❯ you' : assistantSender(); - wrapper.innerHTML = '
' + '
' + sender + '
' + '
' + markdownToHtml(content) + '
' + '
'; if (role === 'assistant') attachWakeChip(wrapper); + return wrapper; +} + +export function addMessage(role, content) { + hideEmptyState(); + const wrapper = buildMessage(role, content); messagesEl.appendChild(wrapper); const bubble = wrapper.querySelector('.bubble'); if (bubble) addCopyButton(bubble); @@ -803,14 +811,7 @@ export function addDivider(text) { // 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 = - '
' + - '
' + assistantSender() + '
' + - '
' + markdownToHtml(content) + '
' + - '
'; - attachWakeChip(wrapper); + const wrapper = buildMessage('assistant', content); messagesEl.appendChild(wrapper); const bubble = wrapper.querySelector('.bubble'); if (bubble) addCopyButton(bubble); @@ -943,7 +944,7 @@ function appendToolResultContent(block, output, record = true) { const preview = block.querySelector('.tb-preview'); if (view && preview) { preview.textContent = view.summary; preview.title = view.summary; } - block.pendingResult = output; + (block.pendingResults ||= []).push(output); const body = block.querySelector('.tb-body'); if (!body || body.classList.contains('open')) renderPendingResult(block); if (record && typeof S.recordResult === 'function') S.recordResult(block, output); @@ -951,16 +952,18 @@ function appendToolResultContent(block, output, record = true) { } function renderPendingResult(block) { - const output = block.pendingResult; - if (output == null) return; - block.pendingResult = null; - const resultEl = document.createElement('div'); - resultEl.className = 'tb-result'; + const pending = block.pendingResults; + if (!pending || !pending.length) return; + block.pendingResults = null; const body = block.querySelector('.tb-body'); const inspect = body && body.querySelector('.result-inspect'); - if (inspect) body.insertBefore(resultEl, inspect); - else (body || block).appendChild(resultEl); - renderResult(resultEl, { name: block.dataset.toolName || '', output, args:block.dataset.toolArgs || '', compact: true }); + pending.forEach((output) => { + const resultEl = document.createElement('div'); + resultEl.className = 'tb-result'; + if (inspect) body.insertBefore(resultEl, inspect); + else (body || block).appendChild(resultEl); + renderResult(resultEl, { name: block.dataset.toolName || '', output, args:block.dataset.toolArgs || '', compact: true }); + }); } // fillToolResult renders tool output into resultEl. The server sends raw, @@ -1485,10 +1488,16 @@ function historyRow(el) { return el; } +// System-initiated user records (bg-wake preamble, bg-notice, +// return-after-break) are not text the operator typed and render nothing. +function syntheticUser(msg) { + return typeof msg.name === 'string' && (msg.name.startsWith('bg-') || msg.name === 'return-after-break'); +} + // historyUnits counts the rows one persisted message renders as. export function historyUnits(msg) { if (!msg) return 0; - if (msg.role === 'user') return 1; + if (msg.role === 'user') return syntheticUser(msg) ? 0 : 1; if (msg.role !== 'assistant') return 0; return (msg.reasoning_content ? 1 : 0) + (msg.content ? 1 : 0) + (Array.isArray(msg.tool_calls) ? msg.tool_calls.length : 0); @@ -1511,7 +1520,7 @@ export function historyWindowStart(messages, end, budget = HISTORY_UNITS) { } units += n; if (n) fit = i; - if (messages[i] && messages[i].role === 'user') { turnStart = i; turnUnits = units; } + if (n && messages[i].role === 'user') { turnStart = i; turnUnits = units; } } return 0; } @@ -1587,9 +1596,7 @@ function renderHistoryRange(messages, from, to) { historyIdx = messageIndex; if (msg.role === 'user') { historyTurnId = msg.turn_id || 'history-' + messageIndex; - // System-initiated records (bg-wake preamble, bg-notice, return-after-break) - // are not text the operator typed. - if (typeof msg.name === 'string' && (msg.name.startsWith('bg-') || msg.name === 'return-after-break')) continue; + if (syntheticUser(msg)) continue; renderHistoricalUser(historyUserText(msg.content, msg.principal_prompt)); continue; } @@ -1666,7 +1673,10 @@ export function showEarlierHistory() { const kids = Array.from(messagesEl.children || []); const marker = kids.find((el) => hasClass(el, 'transcript-trim')); const ref = marker ? (kids[kids.indexOf(marker) + 1] || null) : (kids[0] || null); - holdView(ref, () => { + // Hold on the first row on screen: when the older half of a split turn + // joins `ref`, the stream's own top does not move. + const anchor = ref && hasClass(ref, 'turn-stream') ? (ref.children[0] || ref) : ref; + holdView(anchor, () => { // A window boundary inside a turn splits its stream; rejoin the halves so // the turn reads as one log, as a full render would draw it. const tail = nodes[nodes.length - 1]; @@ -1690,13 +1700,7 @@ export function showEarlierHistory() { S.showEarlierHistory = showEarlierHistory; function renderHistoricalUser(content) { - const wrapper = document.createElement('div'); - wrapper.className = 'msg user'; - wrapper.innerHTML = - '
' + - '
❯ you
' + - '
' + markdownToHtml(content) + '
' + - '
'; + const wrapper = buildMessage('user', content); historyTarget().appendChild(historyRow(wrapper)); const bubble = wrapper.querySelector('.bubble'); if (bubble) addCopyButton(bubble); @@ -1704,14 +1708,7 @@ function renderHistoricalUser(content) { function renderHistoricalAssistant(raw) { const [content, unverified] = stripVerifyMarker(raw); - const wrapper = document.createElement('div'); - wrapper.className = 'msg assistant'; - wrapper.innerHTML = - '
' + - '
' + assistantSender() + '
' + - '
' + markdownToHtml(content) + '
' + - '
'; - attachWakeChip(wrapper); + const wrapper = buildMessage('assistant', content); ensureHistoryStream().appendChild(historyRow(wrapper)); const bubble = wrapper.querySelector('.bubble'); if (bubble) addCopyButton(bubble); @@ -1739,10 +1736,8 @@ function renderHistoricalThinking(content) { host.appendChild(row); }); if (lines.length > MAX_THINKING_LINES) { - const note = document.createElement('div'); - note.className = 'thinking-trim'; - note.dataset.trimmed = String(lines.length - MAX_THINKING_LINES); - note.textContent = '… ' + note.dataset.trimmed + ' earlier lines not shown'; + const note = thinkingTrimNote(); + setThinkingTrim(note, lines.length - MAX_THINKING_LINES); host.insertBefore(note, host.children[0] || null); } } @@ -1818,18 +1813,6 @@ function resolveBubble(el) { return el.classList.contains('bubble') ? el : el.querySelector('.bubble'); } -function nodeHolds(parent, node) { - if (!parent || !node) return false; - if (parent === node) return true; - if (parent.contains) return parent.contains(node); - let el = node; - while (el) { - if (el === parent) return true; - el = el.parentNode; - } - return false; -} - function assistantMessages() { const found = messagesEl.querySelectorAll('.msg.assistant'); return found ? Array.from(found) : []; @@ -1870,12 +1853,14 @@ function checkCollapse(host, opts) { } const content = bubble.querySelector('.content'); if (!content) return; + const existing = bubble.querySelector('.collapse-toggle'); + const height = content.scrollHeight; // Older bubbles no longer change, so each is measured once: re-reading // scrollHeight across the whole transcript forced a layout per bubble - // every time a reply opened. - if (bubble.dataset) bubble.dataset.collapseChecked = '1'; - const existing = bubble.querySelector('.collapse-toggle'); - if (content.scrollHeight <= COLLAPSE_MAX_HEIGHT_PX) { + // every time a reply opened. A zero height is no measurement (hidden pane + // or detached) and is retried on the next pass. + if (height > 0 && bubble.dataset) bubble.dataset.collapseChecked = '1'; + if (height <= COLLAPSE_MAX_HEIGHT_PX) { releaseCollapse(bubble); return; } diff --git a/cmd/odek/ui/js/sessions.js b/cmd/odek/ui/js/sessions.js index e2a272e4..19b3248a 100644 --- a/cmd/odek/ui/js/sessions.js +++ b/cmd/odek/ui/js/sessions.js @@ -286,7 +286,7 @@ export function newSession() { // agent (buffer restore) via session_switch, then renders the transcript // from the REST detail. Falls back to the legacy piggyback-on-prompt path // when the socket is down. -export async function loadAndRenderSession(sid) { +export async function loadAndRenderSession(sid, opts = {}) { // F-A2: same mid-run guard as newSession — ask, cancel through the stop // control, and only then bootstrap the switch (which fetches). if (S.busy) { @@ -357,7 +357,10 @@ export async function loadAndRenderSession(sid) { renderSessionHistory(messages); S.restoreDecisionReceipts?.(sess.decisions || []); - forceScrollBottom(); + // A reload asked for from the trim marker wants the earlier rows: land + // at the top of the window, beside "Show earlier", not at the end. + if (opts.atTop) { S.stickBottom = false; messagesEl.scrollTop = 0; } + else forceScrollBottom(); showToast('Session loaded'); announce('Session loaded'); if (typeof S.closePanels === 'function') S.closePanels(); @@ -370,7 +373,7 @@ export async function loadAndRenderSession(sid) { // only in the persisted session. Never while a turn runs. S.reloadSession = () => { if (S.busy || !S.sessionId) return; - loadAndRenderSession(S.sessionId); + loadAndRenderSession(S.sessionId, { atTop: true }); }; // ── Session Rename ── diff --git a/cmd/odek/ui/js/transcript.js b/cmd/odek/ui/js/transcript.js index 4cd1fd75..fa56c6b1 100644 --- a/cmd/odek/ui/js/transcript.js +++ b/cmd/odek/ui/js/transcript.js @@ -12,6 +12,7 @@ // rows trimmed from a live turn come back by reloading the persisted session. import { S } from './state.js'; import { messagesEl } from './dom.js'; +import { hasClass, nodeHolds } from './utils.js'; export const MAX_UNITS = 300; // units kept while the view follows the bottom export const HARD_UNITS = 600; // ceiling while the operator reads further up @@ -20,24 +21,10 @@ export const HISTORY_UNITS = 150; // units a session load or "Show earlier" ren const TOP_UNITS = ['msg', 'turn-stream', 'tool-block', 'subagent-group', 'thinking-block', 'msg-divider']; -function hasClass(el, name) { - if (!el) return false; - if (el.classList && el.classList.contains) return el.classList.contains(name); - return (' ' + (el.className || '') + ' ').indexOf(' ' + name + ' ') !== -1; -} - function kids(el) { return el && el.children ? Array.from(el.children) : []; } -function holds(parent, node) { - if (!parent || !node) return false; - if (parent === node) return true; - if (parent.contains) return parent.contains(node); - for (let el = node; el; el = el.parentNode) if (el === parent) return true; - return false; -} - function isStream(el) { return hasClass(el, 'turn-stream'); } function isTopUnit(el) { return TOP_UNITS.some((c) => hasClass(el, c)); } @@ -46,7 +33,7 @@ function isLive(el) { if (el.dataset && el.dataset.outcome === 'running') return true; if (hasClass(el, 'live')) return true; const refs = [S.streamBubbleEl, S.thinkingContentEl, S.subagentGroup, S.loadingEl]; - return refs.some((ref) => holds(el, ref)); + return refs.some((ref) => nodeHolds(el, ref)); } export function countUnits() { @@ -73,8 +60,10 @@ export function resetTranscriptTrim() { // setHistoryBacklog records the loaded session's messages and the index // where the rendered window starts; messages before it are offered back. +// Kept even when the whole session fit (end 0): the budget may trim those +// rows later, and they must stay recoverable. export function setHistoryBacklog(messages, end) { - trimState().backlog = end > 0 ? { messages, end } : null; + trimState().backlog = { messages, end }; syncTrimMarker(); } @@ -158,25 +147,38 @@ function newestRow() { return inner[inner.length - 1] || null; } +// rowsInOrder lists every budget row in document order. +function rowsInOrder() { + const out = []; + for (const el of kids(messagesEl)) { + if (isStream(el)) out.push(...kids(el)); + else if (isTopUnit(el)) out.push(el); + } + return out; +} + // firstRowInView returns the first row not wholly above the visible part // of the transcript — or, when that row belongs to a loaded message, the -// message's first row, so a message is never split by a trim. Null when -// layout is unavailable (every row then counts as above the view). +// message's first row, so a message is never split by a trim. Rows are in +// document order, so a binary search keeps the layout reads logarithmic. +// Null when layout is unavailable (every row then counts as above the view). function firstRowInView() { if (!messagesEl.getBoundingClientRect) return null; + const rows = rowsInOrder(); + if (!rows.length || !rows[0].getBoundingClientRect) return null; const viewTopPx = messagesEl.getBoundingClientRect().top; - let groupStart = null; - let groupHidx = null; - for (const el of kids(messagesEl)) { - const rows = isStream(el) ? kids(el) : (isTopUnit(el) ? [el] : []); - for (const row of rows) { - const hidx = row.dataset ? row.dataset.hidx : undefined; - if (hidx == null || hidx === '') { groupStart = null; groupHidx = null; } - else if (hidx !== groupHidx) { groupStart = row; groupHidx = hidx; } - if (!row.getBoundingClientRect || row.getBoundingClientRect().bottom > viewTopPx) return groupStart || row; - } + let lo = 0, hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (rows[mid].getBoundingClientRect().bottom > viewTopPx) hi = mid; + else lo = mid + 1; + } + if (lo >= rows.length) return null; + const hidx = rows[lo].dataset ? rows[lo].dataset.hidx : undefined; + if (hidx != null && hidx !== '') { + while (lo > 0 && rows[lo - 1].dataset && rows[lo - 1].dataset.hidx === hidx) lo--; } - return null; + return rows[lo]; } function viewTop(el) { @@ -247,7 +249,7 @@ export function syncTrimMarker() { let label, action, caption, disabled; if (state.live) { label = 'Earlier activity is hidden to keep the page responsive.'; - caption = S.busy ? 'Reload full history after this turn' : 'Reload full history'; + caption = S.busy ? 'Reload earlier history after this turn' : 'Reload earlier history'; disabled = !!S.busy || !S.sessionId; action = 'reload'; } else { diff --git a/cmd/odek/ui/js/transcript.test.js b/cmd/odek/ui/js/transcript.test.js index 9b646b1d..032a9cf5 100644 --- a/cmd/odek/ui/js/transcript.test.js +++ b/cmd/odek/ui/js/transcript.test.js @@ -69,7 +69,11 @@ class FakeEl { set scrollHeight(v) { this._scrollHeight = v; } get id() { return this._id || ''; } set id(v) { this._id = v; } - get isConnected() { return true; } + get isConnected() { + let root = this; + while (root.parentNode) root = root.parentNode; + return root === globalThis.document.body || Object.values(byId).includes(root); + } setAttribute(k, v) { this._attrs[k] = String(v); } getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; } removeAttribute(k) { delete this._attrs[k]; } @@ -153,6 +157,8 @@ globalThis.cancelAnimationFrame = (t) => clearTimeout(t); const S = (await import('./state.js')).S; const render = await import('./render.js'); const transcript = await import('./transcript.js'); +await import('./workspace.js'); +const wsHooks = { record: S.recordResult, attach: S.attachInspect }; const messages = byId.messages; // Height follows the rendered row count so scroll compensation is observable. @@ -244,12 +250,14 @@ test('marker reload waits for the running turn', () => { const btn = marker().querySelector('.tt-more'); assert.equal(btn.disabled, true, 'no reload mid-turn'); let reloads = 0; + const realReload = S.reloadSession; S.reloadSession = () => { reloads++; }; S.busy = false; render.endStream('completed'); assert.equal(btn.disabled, false, 'turn end re-enables reload'); btn.click(); assert.equal(reloads, 1); + S.reloadSession = realReload; }); test('historyWindowStart snaps to a turn start and falls back inside one oversized turn', () => { @@ -379,7 +387,7 @@ test('results outside the loaded window still reach the Activity panel in order; }); test('Activity results repaint once per frame and keep a bounded byte total', async () => { - await import('./workspace.js'); + S.recordResult = wsHooks.record; const list = byId['output-list']; let paints = 0; const origAppend = list.appendChild.bind(list); @@ -434,11 +442,12 @@ test('Show earlier steps keep their inspect action without re-recording', () => render.showEarlierHistory(); assert.equal(recorded.length, n, 'not recorded again'); assert.ok(inspected.length > 0, 'inspect action attached to re-rendered steps'); - S.attachInspect = undefined; + S.attachInspect = wsHooks.attach; }); test('holdView corrects only what browser scroll anchoring left over', () => { const anchor = new FakeEl('div'); + messages.appendChild(anchor); let top = 400; anchor.getBoundingClientRect = () => ({ top }); messages.scrollTop = 1000; @@ -466,3 +475,97 @@ test('while reading up, rows on screen and below are never trimmed', () => { delete messages.getBoundingClientRect; } }); + +test('a session that fit one window stays recoverable after the budget trims it', () => { + render.renderSessionHistory(session(20)); // 100 rows: whole session rendered + assert.equal(marker(), null, 'nothing hidden yet'); + for (let i = 0; i < transcript.MAX_UNITS; i++) finishedTool(i); + assert.ok(marker(), 'trimmed history is announced'); + assert.equal(marker().querySelector('.tt-more').dataset.action, 'earlier'); + const live = rows().filter(r => !r.dataset.hidx).length; + marker().querySelector('.tt-more').click(); + const shown = new Set(rows().filter(r => r.dataset.hidx).map(r => r.dataset.hidx)); + assert.ok(shown.size > 0 && rows().filter(r => !r.dataset.hidx).length === live, 'history rows rendered back above the live rows'); +}); + +test('Show earlier holds the first row on screen when the older half of a turn joins its stream', () => { + // Position rows by document order so holdView sees real movement. + const order = () => rows(); + const proto = FakeEl.prototype; + proto.getBoundingClientRect = function () { + const list = order(); + let el = this; + if (el.classList.contains('turn-stream')) el = el.children[0] || el; + const i = list.indexOf(el); + const top = (i < 0 ? 0 : i * 10) - messages.scrollTop; + return { top, bottom: top + 10 }; + }; + try { + // One giant turn: the window starts mid-turn, so the boundary splits it. + const big = [{ role: 'user', content: 'go', turn_id: 'g' }, ...Array.from({ length: 200 }, (_, i) => ({ role: 'assistant', content: 'step ' + i, turn_id: 'g' }))]; + render.renderSessionHistory(big); + const first = rows()[0]; + messages.scrollTop = 0; + const before = first.getBoundingClientRect().top; + render.showEarlierHistory(); + assert.equal(messages.children.filter(c => c.classList.contains('turn-stream')).length, 1, 'turn rejoined'); + assert.equal(first.getBoundingClientRect().top, before, 'first row stayed where it was on screen'); + } finally { + delete proto.getBoundingClientRect; + } +}); + +test('several results on one unopened step all render on expand', () => { + render.addToolCall('shell', '{}', ''); + const block = S.currentToolBlock; + render.addToolResult('shell', 'first', '', 'completed'); + S.toolBlockQueues.clear(); + render.addToolResult('shell', 'second', '', 'completed'); + block.querySelector('.tb-header').click(); + assert.equal(block.querySelectorAll('.tb-result').length, 2, 'neither result is lost'); +}); + +test('a bubble measured while hidden is measured again later', () => { + render.addMessage('assistant', 'first'); + const bubble = messages.querySelector('.msg.assistant').querySelector('.bubble'); + const content = bubble.querySelector('.content'); + let height = 0; + Object.defineProperty(content, 'scrollHeight', { get: () => height }); + render.addMessage('assistant', 'second'); + assert.equal(bubble.dataset.collapseChecked, undefined, 'zero height is no measurement'); + height = 900; + render.addMessage('assistant', 'third'); + assert.ok(bubble.classList.contains('collapsible'), 'folded once it has a real height'); +}); + +test('system-initiated records neither use the window budget nor start a window', () => { + assert.equal(render.historyUnits({ role: 'user', name: 'bg-wake', content: 'x' }), 0); + assert.equal(render.historyUnits({ role: 'user', name: 'return-after-break', content: 'x' }), 0); + const list = [{ role: 'user', content: 'real', turn_id: 'a' }]; + for (let i = 0; i < 30; i++) list.push({ role: 'assistant', content: 'a' + i }); + list.push({ role: 'user', name: 'bg-wake', content: 'wake' }); + for (let i = 0; i < 4; i++) list.push({ role: 'assistant', content: 'w' + i }); + const start = render.historyWindowStart(list, list.length, 20); + assert.notEqual(list[start].name, 'bg-wake', 'window never opens on a record that renders nothing'); +}); + +test('Show in conversation reaches a step re-rendered by Show earlier', async () => { + S.recordResult = wsHooks.record; + S.attachInspect = wsHooks.attach; + S.clearResults(); + // The shim's textContent='' keeps children; start from empty panels. + byId['output-list'].children.length = 0; + byId['output-detail'].children.length = 0; + render.renderSessionHistory(session(40)); // window ~30 turns; turn 0 is a load-time stub + render.showEarlierHistory(); + await new Promise((r) => setTimeout(r, 5)); + const jumped = []; + rows().filter(r => r.classList.contains('tool-block')).forEach((b) => { b.scrollIntoView = () => jumped.push(b.dataset.callId); }); + const first = byId['output-list'].querySelectorAll('.output-item')[0]; + assert.ok(first, 'Activity lists the oldest result'); + first.click(); + const jump = byId['output-detail'].querySelectorAll('button').find(b => b.textContent === 'Show in conversation'); + jump.click(); + assert.deepEqual(jumped, ['k0'], 'the Activity item now points at the visible step, not the load-time stub'); + S.clearResults(); +}); diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index c86fdf1c..c074f5dc 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -89,6 +89,20 @@ export function teach(key, text) { showToast(String(text).replace(/^tip:\s*/i, ''), HINT_TTL_MS, 'tip'); } +// ── DOM helpers (tolerant of the test suites' minimal DOM shims) ── +export function hasClass(el, name) { + if (!el) return false; + if (el.classList && el.classList.contains) return el.classList.contains(name); + return (' ' + (el.className || '') + ' ').indexOf(' ' + name + ' ') !== -1; +} +export function nodeHolds(parent, node) { + if (!parent || !node) return false; + if (parent === node) return true; + if (parent.contains) return parent.contains(node); + for (let el = node; el; el = el.parentNode) if (el === parent) return true; + return false; +} + // ── Smart Scroll ── export const SCROLL_THRESHOLD = 100; export function isNearBottom() { diff --git a/cmd/odek/ui/js/workspace.js b/cmd/odek/ui/js/workspace.js index 14f915c8..dbf564bf 100644 --- a/cmd/odek/ui/js/workspace.js +++ b/cmd/odek/ui/js/workspace.js @@ -76,8 +76,15 @@ function addInspectButton(block, item) { } // attachInspect gives a re-rendered history step (Show earlier) its inspect // action without recording it again: the Activity list already holds the -// result in transcript order from the session load. -S.attachInspect = (block, output) => addInspectButton(block, resultItem(block, output)); +// result in transcript order from the session load. That item is rebound to +// the visible step so "Show in conversation" reaches it. +S.attachInspect = (block, output) => { + const text = String(output || ''); + const callId = block.dataset.callId || '', turnId = block.dataset.turnId || ''; + const item = outputs.find((o) => !o.block.isConnected && o.callId === callId && o.turnId === turnId && o.output === text); + if (item) item.block = block; + addInspectButton(block, item || resultItem(block, output)); +}; S.recordResult = (block, output) => { const item = resultItem(block, output); item.stepId=(S.plan?.steps || []).find(step=>step.status==='in_progress')?.id || ''; diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 629ce17e..5645c6bf 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -990,7 +990,7 @@ match as plain text. The bundled WebUI implements this in - **Design**: self-contained themes — Match system (the default: Ember dark or Porcelain light, following the OS colour scheme live), Ember, Porcelain, Midnight and High contrast. Locally bundled Geist serves reading/interface text, Space Grotesk the display headline and Geist Mono code (ligatures off, 4-space tabs). Reading text defaults to 15px (17/19px selectable); compact density tightens the interface scale. One header row on every width (52px, 44px in compact density); below 1100px the session rail and inspector are drawers with a scrim that closes them, from 1100px they dock as columns and the inspector is resizable. One token set per theme defines colours, spacing, radii and type; reduced motion is respected. Structured tool views render `glob` as a file list and `tree` as an indented outline (Raw shows the original JSON). No CDN or font network request is required. - **Streaming**: fragments (`token_delta`/`thinking_delta`) and bulk `token` events share one rAF-batched render pipeline - **DOM budget** (`js/transcript.js`): the transcript is capped at 300 rendered rows (`MAX_UNITS`), counting every row inside a turn's stream as well as top-level messages, so one long turn with hundreds of tool steps stays bounded. The oldest rows are trimmed first; live work (the streaming answer, open reasoning, running sub-agents, tool steps still waiting for a result) is never trimmed. While you are scrolled up reading, trimming waits until 600 rows, then removes only rows wholly above the screen and keeps your view in place (past 1,200 rows the oldest go regardless). A note at the top says when earlier rows are hidden -- **Long sessions**: opening a session renders only its newest ~150 rows; **Show earlier** at the top renders the previous window above, without moving what is on screen. Rows trimmed from a live turn come back with **Reload full history** once the turn finishes (the session is persisted every step). Tool output is rendered when a step is first expanded, reasoning blocks keep their newest 400 lines and sub-agent chip logs their newest 200; the persisted session always holds everything +- **Long sessions**: opening a session renders only its newest ~150 rows; **Show earlier** at the top renders the previous window above, without moving what is on screen. Rows trimmed from a live turn come back with **Reload earlier history** once the turn finishes (the session is persisted every step; the reload opens at the top of its window, beside **Show earlier**). Tool output is rendered when a step is first expanded, reasoning blocks keep their newest 400 lines and sub-agent chip logs their newest 200; the persisted session always holds everything - **Resilience**: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s `keepalive`. A drop is visible: amber top-bar word, sticky `#conn-banner` with retry countdown, one transcript line per outage, and a composer toast if you send while down. - **Tests**: `node --test cmd/odek/ui/js/` (markdown + untrusted-envelope goldens, and api.js request-shape E2E against a mocked fetch) plus Go-side WebUI E2E (`cmd/odek/webui_e2e_test.go`): asset/header/CSP contract, token injection, JS↔HTML id and JS↔CSS class contracts, and full client journeys (streamed WS run, headless run with the remote-approval bridge, kick, pin/export) through the production mux (`newServeMux` — the same constructor `serveCmd` uses, so tests cannot drift from the real mounting) From d910baab21d4b6d39680ad1a9e6f466a84edb0d7 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:26:37 +0200 Subject: [PATCH 4/4] fix(webui): second review round for the transcript budget - An interrupted turn clears the live flag on its reasoning row, so the budget can trim it. - Folds are re-measured after a layout change (window or inspector width, reading size, density), debounced. - Identical results within one turn rebind to distinct Activity items. - The earlier-turns count skips system-initiated records. - Session load collects only the newest results the Activity panel can hold, walking back from the window: load cost no longer grows with the session's tool history. - A reader parked where nothing above the view can go costs no layout reads until they scroll. - The trim loop stops once a message boundary is reached. - Reload re-renders the transcript only (no session_switch, queue pause, plan or Activity reset) and rebinds the existing Activity items. Co-Authored-By: Claude Opus 5.5 --- cmd/odek/ui/js/render.js | 105 +++++++++++++++++++-------- cmd/odek/ui/js/results.js | 3 + cmd/odek/ui/js/sessions.js | 29 +++++--- cmd/odek/ui/js/transcript.js | 23 ++++-- cmd/odek/ui/js/transcript.test.js | 113 ++++++++++++++++++++++++++++++ cmd/odek/ui/js/utils.js | 6 ++ cmd/odek/ui/js/workspace.js | 12 ++-- docs/WEBUI.md | 2 +- 8 files changed, 244 insertions(+), 49 deletions(-) diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index 8c9a83de..5a4411f1 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -7,14 +7,14 @@ import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js'; import { escapeHtml, escapeAttr, truncateStr, copyTextToClipboard, scrollBottom, forceScrollBottom, historyUserText, - showCancel, hideCancel, teach, hasClass, nodeHolds, + showCancel, hideCancel, teach, hasClass, nodeHolds, isSyntheticUser, } from './utils.js'; import { pruneMessages, syncTrimMarker, setHistoryBacklog, trimState, holdView, HISTORY_UNITS } from './transcript.js'; import { markdownToHtml, CODE_COPY_LABEL, CODE_COPIED_LABEL } from './markdown.js'; import { parseUntrusted } from './untrusted.js'; import { classifyToolResult, chipsHtml, prettyToolBody, collectReceipt, formatReceipt } from './tools.js'; import { toolIcon } from './icons.js'; -import { renderResult, resultText } from './results.js'; +import { renderResult, resultText, ACTIVITY_MAX_RESULTS, ACTIVITY_MAX_BYTES } from './results.js'; import { toolPreview, toolArguments, toolView, renderToolItems } from './toolviews.js'; import { metricsNoteOutput } from './metrics.js'; @@ -730,6 +730,7 @@ export function endStream(reason = "interrupted") { if (S.closedTurnIds.size > 32) S.closedTurnIds.delete(S.closedTurnIds.values().next().value); } removeStreamCursor(); + endThinking(); // an interrupted turn leaves no row marked live sealTurnStream(); // The live answer is the latest — never fold it. Older long replies // were already compacted when this bubble opened. @@ -948,7 +949,7 @@ function appendToolResultContent(block, output, record = true) { const body = block.querySelector('.tb-body'); if (!body || body.classList.contains('open')) renderPendingResult(block); if (record && typeof S.recordResult === 'function') S.recordResult(block, output); - else if (!record && typeof S.attachInspect === 'function') S.attachInspect(block, output); + else if (!record && typeof S.attachInspect === 'function') S.attachInspect(block, output, inspectClaims); } function renderPendingResult(block) { @@ -1478,6 +1479,7 @@ export const MAX_SUBAGENT_LOG_LINES = 200; let historyHost = null; // detached container a window renders into let historyIdx = null; // message index stamped on each row let historyRecord = true; // feed tool results to the Activity panel +let inspectClaims = null; // Activity items rebound during one Show earlier function historyTarget() { return historyHost || messagesEl; @@ -1488,16 +1490,10 @@ function historyRow(el) { return el; } -// System-initiated user records (bg-wake preamble, bg-notice, -// return-after-break) are not text the operator typed and render nothing. -function syntheticUser(msg) { - return typeof msg.name === 'string' && (msg.name.startsWith('bg-') || msg.name === 'return-after-break'); -} - // historyUnits counts the rows one persisted message renders as. export function historyUnits(msg) { if (!msg) return 0; - if (msg.role === 'user') return syntheticUser(msg) ? 0 : 1; + if (msg.role === 'user') return isSyntheticUser(msg) ? 0 : 1; if (msg.role !== 'assistant') return 0; return (msg.reasoning_content ? 1 : 0) + (msg.content ? 1 : 0) + (Array.isArray(msg.tool_calls) ? msg.tool_calls.length : 0); @@ -1561,27 +1557,33 @@ function historyTurnIdBefore(messages, from) { // recordHistoricalResults feeds tool results outside the rendered window // to the Activity panel in transcript order, so its evidence summary covers -// the session and not only the visible window. The stub block is never +// the session and not only the visible window. Only the newest results the +// panel can hold are collected (walking back from the window), so a huge +// session costs no more than the panel's cap. The stub block is never // attached; "Show in conversation" already skips disconnected blocks. function recordHistoricalResults(messages, from, to) { if (typeof S.recordResult !== 'function') return; - let turnId = historyTurnIdBefore(messages, from); - for (let i = from; i < to; i++) { + const picked = []; + let bytes = 0; + for (let i = to - 1; i >= from && picked.length < ACTIVITY_MAX_RESULTS && bytes <= ACTIVITY_MAX_BYTES; i--) { const msg = messages[i]; - if (!msg) continue; - if (msg.role === 'user') { turnId = msg.turn_id || 'history-' + i; continue; } - if (msg.role !== 'assistant') continue; - historyToolCalls(messages, i).forEach((call) => { - if (call.name === 'delegate_tasks' || !call.result) return; - const stub = document.createElement('div'); - stub.dataset.toolName = call.name; - stub.dataset.toolArgs = call.args; - stub.dataset.outcome = call.outcome || 'unknown'; - stub.dataset.turnId = msg.turn_id || turnId; - stub.dataset.callId = call.id; - S.recordResult(stub, call.result); - }); + if (!msg || msg.role !== 'assistant') continue; + const calls = historyToolCalls(messages, i).filter((call) => call.name !== 'delegate_tasks' && call.result); + for (let k = calls.length - 1; k >= 0; k--) { + picked.push({ index: i, call: calls[k] }); + bytes += String(calls[k].result).length; + } } + picked.reverse().forEach(({ index, call }) => { + const msg = messages[index]; + const stub = document.createElement('div'); + stub.dataset.toolName = call.name; + stub.dataset.toolArgs = call.args; + stub.dataset.outcome = call.outcome || 'unknown'; + stub.dataset.turnId = msg.turn_id || historyTurnIdBefore(messages, index); + stub.dataset.callId = call.id; + S.recordResult(stub, call.result); + }); } // renderHistoryRange renders messages[from, to) into a detached container @@ -1596,7 +1598,7 @@ function renderHistoryRange(messages, from, to) { historyIdx = messageIndex; if (msg.role === 'user') { historyTurnId = msg.turn_id || 'history-' + messageIndex; - if (syntheticUser(msg)) continue; + if (isSyntheticUser(msg)) continue; renderHistoricalUser(historyUserText(msg.content, msg.principal_prompt)); continue; } @@ -1646,12 +1648,19 @@ function settleHistoryMessages(msgs) { }); } -export function renderSessionHistory(messages) { +// renderSessionHistory renders the newest window of a session. With +// { record: false } (a reload of the session already shown) results are not +// recorded again; the Activity items are rebound to the re-rendered steps. +export function renderSessionHistory(messages, opts = {}) { const list = Array.isArray(messages) ? messages : []; + const record = opts.record !== false; hideEmptyState(); const start = historyWindowStart(list, list.length); - recordHistoricalResults(list, 0, start); - const nodes = renderHistoryRange(list, start, list.length); + if (record) recordHistoricalResults(list, 0, start); + historyRecord = record; + inspectClaims = record ? null : new Set(); + let nodes; + try { nodes = renderHistoryRange(list, start, list.length); } finally { historyRecord = true; inspectClaims = null; } nodes.forEach((node) => messagesEl.appendChild(node)); settleHistoryMessages(historyMessages(nodes)); setHistoryBacklog(list, start); @@ -1667,8 +1676,9 @@ export function showEarlierHistory() { const end = backlog.end; const start = historyWindowStart(backlog.messages, end); historyRecord = false; // already recorded when the session loaded + inspectClaims = new Set(); let nodes; - try { nodes = renderHistoryRange(backlog.messages, start, end); } finally { historyRecord = true; } + try { nodes = renderHistoryRange(backlog.messages, start, end); } finally { historyRecord = true; inspectClaims = null; } const msgs = historyMessages(nodes); const kids = Array.from(messagesEl.children || []); const marker = kids.find((el) => hasClass(el, 'transcript-trim')); @@ -1877,6 +1887,39 @@ function checkCollapse(host, opts) { bubble.appendChild(toggle); } +// remeasureFolds re-checks every older bubble after a layout change (window +// or inspector width, reading size, density): one measurement per bubble is +// only valid for the layout it was taken in. +export function remeasureFolds() { + const all = assistantMessages(); + messagesEl.querySelectorAll('.bubble').forEach((b) => { if (b.dataset) delete b.dataset.collapseChecked; }); + messagesEl.querySelectorAll('.msg.user').forEach((msg) => { + const bubble = msg.querySelector('.bubble'); + if (bubble) checkCollapse(bubble, { latest: false }); + }); + compactOlderAnswers(all[all.length - 1] || null); +} +// Trailing debounce: an inspector drag changes the layout on every frame. +let foldTimer = null; +let foldWidth = 0; +function scheduleRemeasure() { + clearTimeout(foldTimer); + foldTimer = setTimeout(() => { foldTimer = null; remeasureFolds(); }, 150); +} +if (typeof ResizeObserver === 'function' && messagesEl) { + new ResizeObserver((entries) => { + const w = Math.round(entries[0]?.contentRect?.width || 0); + if (w && foldWidth && w !== foldWidth) scheduleRemeasure(); + foldWidth = w; + }).observe(messagesEl); +} +if (typeof MutationObserver === 'function' && document.documentElement) { + // Reading size is a root custom property; density a body class. + const watch = new MutationObserver(scheduleRemeasure); + watch.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); + if (document.body) watch.observe(document.body, { attributes: true, attributeFilter: ['class'] }); +} + // compactOlderAnswers folds every assistant bubble except `keep` (the // current latest). Call when a new assistant reply opens so history // shrinks and the live answer stays full-height. diff --git a/cmd/odek/ui/js/results.js b/cmd/odek/ui/js/results.js index 903dcbb3..7438a7c1 100644 --- a/cmd/odek/ui/js/results.js +++ b/cmd/odek/ui/js/results.js @@ -4,6 +4,9 @@ import { escapeHtml } from './escape.js'; import { toolView, renderToolItems, treeText } from './toolviews.js'; export const RESULT_PAGE = 200; +// The Activity list keeps at most this many results / bytes of output. +export const ACTIVITY_MAX_RESULTS = 300; +export const ACTIVITY_MAX_BYTES = 16 * 1024 * 1024; export function resultText(output) { let text = String(output || ''); // Persisted tool messages include a nonce-matched model-facing frame. diff --git a/cmd/odek/ui/js/sessions.js b/cmd/odek/ui/js/sessions.js index 19b3248a..2ae0520f 100644 --- a/cmd/odek/ui/js/sessions.js +++ b/cmd/odek/ui/js/sessions.js @@ -286,7 +286,7 @@ export function newSession() { // agent (buffer restore) via session_switch, then renders the transcript // from the REST detail. Falls back to the legacy piggyback-on-prompt path // when the socket is down. -export async function loadAndRenderSession(sid, opts = {}) { +export async function loadAndRenderSession(sid) { // F-A2: same mid-run guard as newSession — ask, cancel through the stop // control, and only then bootstrap the switch (which fetches). if (S.busy) { @@ -357,10 +357,7 @@ export async function loadAndRenderSession(sid, opts = {}) { renderSessionHistory(messages); S.restoreDecisionReceipts?.(sess.decisions || []); - // A reload asked for from the trim marker wants the earlier rows: land - // at the top of the window, beside "Show earlier", not at the end. - if (opts.atTop) { S.stickBottom = false; messagesEl.scrollTop = 0; } - else forceScrollBottom(); + forceScrollBottom(); showToast('Session loaded'); announce('Session loaded'); if (typeof S.closePanels === 'function') S.closePanels(); @@ -370,10 +367,26 @@ export async function loadAndRenderSession(sid, opts = {}) { } // The transcript-trim marker's reload: rows trimmed from a live turn exist -// only in the persisted session. Never while a turn runs. -S.reloadSession = () => { +// only in the persisted session. It refetches and re-renders the transcript +// alone — the connection's agent, queue and plan are left as they are — and +// lands at the top of the new window, beside "Show earlier". Never while a +// turn runs. +S.reloadSession = async () => { if (S.busy || !S.sessionId) return; - loadAndRenderSession(S.sessionId, { atTop: true }); + const sid = S.sessionId; + const loadVersion = ++sessionLoadVersion; + try { + const sess = await getSession(sid, getSessionToken(sid) || undefined); + if (loadVersion !== sessionLoadVersion || S.busy || S.sessionId !== sid) return; + resetTurnState(); + messagesEl.innerHTML = ''; + resetTranscriptTrim(); + renderSessionHistory(sess.messages || [], { record: false }); + S.stickBottom = false; + messagesEl.scrollTop = 0; + } catch { + showToast('Error reloading history'); + } }; // ── Session Rename ── diff --git a/cmd/odek/ui/js/transcript.js b/cmd/odek/ui/js/transcript.js index fa56c6b1..58508621 100644 --- a/cmd/odek/ui/js/transcript.js +++ b/cmd/odek/ui/js/transcript.js @@ -12,7 +12,7 @@ // rows trimmed from a live turn come back by reloading the persisted session. import { S } from './state.js'; import { messagesEl } from './dom.js'; -import { hasClass, nodeHolds } from './utils.js'; +import { hasClass, nodeHolds, isSyntheticUser } from './utils.js'; export const MAX_UNITS = 300; // units kept while the view follows the bottom export const HARD_UNITS = 600; // ceiling while the operator reads further up @@ -36,6 +36,10 @@ function isLive(el) { return refs.some((ref) => nodeHolds(el, ref)); } +// Set when a trim while reading removed nothing; cleared by any scroll. +let parked = false; +messagesEl?.addEventListener?.('scroll', () => { parked = false; }, { passive: true }); + export function countUnits() { let n = 0; for (const el of kids(messagesEl)) { @@ -54,6 +58,7 @@ export function trimState() { // whenever the transcript is cleared (new session, session load). export function resetTranscriptTrim() { S.transcriptTrim = { live: 0, backlog: null }; + parked = false; const marker = findMarker(); if (marker) marker.remove(); } @@ -88,6 +93,9 @@ export function pruneMessages() { if (units <= MAX_UNITS) return; const reading = !S.stickBottom; if (reading && units <= HARD_UNITS) return; + // A reader parked where nothing above the view can go: skip the layout + // reads until they scroll (or the absolute ceiling is reached). + if (reading && parked && units <= ABSOLUTE_UNITS) return; const state = trimState(); // While reading, only rows wholly above the view leave, so the row being @@ -117,19 +125,23 @@ export function pruneMessages() { return true; }; - const trim = () => { for (const el of kids(messagesEl)) { + const trim = () => { rows: for (const el of kids(messagesEl)) { if ((units <= MAX_UNITS && lastHidx == null) || blocked) break; if (isStream(el)) { + let stopped = false; for (const row of kids(el)) { - if (!drop(row) && (units <= MAX_UNITS || blocked)) break; + if (!drop(row) && (units <= MAX_UNITS || blocked)) { stopped = true; break; } } if (!kids(el).length && el !== S.turnStreamEl) el.remove(); + if (stopped) break rows; } else if (isTopUnit(el)) { if (!drop(el) && (units <= MAX_UNITS || blocked)) break; } } }; + const before = units; if (reading) holdView(anchor, trim); else trim(); + parked = reading && units === before; if (removedHistory && state.backlog) { const first = firstHistoryIndex(); @@ -206,7 +218,10 @@ export function holdView(anchor, change) { function earlierTurns(backlog) { if (backlog.turnsEnd !== backlog.end) { let n = 0; - for (let i = 0; i < backlog.end; i++) if (backlog.messages[i] && backlog.messages[i].role === 'user') n++; + for (let i = 0; i < backlog.end; i++) { + const m = backlog.messages[i]; + if (m && m.role === 'user' && !isSyntheticUser(m)) n++; + } backlog.turns = n; backlog.turnsEnd = backlog.end; } diff --git a/cmd/odek/ui/js/transcript.test.js b/cmd/odek/ui/js/transcript.test.js index 032a9cf5..ff4164e8 100644 --- a/cmd/odek/ui/js/transcript.test.js +++ b/cmd/odek/ui/js/transcript.test.js @@ -569,3 +569,116 @@ test('Show in conversation reaches a step re-rendered by Show earlier', async () assert.deepEqual(jumped, ['k0'], 'the Activity item now points at the visible step, not the load-time stub'); S.clearResults(); }); + +test('an interrupted turn leaves no reasoning row marked live', () => { + S.currentTurnId = 't-esc'; + render.streamThinking('pondering'); + const block = messages.querySelector('.thinking-block'); + assert.ok(block.classList.contains('live')); + render.endStream('interrupted'); + assert.equal(block.classList.contains('live'), false, 'the budget may trim it now'); +}); + +test('folds are re-measured after a layout change', () => { + render.addMessage('assistant', 'first'); + const bubble = messages.querySelector('.msg.assistant').querySelector('.bubble'); + let height = 100; + Object.defineProperty(bubble.querySelector('.content'), 'scrollHeight', { get: () => height }); + render.addMessage('assistant', 'second'); + assert.equal(bubble.classList.contains('collapsible'), false); + height = 900; // e.g. reading size raised + render.addMessage('assistant', 'third'); + assert.equal(bubble.classList.contains('collapsible'), false, 'measured once, not on every reply'); + render.remeasureFolds(); + assert.ok(bubble.classList.contains('collapsible'), 'layout change re-measures'); +}); + +test('identical results in one turn rebind to distinct Activity items', async () => { + S.recordResult = wsHooks.record; + S.attachInspect = wsHooks.attach; + S.clearResults(); + byId['output-list'].children.length = 0; + const stub = () => { const b = document.createElement('div'); Object.assign(b.dataset, { toolName: 'shell', toolArgs: '{}', turnId: 'h', callId: '', outcome: 'completed' }); return b; }; + S.recordResult(stub(), 'same'); + S.recordResult(stub(), 'same'); + const blocks = [0, 1].map(() => { + const b = document.createElement('div'); b.className = 'tool-block'; + b.innerHTML = '
'; + Object.assign(b.dataset, { toolName: 'shell', toolArgs: '{}', turnId: 'h', callId: '' }); + return b; + }); + const claims = new Set(); + blocks.forEach((b) => S.attachInspect(b, 'same', claims)); + blocks.forEach((b) => messages.appendChild(b)); + await new Promise((r) => setTimeout(r, 5)); + const jumped = []; + blocks.forEach((b, i) => { b.scrollIntoView = () => jumped.push(i); }); + for (const item of byId['output-list'].querySelectorAll('.output-item')) { + byId['output-detail'].children.length = 0; + item.click(); + byId['output-detail'].querySelectorAll('button').find(b => b.textContent === 'Show in conversation').click(); + } + assert.deepEqual(jumped, [0, 1], 'each step owns its own item'); + S.clearResults(); +}); + +test('the earlier-turns count ignores system-initiated records', () => { + const list = []; + for (let t = 0; t < 40; t++) { + list.push({ role: 'user', content: 'q' + t }); + list.push({ role: 'user', name: 'bg-wake', content: 'wake' }); + for (let i = 0; i < 4; i++) list.push({ role: 'assistant', content: 'a' + i }); + } + render.renderSessionHistory(list); + const end = transcript.trimState().backlog.end; + const real = list.slice(0, end).filter(m => m.role === 'user' && !m.name).length; + assert.match(marker().querySelector('.tt-text').textContent, new RegExp('^' + real + ' earlier turns')); +}); + +test('session load records at most the Activity cap of earlier results, newest first kept in order', () => { + const recorded = []; + S.recordResult = (block, output) => recorded.push(output); + const list = session(400); // 400 results, ~370 before the window + render.renderSessionHistory(list); + const windowResults = rows().filter(r => r.classList.contains('tool-block')).length; + assert.ok(recorded.length <= 300 + windowResults, 'bounded by the panel cap: ' + recorded.length); + const all = list.filter(m => m.role === 'tool').map(m => m.content); + assert.deepEqual(recorded, all.slice(all.length - recorded.length), 'the newest results, in transcript order'); +}); + +test('a reader parked at the top costs no layout reads until they scroll', () => { + S.stickBottom = false; + let reads = 0; + try { + for (let i = 0; i < transcript.HARD_UNITS; i++) finishedTool(i); // at the ceiling, no trim yet + messages.getBoundingClientRect = () => { reads++; return { top: 0 }; }; + const onScreen = function () { reads++; return { top: 0, bottom: 10 }; }; + FakeEl.prototype.getBoundingClientRect = onScreen; // every row on screen + finishedTool('a'); + const after = reads; + assert.ok(after > 0, 'first trim attempt measured'); + finishedTool('b'); finishedTool('c'); + assert.equal(reads, after, 'parked: no further layout reads'); + messages.dispatch('scroll'); + finishedTool('d'); + assert.ok(reads > after, 'a scroll re-arms the check'); + } finally { + delete messages.getBoundingClientRect; + delete FakeEl.prototype.getBoundingClientRect; + } +}); + +test('reload re-renders the transcript without switching sessions or re-recording', async () => { + const sent = []; + S.ws = { readyState: 1, send: (m) => sent.push(m) }; + const recorded = []; + S.recordResult = (b, o) => recorded.push(o); + globalThis.fetch = async () => ({ ok: true, status: 200, headers: { get: (h) => (h === 'Content-Type' ? 'application/json' : null) }, json: async () => ({ messages: session(3) }), text: async () => '' }); + render.renderSessionHistory([]); + await S.reloadSession(); + assert.equal(sent.length, 0, 'no session_switch'); + assert.equal(recorded.length, 0, 'Activity not recorded again'); + assert.ok(rows().some(r => r.classList.contains('tool-block')), 'history re-rendered'); + assert.equal(messages.scrollTop, 0); + S.ws = null; +}); diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index c074f5dc..2541485c 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -89,6 +89,12 @@ export function teach(key, text) { showToast(String(text).replace(/^tip:\s*/i, ''), HINT_TTL_MS, 'tip'); } +// System-initiated user records (bg-wake preamble, bg-notice, +// return-after-break) are not text the operator typed. +export function isSyntheticUser(msg) { + return !!msg && typeof msg.name === 'string' && (msg.name.startsWith('bg-') || msg.name === 'return-after-break'); +} + // ── DOM helpers (tolerant of the test suites' minimal DOM shims) ── export function hasClass(el, name) { if (!el) return false; diff --git a/cmd/odek/ui/js/workspace.js b/cmd/odek/ui/js/workspace.js index dbf564bf..5b7a91fc 100644 --- a/cmd/odek/ui/js/workspace.js +++ b/cmd/odek/ui/js/workspace.js @@ -1,6 +1,6 @@ // Workspace navigation, per-session drafts and the inspectable result collection. import { S } from './state.js'; -import { renderResult, resultKind } from './results.js'; +import { renderResult, resultKind, ACTIVITY_MAX_RESULTS, ACTIVITY_MAX_BYTES } from './results.js'; import { toolPreview } from './toolviews.js'; import { openTab } from './commands.js'; import { summarizeEvidence } from './evidence.js'; @@ -78,11 +78,13 @@ function addInspectButton(block, item) { // action without recording it again: the Activity list already holds the // result in transcript order from the session load. That item is rebound to // the visible step so "Show in conversation" reaches it. -S.attachInspect = (block, output) => { +// `claimed` holds the items already rebound in this render, so identical +// results within a turn (no call ids, same output) bind one step each. +S.attachInspect = (block, output, claimed) => { const text = String(output || ''); const callId = block.dataset.callId || '', turnId = block.dataset.turnId || ''; - const item = outputs.find((o) => !o.block.isConnected && o.callId === callId && o.turnId === turnId && o.output === text); - if (item) item.block = block; + const item = outputs.find((o) => !o.block.isConnected && !(claimed && claimed.has(o)) && o.callId === callId && o.turnId === turnId && o.output === text); + if (item) { item.block = block; claimed?.add(item); } addInspectButton(block, item || resultItem(block, output)); }; S.recordResult = (block, output) => { @@ -90,7 +92,7 @@ S.recordResult = (block, output) => { item.stepId=(S.plan?.steps || []).find(step=>step.status==='in_progress')?.id || ''; outputs.push(item);outputBytes+=item.output.length; let pruned=false; - while(outputs.length>300 || outputBytes>16*1024*1024){outputBytes-=outputs.shift().output.length;pruned=true;} + while(outputs.length>ACTIVITY_MAX_RESULTS || outputBytes>ACTIVITY_MAX_BYTES){outputBytes-=outputs.shift().output.length;pruned=true;} if(pruned)S.activityPruned=true; schedulePaintResults(); addInspectButton(block, item); diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 5645c6bf..c77d727c 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -990,7 +990,7 @@ match as plain text. The bundled WebUI implements this in - **Design**: self-contained themes — Match system (the default: Ember dark or Porcelain light, following the OS colour scheme live), Ember, Porcelain, Midnight and High contrast. Locally bundled Geist serves reading/interface text, Space Grotesk the display headline and Geist Mono code (ligatures off, 4-space tabs). Reading text defaults to 15px (17/19px selectable); compact density tightens the interface scale. One header row on every width (52px, 44px in compact density); below 1100px the session rail and inspector are drawers with a scrim that closes them, from 1100px they dock as columns and the inspector is resizable. One token set per theme defines colours, spacing, radii and type; reduced motion is respected. Structured tool views render `glob` as a file list and `tree` as an indented outline (Raw shows the original JSON). No CDN or font network request is required. - **Streaming**: fragments (`token_delta`/`thinking_delta`) and bulk `token` events share one rAF-batched render pipeline - **DOM budget** (`js/transcript.js`): the transcript is capped at 300 rendered rows (`MAX_UNITS`), counting every row inside a turn's stream as well as top-level messages, so one long turn with hundreds of tool steps stays bounded. The oldest rows are trimmed first; live work (the streaming answer, open reasoning, running sub-agents, tool steps still waiting for a result) is never trimmed. While you are scrolled up reading, trimming waits until 600 rows, then removes only rows wholly above the screen and keeps your view in place (past 1,200 rows the oldest go regardless). A note at the top says when earlier rows are hidden -- **Long sessions**: opening a session renders only its newest ~150 rows; **Show earlier** at the top renders the previous window above, without moving what is on screen. Rows trimmed from a live turn come back with **Reload earlier history** once the turn finishes (the session is persisted every step; the reload opens at the top of its window, beside **Show earlier**). Tool output is rendered when a step is first expanded, reasoning blocks keep their newest 400 lines and sub-agent chip logs their newest 200; the persisted session always holds everything +- **Long sessions**: opening a session renders only its newest ~150 rows; **Show earlier** at the top renders the previous window above, without moving what is on screen. Rows trimmed from a live turn come back with **Reload earlier history** once the turn finishes (the session is persisted every step; the reload re-renders the transcript only — the connection, queue and plan are untouched — and opens at the top of its window, beside **Show earlier**). Long replies fold once measured and are re-measured when the window, inspector width, reading size or density changes. Tool output is rendered when a step is first expanded, reasoning blocks keep their newest 400 lines and sub-agent chip logs their newest 200; the persisted session always holds everything - **Resilience**: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s `keepalive`. A drop is visible: amber top-bar word, sticky `#conn-banner` with retry countdown, one transcript line per outage, and a composer toast if you send while down. - **Tests**: `node --test cmd/odek/ui/js/` (markdown + untrusted-envelope goldens, and api.js request-shape E2E against a mocked fetch) plus Go-side WebUI E2E (`cmd/odek/webui_e2e_test.go`): asset/header/CSP contract, token injection, JS↔HTML id and JS↔CSS class contracts, and full client journeys (streamed WS run, headless run with the remote-approval bridge, kick, pin/export) through the production mux (`newServeMux` — the same constructor `serveCmd` uses, so tests cannot drift from the real mounting)