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
113 changes: 113 additions & 0 deletions lib/history-visibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* history-visibility.js — shared source of truth for classifying a recorded
* history event as "renders a visible message" vs. an invisible-yield event.
* (lr-c24b)
*
* This is the CJS backend copy, used by the load_more_history page-window
* extension in lib/sessions.js / lib/project-sessions.js. The ES module
* (browser) copy lives at lib/public/modules/history-visibility.js and must
* be kept in sync with the render dispatch in lib/public/modules/app-messages.js
* (the switch(msg.type) cases starting around line 67). A test
* (test/history-visibility-parity.test.js) asserts both copies produce
* identical classifications so they cannot silently drift.
*
* Background: pagination pages by RAW history-event count
* (sessions.js HISTORY_PAGE_SIZE), but the user perceives rendered message
* bubbles. Long agentic turns are dominated by invisible-yield events (todo/
* task-list bookkeeping, hidden plan tools, state-only events, thinking
* deltas) — a page made entirely of these renders zero visible bubbles.
* isVisibleHistoryEvent() lets callers extend a page window until it
* contains at least one event that will actually render something.
*
* Keep this module dependency-light: no project imports, no Node built-ins.
* That constraint is what lets it stay close to the browser copy.
*/

// Tool names that render as hidden bookkeeping widgets (todo/task list state,
// plan-mode banners, the ask-user-questions card is rendered by its own
// dedicated tool_executing branch, not the generic tool item) — mirrors
// TODO_TOOLS / PLAN_MODE_TOOLS in lib/public/modules/tools.js and the
// tool_start branch in lib/public/modules/app-messages.js.
var HIDDEN_TOOL_NAMES = {
TodoWrite: 1,
TaskCreate: 1,
TaskUpdate: 1,
TaskList: 1,
TaskGet: 1,
EnterPlanMode: 1,
ExitPlanMode: 1,
ask_user_questions: 1,
};

// Event types that never render a new visible message/bubble on their own —
// they update existing UI state (status text, badges, panels) or are pure
// internal bookkeeping. Matches the case branches in app-messages.js that
// call a state setter / no-op rather than a DOM-inserting render helper.
var INVISIBLE_TYPES = {
message_uuid: 1,
session_id: 1,
status: 1,
compacting: 1,
thinking_start: 1,
thinking_delta: 1,
thinking_stop: 1,
ask_user_answered: 1,
permission_request: 1,
permission_request_pending: 1,
permission_cancel: 1,
permission_resolved: 1,
elicitation_request: 1,
elicitation_resolved: 1,
context_usage: 1,
fast_mode_state: 1,
rate_limit: 1,
rate_limit_usage: 1,
subagent_activity: 1,
subagent_tool: 1,
subagent_done: 1,
task_started: 1,
task_progress: 1,
task_updated: 1,
done: 1,
digest_checkpoint: 1,
};

// Event types whose visibility depends on the tool name carried in the event.
var TOOL_NAME_TYPES = {
tool_start: 1,
tool_executing: 1,
};

/**
* Returns true when `entry` (a single session.history[] item / WS message)
* causes app-messages.js's processMessage() to insert a new visible element
* into the message list. Returns false for state-only or hidden-bookkeeping
* events — the invisible-yield set that can dominate a raw-event-count page.
*
* entry is intentionally treated defensively: unknown/missing type defaults
* to visible (safe default — never under-counts and can't create a new
* zero-visible page shape that this module wasn't already guarding against).
*/
function isVisibleHistoryEvent(entry) {
if (!entry || typeof entry.type !== "string") return true;
var type = entry.type;

if (INVISIBLE_TYPES[type]) return false;

if (TOOL_NAME_TYPES[type]) {
return !HIDDEN_TOOL_NAMES[entry.name];
}

// tool_result carries only an id (no name) — it updates an existing tool
// card rather than inserting a new one, so it is never itself the reason
// a page becomes visible.
if (type === "tool_result") return false;

return true;
}

