Release: lazy-render collapsed worklog rows w/ post-process + disclosure preservation (#5860, #5839) (#5866)

* fix(#5839): post-process + restore disclosure on lazily-materialized worklog rows

Gate (live Chromium) found the deferred-render path skipped two things the eager
path does: (1) materialized Markdown rows bypassed postProcessRenderedMessages ->
lost syntax highlight / copy button / mermaid / katex / trees; (2) detail-disclosure
(open tool/thinking card + scroll) was lost across rebuilds because restore ran
before deferred rows existed. _materializeDeferredWorklogRows now rAF-schedules
_postProcessWithAnchorSuppression + re-applies stashed disclosure state; the rebuild
restore pass stashes the captured state on still-deferred groups. +2 regression tests.

* changelog: #5860 lazy-worklog frontend fix (#5839, #4633)

---------

Co-authored-by: t <a@b>
This commit is contained in:
nesquena-hermesandt authored and GitHub committed 2026-07-09 19:25:11 -07:00
1 parent e4342a65cf
commit 803b081506
3 files changed
+202 -1

No files matched your search

+2
View File
@@ -11,6 +11,8 @@
### Fixed
- **The activity worklog no longer freezes the browser on reasoning-heavy history (#4633 frontend half).** A settled turn's activity worklog (the reasoning + tool steps behind the "Processed Xm Ys" chip) can carry 80+ rows; in compact-worklog mode (the default) every collapsed group still eagerly built all its row DOM, so a long history ballooned the DOM and a later synchronous layout could tip the tab into a multi-GB freeze. Collapsed settled worklogs now defer their row DOM until first expand. Expanded rows still get full post-processing (syntax highlighting, copy buttons, Mermaid, KaTeX, structured trees) and their open/scroll state is preserved across transcript rebuilds — matching (and, for nested-card scroll, improving on) the eager path. Thanks @nesquena-hermes. (#5860, #5839, #4633)
- **Server memory growth on reasoning-heavy sessions is fixed (#4633 backend root cause).** Reasoning-heavy sessions accumulate 250–480 KB of `anchor_activity_scenes` that were serialized *before* the `messages` array in the session sidecar, so the cheap 64 KB metadata-prefix read always overflowed and fell back to a full parse on every read — driving memory growth. The scene data is now serialized *after* `messages`, so the metadata prefix stays small (≈2 KB) and modern reads need no full parse. Existing session files in the old layout still load correctly (one authoritative parse, then cached), and round-trips preserve scene content exactly. Thanks @nesquena-hermes. (#5858, #5854, #4633)
- **Uploading an Office document now writes a Markdown sidecar so the agent can read its text.** When you upload a `.docx`, `.xlsx`, or `.pptx` to a workspace, a best-effort `<name>.md` sidecar with the extracted text is written alongside it (reusing the existing Office-preview extractor — no new dependency). The write is path-contained (`O_NOFOLLOW` openat-walk within the workspace), never overwrites an existing `.md` (`O_CREAT|O_EXCL`), and is best-effort — a sidecar failure leaves the uploaded Office file fully intact. Thanks @rodboev. (#5734, #540)
+96 -1
View File
@@ -11039,12 +11039,77 @@ function _onLiveActivityToggle(group){
if(group.getAttribute('data-live-tool-call-group')!=='1') return;
_liveActivityUserExpanded = !group.classList.contains('tool-call-group-collapsed');
}
function _materializeDeferredWorklogRows(group){
// #5839: build the row DOM for a settled worklog whose rows were deferred at
// render time (collapsed). Idempotent — clears the marker so it runs once.
if(!group||group.getAttribute('data-worklog-rows-deferred')!=='1') return false;
let rows=group._deferredWorklogRows;
// The JS-property stash is dropped when the transcript is restored from the
// HTML cache (innerHTML round-trip). Recover the rows from the owning message
// via the disclosure key (anchor-scene:<rawIdx>) so a post-restore expand
// still fills the worklog. (#5839)
if((!rows||!rows.length)&&typeof _deferredWorklogRowsFromGroup==='function'){
rows=_deferredWorklogRowsFromGroup(group);
}
group.removeAttribute('data-worklog-rows-deferred');
group._deferredWorklogRows=null;
if(!rows||!rows.length) return false;
const ok=_renderAnchorSceneRowsIntoWorklog(group,rows,{settled:true});
if(!ok) return false;
// #5839 fix: the eager render path post-processes its rows (syntax highlight,
// copy buttons, mermaid, katex, structured trees) and restores detail-disclosure
// state; a lazily-materialized group must do the same or expanded rows render
// un-enhanced and any captured open/scroll state is lost. Post-process on the
// next frame (matching the eager rebuild paths), then re-apply the disclosure
// state stashed with the group at defer time.
const disclosure=group._deferredWorklogDisclosure;
group._deferredWorklogDisclosure=null;
if(typeof _postProcessWithAnchorSuppression==='function'
&& typeof requestAnimationFrame==='function'){
requestAnimationFrame(()=>{
_postProcessWithAnchorSuppression(group);
if(disclosure&&disclosure.size&&typeof _restoreWorklogDetailDisclosureState==='function'){
_restoreWorklogDetailDisclosureState(group, disclosure);
}
});
}else if(disclosure&&disclosure.size&&typeof _restoreWorklogDetailDisclosureState==='function'){
_restoreWorklogDetailDisclosureState(group, disclosure);
}
return true;
}
function _deferredWorklogRowsFromGroup(group){
// Recover a settled worklog's rows from S.messages using the group's
// disclosure key `anchor-scene:<rawIdx>`. Used after an HTML-cache restore
// where the _deferredWorklogRows JS property was dropped. (#5839)
const key=group&&group.getAttribute&&group.getAttribute('data-activity-disclosure-key');
const m=key&&/^anchor-scene:(\d+)$/.exec(key);
if(!m) return null;
const msg=S.messages&&S.messages[Number(m[1])];
const scene=msg&&msg._anchor_activity_scene;
if(!scene) return null;
return _anchorSceneRowsForRendering(scene,{settled:true});
}
function _rehydrateDeferredWorklogsFromCache(root){
// After restoring a transcript from _sessionHtmlCache, deferred settled
// worklogs carry data-worklog-rows-deferred="1" but lost their stashed rows
// (JS properties don't survive innerHTML). Re-stash from the owning message so
// the first expand materializes correctly. (#5839)
if(!root||!root.querySelectorAll) return;
root.querySelectorAll('[data-worklog-rows-deferred="1"]').forEach(group=>{
if(group._deferredWorklogRows&&group._deferredWorklogRows.length) return;
const rows=_deferredWorklogRowsFromGroup(group);
if(rows&&rows.length) group._deferredWorklogRows=rows;
else group.removeAttribute('data-worklog-rows-deferred'); // nothing to defer
});
}
function _toggleActivityGroup(summary){
const group=summary&&summary.closest?summary.closest('.agent-activity-group,.tool-call-group'):null;
if(!group) return;
const collapsed=group.classList.toggle('tool-call-group-collapsed');
group.classList.toggle('open',!collapsed);
summary.setAttribute('aria-expanded',String(!collapsed));
// #5839: materialize deferred settled rows on first expand (lazy render).
if(!collapsed) _materializeDeferredWorklogRows(group);
_writeActivityDisclosureState(group.getAttribute('data-activity-disclosure-key'), !collapsed);
if(typeof _onLiveActivityToggle==='function') _onLiveActivityToggle(group);
}
@@ -12313,6 +12378,24 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
});
if(!group) return false;
group.setAttribute('data-anchor-settled-scene-owner','1');
// #5839: for a COLLAPSED settled worklog, defer building the row DOM until the
// user first expands it. A reasoning-heavy turn can carry 80+ activity rows;
// eagerly materializing them for every historical turn balloons the DOM and a
// later synchronous layout (e.g. opening a dropdown) tips the tab into a
// multi-GB freeze. The summary chip renders from data-turn-duration, not the
// rows, so a deferred worklog still shows its "Processed in Xs" label. On
// expand, _toggleActivityGroup materializes the stashed rows exactly once.
const collapsed=group.classList.contains('tool-call-group-collapsed');
if(collapsed){
group._deferredWorklogRows=rows;
group.setAttribute('data-worklog-rows-deferred','1');
const list=_toolWorklogListEl(group);
if(list) list.innerHTML='';
_syncToolCallGroupSummary(group);
return true;
}
group._deferredWorklogRows=null;
group.removeAttribute('data-worklog-rows-deferred');
return _renderAnchorSceneRowsIntoWorklog(group,rows,{settled:true});
}
function _syncLiveWorklogReasonsForAnchor(anchor, displayTextOverride){
@@ -14103,6 +14186,7 @@ function renderMessages(options){
_messageVirtualWindowKey=renderWindowKey;
_sessionHtmlCacheSid=sid;
_rehydrateTransparentStreamDom(inner);
_rehydrateDeferredWorklogsFromCache(inner);
_wireMessageWindowLoadEarlierButton();
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
@@ -15265,7 +15349,15 @@ function renderMessages(options){
}
}
_restoreWorklogDetailDisclosureState(inner, worklogDetailDisclosureState);
_messageVirtualWindowKey=renderWindowKey;
// #5839 fix: deferred settled worklogs have no rows yet at restore time, so
// the disclosure restore above can't reach their detail elements. Stash the
// captured state on each still-deferred group; _materializeDeferredWorklogRows
// re-applies it (key-scoped + idempotent) once the rows exist on expand.
if(worklogDetailDisclosureState&&worklogDetailDisclosureState.size){
inner.querySelectorAll('[data-worklog-rows-deferred="1"]').forEach(group=>{
group._deferredWorklogDisclosure=worklogDetailDisclosureState;
});
}
// Render per-turn duration and optional token usage on assistant messages.
// Duration stays visible even when token usage is disabled, because it answers
// the basic "how long did that turn take?" UX question. Only walk rendered
@@ -15437,6 +15529,9 @@ function renderMessages(options){
group.classList.add('open');
const summary=group.querySelector('.tool-call-group-summary,.activity-summary');
if(summary) summary.setAttribute('aria-expanded','true');
// #5839: this turn is otherwise blank, so materialize any deferred
// settled rows now that we're force-expanding the worklog to fill it.
if(typeof _materializeDeferredWorklogRows==='function') _materializeDeferredWorklogRows(group);
}
// `revealed` means "this turn has a non-empty Worklog group that the user
// can see" — NOT "we just expanded something". An already-open non-empty
+104
View File
@@ -0,0 +1,104 @@
"""Regression coverage for #5839: lazy settled-worklog DOM (compact mode).
Reasoning-heavy turns can carry 80+ activity rows per scene. The settled
compact-worklog render used to build EVERY row's DOM even when the group is
collapsed, so a long history balloons the DOM and a later synchronous layout
(e.g. opening a dropdown) tips the tab into a multi-GB freeze (#5839).
The fix defers row construction for a COLLAPSED settled worklog until first
expand, recovering the rows from the owning message after an HTML-cache restore
(where the JS-property stash is dropped). These are source-assertion tests in
the same style as the other anchor-scene UI tests (no JS engine needed).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
if src[idx] == "{":
depth += 1
elif src[idx] == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"{name} body not found")
def test_settled_collapsed_worklog_defers_row_dom():
body = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
# A collapsed settled worklog must stash rows + mark deferred + NOT build rows.
assert "group.classList.contains('tool-call-group-collapsed')" in body
assert "group._deferredWorklogRows=rows" in body
assert "data-worklog-rows-deferred" in body
# The expanded/keep-open path still renders eagerly.
assert "_renderAnchorSceneRowsIntoWorklog(group,rows,{settled:true})" in body
def test_toggle_materializes_deferred_rows_on_expand():
body = _function_body(UI_JS, "_toggleActivityGroup")
assert "if(!collapsed) _materializeDeferredWorklogRows(group);" in body
def test_materialize_helper_recovers_rows_after_cache_restore():
body = _function_body(UI_JS, "_materializeDeferredWorklogRows")
# Idempotent: gated on the deferred marker, cleared before building.
assert "data-worklog-rows-deferred" in body
# Falls back to message-recovery when the JS stash was dropped (innerHTML).
assert "_deferredWorklogRowsFromGroup(group)" in body
assert "_renderAnchorSceneRowsIntoWorklog(group,rows,{settled:true})" in body
def test_row_recovery_maps_group_to_message_scene():
body = _function_body(UI_JS, "_deferredWorklogRowsFromGroup")
# Recovers rawIdx from the disclosure key and rebuilds from S.messages.
assert "anchor-scene:" in body
assert "S.messages" in body
assert "_anchor_activity_scene" in body
assert "_anchorSceneRowsForRendering(scene,{settled:true})" in body
def test_cache_restore_rehydrates_deferred_worklogs():
# The HTML-cache fast-path restore must re-stash deferred rows so the first
# post-restore expand works (JS properties don't survive innerHTML).
assert "_rehydrateDeferredWorklogsFromCache(inner);" in UI_JS
body = _function_body(UI_JS, "_rehydrateDeferredWorklogsFromCache")
assert '[data-worklog-rows-deferred="1"]' in body
assert "_deferredWorklogRowsFromGroup(group)" in body
# If a group can't be recovered, drop the deferred marker (no dead chip).
assert "removeAttribute('data-worklog-rows-deferred')" in body
def test_blank_turn_reveal_materializes_deferred_rows():
# The "blank turn" safety reveal force-expands a collapsed worklog; it must
# also materialize deferred rows or the revealed worklog would be empty.
assert UI_JS.count("_materializeDeferredWorklogRows(group)") >= 3
def test_materialize_postprocesses_and_restores_disclosure():
"""#5860 gate fix: lazily-materialized rows must get the same post-processing
(syntax highlight / copy button / mermaid / katex / trees) as the eager path,
and any captured detail-disclosure state must be re-applied — otherwise
expanded rows render un-enhanced and an open tool card resets closed."""
body = _function_body(UI_JS, "_materializeDeferredWorklogRows")
# post-processing scheduled after materialization (matches eager rebuild paths)
assert "_postProcessWithAnchorSuppression(group)" in body
assert "requestAnimationFrame" in body
# disclosure state stashed at defer/rebuild time is re-applied after rows exist
assert "_deferredWorklogDisclosure" in body
assert "_restoreWorklogDetailDisclosureState(group" in body
def test_rebuild_stashes_disclosure_on_deferred_groups():
"""#5860 gate fix: on a transcript rebuild the disclosure-restore pass can't
reach deferred groups (no rows yet), so the captured state is stashed on each
still-deferred group for _materializeDeferredWorklogRows to apply on expand."""
assert "group._deferredWorklogDisclosure=worklogDetailDisclosureState;" in UI_JS
# stash happens right after the main restore pass over the rebuilt transcript
assert 'querySelectorAll(\'[data-worklog-rows-deferred="1"]\')' in UI_JS