diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d8f773..2dff2411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.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) diff --git a/static/ui.js b/static/ui.js index f7d11f15..2c6f823d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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:) 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:`. 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 diff --git a/tests/test_issue5839_lazy_worklog_render.py b/tests/test_issue5839_lazy_worklog_render.py new file mode 100644 index 00000000..47c091cd --- /dev/null +++ b/tests/test_issue5839_lazy_worklog_render.py @@ -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