module.exports = {
HIDDEN_TOOL_NAMES: HIDDEN_TOOL_NAMES,
INVISIBLE_TYPES: INVISIBLE_TYPES,
isVisibleHistoryEvent: isVisibleHistoryEvent,
};
7 changes: 7 additions & 0 deletions lib/project-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ function attachSessions(ctx) {
var before = msg.before;
var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE;
var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom));
// Extend backward turn-boundary by turn-boundary (bounded — see
// extendWindowForVisibility) until the page contains at least one
// visibly-rendering event. Otherwise a page landing entirely on
// invisible-yield events (todo/task bookkeeping, hidden plan tools,
// state events, thinking deltas) advances historyFrom but renders
// nothing — "Load earlier" appears to do nothing (lr-c24b).
from = sm.extendWindowForVisibility(session.history, from, before);
var to = before;
var items = session.history.slice(from, to).map(hydrateImageRefs);
sendTo(ws, {
Expand Down
78 changes: 68 additions & 10 deletions lib/public/modules/app-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ export function updateHistorySentinel() {
var messagesEl = getMessagesEl();
var existing = messagesEl.querySelector(".history-sentinel");
if (store.get('historyFrom') > 0) {
// Never render a solitary sentinel (lr-c24b): if there is no visible
// message currently rendered below where the sentinel would sit, a lone
// "Load earlier messages" button with nothing above it is not a useful
// affordance — trigger a load instead of displaying it. This can happen
// after a prepend page advanced historyFrom without rendering any
// visible content. Skipped mid-initial-replay (replayingHistory): that
// path's own history_meta -> item stream -> history_done sequence is
// already populating #messages and has not finished yet, so an empty
// container at this instant is expected, not a stuck page.
var hasRenderedContent = !!(existing ? existing.nextElementSibling : messagesEl.firstElementChild);
if (!hasRenderedContent && !store.get('replayingHistory')) {
if (existing) existing.remove();
if (historySentinelObserver) { historySentinelObserver.disconnect(); historySentinelObserver = null; }
if (!store.get('loadingMore')) requestMoreHistory();
return;
}
if (!existing) {
var sentinel = document.createElement("div");
sentinel.className = "history-sentinel";
Expand All @@ -153,15 +169,26 @@ export function updateHistorySentinel() {
}
}

// Sends the load_more_history request. Assumes the caller has already
// validated preconditions (ws connected, historyFrom > 0) and set
// loadingMore — shared by requestMoreHistory (guarded, user/observer-
// triggered) and prependOlderHistory's auto-advance (lr-c24b), which is
// already mid-load and must not be blocked by the loadingMore guard.
function sendLoadMoreHistory(before) {
var ws = getWs();
if (!ws) return;
var messagesEl = getMessagesEl();
var btn = messagesEl.querySelector(".load-more-btn");
if (btn) btn.classList.add("loading");
ws.send(JSON.stringify({ type: "load_more_history", before: before }));
}

export function requestMoreHistory() {
var ws = getWs();
var s = store.snap();
if (s.loadingMore || s.historyFrom <= 0 || !ws || !s.connected) return;
store.set({ loadingMore: true });
var messagesEl = getMessagesEl();
var btn = messagesEl.querySelector(".load-more-btn");
if (btn) btn.classList.add("loading");
ws.send(JSON.stringify({ type: "load_more_history", before: s.historyFrom }));
sendLoadMoreHistory(s.historyFrom);
}

export function prependOlderHistory(items, meta) {
Expand Down Expand Up @@ -193,6 +220,16 @@ export function prependOlderHistory(items, meta) {
var anchorEl = getPrependAnchor();
var anchorOffset = anchorEl ? anchorEl.getBoundingClientRect().top : 0;

// Snapshot the sibling immediately before the anchor (or the last child,
// when the container was empty) so the batch's visible yield can be
// measured after rendering (lr-c24b). addToMessages() (app-rendering.js)
// is the single choke point for top-level #messages insertion during
// processMessage(), and it always inserts immediately before the current
// prepend anchor — so any new node from this batch appears between this
// snapshot and anchorEl. anchorEl itself is never removed/replaced by the
// rendering pipeline, so it remains a stable reference across the batch.
var siblingBeforeAnchor = anchorEl ? anchorEl.previousSibling : messagesEl.lastChild;

// Process each item through the rendering pipeline
for (var i = 0; i < items.length; i++) {
processMessage(items[i]);
Expand All @@ -204,6 +241,13 @@ export function prependOlderHistory(items, meta) {
// Clear prepend mode
setPrependAnchor(null);

// Did this page render any visible DOM? If the sibling immediately before
// the anchor (or the container's last child, for an initially-empty
// container) is unchanged, the batch inserted nothing new.
var renderedNoVisibleContent = anchorEl
? anchorEl.previousSibling === siblingBeforeAnchor
: messagesEl.lastChild === siblingBeforeAnchor;

// Restore saved state
store.set({ currentMsgEl: savedMsgEl, currentFullText: savedFullText });
resetCurrentFullText(savedFullText);
Expand All @@ -223,7 +267,7 @@ export function prependOlderHistory(items, meta) {
}

// Update state
store.set({ historyFrom: meta.from, loadingMore: false });
store.set({ historyFrom: meta.from });

// Renumber data-turn attributes in DOM order
var turnEls = messagesEl.querySelectorAll("[data-turn]");
Expand All @@ -232,12 +276,26 @@ export function prependOlderHistory(items, meta) {
}
setTurnCounter(turnEls.length);

// Update sentinel
if (meta.hasMore) {
var btn = messagesEl.querySelector(".load-more-btn");
if (btn) btn.classList.remove("loading");
// Auto-advance when this page rendered nothing visible (lr-c24b): the
// server already extends the window by turn boundary to surface a visible
// event when possible, but a bounded extension can still exhaust its step
// cap on a pathologically long invisible-yield run. One user click (or one
// sentinel auto-load) must always surface >=1 message when older history
// exists — settle loadingMore:false only once something rendered, or there
// is nothing left to load.
if (renderedNoVisibleContent && meta.hasMore) {
// loadingMore stays true; the next history_prepend response re-enters
// this function and re-evaluates.
sendLoadMoreHistory(meta.from);
} else {
updateHistorySentinel();
store.set({ loadingMore: false });
// Update sentinel
if (meta.hasMore) {
var btn = messagesEl.querySelector(".load-more-btn");
if (btn) btn.classList.remove("loading");
} else {
updateHistorySentinel();
}
}

// Notify in-session search that history was prepended (for pending scroll targets)
Expand Down
98 changes: 98 additions & 0 deletions lib/public/modules/history-visibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* history-visibility.js — shared source of truth for classifying a recorded
* history event as "renders a visible message" vs. an invisible-yield event.
* (lr-c24b)
*
* This is the ES module (browser) copy, used by app-header.js's
* prependOlderHistory / updateHistorySentinel to decide whether a prepended
* page surfaced any visible content. The CJS backend copy lives at
* lib/history-visibility.js. A test (test/history-visibility-parity.test.js)
* asserts both copies produce identical classifications so they cannot
* silently drift.
*
* When adding a new WS message type: check whether processMessage() in
* app-messages.js inserts a new visible element for it, then update BOTH
* this file and lib/history-visibility.js accordingly. The parity test will
* fail if they disagree.
*/

// Tool names that render as hidden bookkeeping widgets (todo/task list state,
// plan-mode banners) — mirrors TODO_TOOLS / PLAN_MODE_TOOLS in tools.js and
// the tool_start branch in app-messages.js.
export var HIDDEN_TOOL_NAMES = {
TodoWrite: 1,
TaskCreate: 1,
TaskUpdate: 1,
TaskList: 1,
TaskGet: 1,
EnterPlanMode: 1,
ExitPlanMode: 1,
ask_user_questions: 1,
};

// Event types that never render a new visible message/bubble on their own —
// they update existing UI state (status text, badges, panels) or are pure
// internal bookkeeping. Matches the case branches in app-messages.js that
// call a state setter / no-op rather than a DOM-inserting render helper.
export var INVISIBLE_TYPES = {
message_uuid: 1,
session_id: 1,
status: 1,
compacting: 1,
thinking_start: 1,
thinking_delta: 1,
thinking_stop: 1,
ask_user_answered: 1,
permission_request: 1,
permission_request_pending: 1,
permission_cancel: 1,
permission_resolved: 1,
elicitation_request: 1,
elicitation_resolved: 1,
context_usage: 1,
fast_mode_state: 1,
rate_limit: 1,
rate_limit_usage: 1,
subagent_activity: 1,
subagent_tool: 1,
subagent_done: 1,
task_started: 1,
task_progress: 1,
task_updated: 1,
done: 1,
digest_checkpoint: 1,
};

// Event types whose visibility depends on the tool name carried in the event.
var TOOL_NAME_TYPES = {
tool_start: 1,
tool_executing: 1,
};

/**
* Returns true when `entry` (a single session.history[] item / WS message)
* causes app-messages.js's processMessage() to insert a new visible element
* into the message list. Returns false for state-only or hidden-bookkeeping
* events — the invisible-yield set that can dominate a raw-event-count page.
*
* entry is intentionally treated defensively: unknown/missing type defaults
* to visible (safe default — never under-counts and can't create a new
* zero-visible page shape that this module wasn't already guarding against).
*/
export function isVisibleHistoryEvent(entry) {
if (!entry || typeof entry.type !== "string") return true;
var type = entry.type;

if (INVISIBLE_TYPES[type]) return false;

if (TOOL_NAME_TYPES[type]) {
return !HIDDEN_TOOL_NAMES[entry.name];
}

// tool_result carries only an id (no name) — it updates an existing tool
// card rather than inserting a new one, so it is never itself the reason
// a page becomes visible.
if (type === "tool_result") return false;

return true;
}
Loading
Loading