From 87ab74d1b94c6bc6318df07fc8ec9d276859986a Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 23 Jun 2026 00:11:51 +0800 Subject: [PATCH] fix(ui): preserve thinking detail scroll on rebuild --- static/ui.js | 20 ++++++++++++++++++-- tests/test_ui_tool_call_cleanup.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index 4ee97f61..9ea475ae 100644 --- a/static/ui.js +++ b/static/ui.js @@ -8224,6 +8224,10 @@ function _worklogDetailBaseKey(el){ function _worklogDetailDisclosureIsOpen(el){ return !!(el&&el.classList&&el.classList.contains('open')); } +function _worklogDetailScrollableBody(el){ + if(!el||!el.querySelector) return null; + return el.querySelector('.thinking-card-body,.tool-card-detail'); +} function _setWorklogDetailDisclosureOpen(el, open){ if(!el||!el.classList) return; el.classList.toggle('open', !!open); @@ -8252,7 +8256,12 @@ function _captureWorklogDetailDisclosureState(root){ const counts=Object.create(null); root.querySelectorAll(_worklogDetailDisclosureSelector).forEach(el=>{ const key=_worklogDetailDisclosureKeyForElement(el, counts); - if(key) state.set(key, _worklogDetailDisclosureIsOpen(el)); + if(!key) return; + const body=_worklogDetailScrollableBody(el); + state.set(key,{ + open:_worklogDetailDisclosureIsOpen(el), + scrollTop:body?Math.max(0,Number(body.scrollTop)||0):0, + }); }); return state; } @@ -8264,7 +8273,14 @@ function _restoreWorklogDetailDisclosureState(root, state){ root.querySelectorAll(_worklogDetailDisclosureSelector).forEach(el=>{ const key=_worklogDetailDisclosureKeyForElement(el, counts); if(!key||!state.has(key)) return; - _setWorklogDetailDisclosureOpen(el, state.get(key)); + const saved=state.get(key); + const open=(saved&&typeof saved==='object'&&'open' in saved)?saved.open:saved; + _setWorklogDetailDisclosureOpen(el, open); + const scrollTop=(saved&&typeof saved==='object')?Number(saved.scrollTop):0; + if(open&&Number.isFinite(scrollTop)&&scrollTop>0){ + const body=_worklogDetailScrollableBody(el); + if(body) body.scrollTop=Math.min(scrollTop, Math.max(0, body.scrollHeight-body.clientHeight)); + } }); } function _thinkingCardHtml(text, open){ diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index d3b4ceae..4e6dea61 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -279,6 +279,30 @@ class TestToolCallGroupingStatic: "Restoring multi-tool groups must sync both CSS state and accessibility state." ) + def test_render_rebuild_preserves_nested_worklog_detail_scroll_position(self): + capture_fn = _function_body(UI_JS, "_captureWorklogDetailDisclosureState") + restore_fn = _function_body(UI_JS, "_restoreWorklogDetailDisclosureState") + body_fn = _function_body(UI_JS, "_worklogDetailScrollableBody") + + assert ".thinking-card-body,.tool-card-detail" in body_fn, ( + "Nested scroll preservation must cover Thinking bodies and Tool details." + ) + assert "_worklogDetailScrollableBody(el)" in capture_fn, ( + "The rebuild-state capture must inspect each detail's nested scroll container." + ) + assert "scrollTop:body?Math.max(0,Number(body.scrollTop)||0):0" in capture_fn, ( + "The captured Worklog detail state must retain the nested scrollTop value." + ) + assert "const saved=state.get(key)" in restore_fn, ( + "Restoration should read the structured state object for each detail." + ) + assert "const open=(saved&&typeof saved==='object'&&'open' in saved)?saved.open:saved" in restore_fn, ( + "Restoration must remain backward-compatible with legacy boolean snapshots." + ) + assert "body.scrollTop=Math.min(scrollTop, Math.max(0, body.scrollHeight-body.clientHeight))" in restore_fn, ( + "Restoration must reapply nested scrollTop after the rebuilt detail is opened." + ) + def test_worklog_detail_keys_stay_stable_while_streaming_content_grows(self): key_fn = _function_body(UI_JS, "_worklogDetailBaseKey") append_thinking_fn = _function_body(UI_JS, "appendThinking")