Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
466 changes: 369 additions & 97 deletions cmd/odek/ui/js/render.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions cmd/odek/ui/js/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions cmd/odek/ui/js/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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);
Expand All @@ -363,6 +366,29 @@ export async function loadAndRenderSession(sid) {
}
}

// The transcript-trim marker's reload: rows trimmed from a live turn exist
// 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;
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 ──
// Inline edit: swaps the task label for an input; Enter commits, Esc
// cancels, blur commits. No window.prompt.
Expand Down
292 changes: 292 additions & 0 deletions cmd/odek/ui/js/transcript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
// 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';
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
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'];

function kids(el) {
return el && el.children ? Array.from(el.children) : [];
}

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) => 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)) {
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 };
parked = false;
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.
// 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 = { messages, end };
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;
// 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
// 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;
if (isLive(row)) return false;
row.remove();
units--;
if (hidx != null && hidx !== '') { lastHidx = hidx; removedHistory = true; }
else { lastHidx = null; state.live++; }
return true;
};

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)) { 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();
state.backlog.end = first >= 0 ? first : state.backlog.messages.length;
}
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;
}

// 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. 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 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 rows[lo];
}

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.
function earlierTurns(backlog) {
if (backlog.turnsEnd !== backlog.end) {
let n = 0;
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;
}
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';
text.setAttribute('aria-live', 'polite');
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');
let label, action, caption, disabled;
if (state.live) {
label = 'Earlier activity is hidden to keep the page responsive.';
caption = S.busy ? 'Reload earlier history after this turn' : 'Reload earlier history';
disabled = !!S.busy || !S.sessionId;
action = 'reload';
} else {
const turns = earlierTurns(backlog);
label = turns
? turns + (turns === 1 ? ' earlier turn is' : ' earlier turns are') + ' not shown.'
: 'Earlier activity is not shown.';
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) => {
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?.();
});
Loading
Loading