diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8e77e0..91ea02f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.379] — 2026-06-13 — Release MR (Worklog detail collapse survives live refresh, #4062) + +### Fixed + +- **Manual Thinking/Tool detail collapse choices now survive live transcript refreshes (#4062).** `renderMessages()` captures the open/closed disclosure state of Worklog Thinking cards, Tool cards, and multi-tool detail groups before rebuilding the transcript, then restores it afterward — keyed on stable render-time row attributes (`data-thinking-key` / tool id) rather than streaming body text — so new streaming results no longer reset every detail row back to the global Worklog-details default. (Reported via Discord; #4062) + ## [v0.51.378] — 2026-06-13 — Release MQ (don't yank the viewport while reading; auto-follow toggle, #4006) ### Fixed diff --git a/static/ui.js b/static/ui.js index 13d5c58a..83f18ba7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6652,6 +6652,103 @@ function _applyWorklogDetailsExpandedDefault(root){ if(summary) summary.setAttribute('aria-expanded', String(open)); }); } +const _worklogDetailDisclosureSelector='.thinking-card,.tool-card,.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group'; +function _worklogDetailTextKey(text, maxLen){ + return String(text||'').replace(/\s+/g,' ').trim().slice(0,maxLen||160); +} +function _worklogDetailHashKey(value){ + const s=String(value||''); + let hash=2166136261; + for(let i=0;i>>0; + } + return hash.toString(36); +} +function _worklogDetailBaseKey(el){ + if(!el||!el.classList) return ''; + const activity=el.closest&&el.closest('.agent-activity-group,.tool-worklog-group[data-tool-worklog-group="1"],.tool-call-group[data-tool-call-group="1"],.live-worklog[data-live-worklog-shell="1"]'); + const scope=activity?[ + activity.getAttribute('data-activity-disclosure-key')||'', + activity.getAttribute('data-tool-worklog-key')||'', + activity.getAttribute('data-live-segment-seq')||'', + activity.getAttribute('data-activity-burst-id')||'', + ].filter(Boolean).join('|'):''; + if(el.classList.contains('thinking-card')){ + const row=el.closest('.agent-activity-thinking,.thinking-card-row'); + const stable=row&&( + row.getAttribute('data-thinking-key')|| + row.getAttribute('data-live-thinking-key')|| + row.getAttribute('data-live-segment-seq')|| + row.getAttribute('data-activity-burst-id')|| + row.id|| + '' + ); + return `thinking:${scope}:${stable||'ordinal'}`; + } + if(el.classList.contains('tool-card')){ + const row=el.closest('.tool-card-row'); + const tid=row&&( + row.getAttribute('data-tool-disclosure-key')|| + row.getAttribute('data-live-tid')|| + row.getAttribute('data-tool-call-id')|| + row.getAttribute('data-tool-id')|| + '' + ); + const label=row&&(row.dataset&&row.dataset.toolActionLabel)||''; + const name=el.querySelector('.tool-card-name'); + return `tool:${scope}:${tid||label||_worklogDetailTextKey(name?name.textContent:'tool',80)}`; + } + if(el.matches&&el.matches('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group')){ + const stable= + el.getAttribute('data-tool-group-disclosure-key')|| + el.getAttribute('data-activity-disclosure-key')|| + el.getAttribute('data-tool-worklog-key')|| + el.getAttribute('data-live-segment-seq')|| + el.getAttribute('data-activity-burst-id')|| + 'group'; + return `tool-group:${scope}:${stable}`; + } + return ''; +} +function _worklogDetailDisclosureIsOpen(el){ + return !!(el&&el.classList&&el.classList.contains('open')); +} +function _setWorklogDetailDisclosureOpen(el, open){ + if(!el||!el.classList) return; + el.classList.toggle('open', !!open); + if(el.matches&&el.matches('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group')){ + el.classList.toggle('tool-worklog-tool-group-collapsed', !open); + const summary=el.querySelector('.tool-group-head,.tool-worklog-tool-group-head'); + if(summary) summary.setAttribute('aria-expanded', String(!!open)); + } +} +function _worklogDetailDisclosureKeyForElement(el, counts){ + const base=_worklogDetailBaseKey(el); + if(!base) return ''; + const idx=counts[base]||0; + counts[base]=idx+1; + return `${base}#${idx}`; +} +function _captureWorklogDetailDisclosureState(root){ + const state=new Map(); + if(!root||!root.querySelectorAll) return state; + const counts=Object.create(null); + root.querySelectorAll(_worklogDetailDisclosureSelector).forEach(el=>{ + const key=_worklogDetailDisclosureKeyForElement(el, counts); + if(key) state.set(key, _worklogDetailDisclosureIsOpen(el)); + }); + return state; +} +function _restoreWorklogDetailDisclosureState(root, state){ + if(!root||!root.querySelectorAll||!state||!state.size) return; + const counts=Object.create(null); + root.querySelectorAll(_worklogDetailDisclosureSelector).forEach(el=>{ + const key=_worklogDetailDisclosureKeyForElement(el, counts); + if(!key||!state.has(key)) return; + _setWorklogDetailDisclosureOpen(el, state.get(key)); + }); +} function _thinkingCardHtml(text, open){ const clean=_sanitizeThinkingDisplayText(text); const copyBtn=``; @@ -6662,10 +6759,11 @@ function _thinkingCardHtml(text, open){ function isSimplifiedToolCalling(){ return window._simplifiedToolCalling!==false; } -function _thinkingActivityNode(text, open){ +function _thinkingActivityNode(text, open, disclosureKey){ const row=document.createElement('div'); row.className='agent-activity-thinking'; row.setAttribute('data-worklog-thinking-card','1'); + if(disclosureKey) row.setAttribute('data-thinking-key', String(disclosureKey)); row.innerHTML=_thinkingCardHtml(text, open); _renderThinkingInto(row,text); return row; @@ -7458,6 +7556,16 @@ function _toolIdentity(tc){ String(tc.snippet||tc.preview||'').slice(0,160), ].join('|'); } +function _toolDisclosureIdentity(tc){ + if(!tc) return ''; + const tid=tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||''; + if(tid) return `id:${tid}`; + const stable=[ + tc.assistant_msg_idx!==undefined?`a:${tc.assistant_msg_idx}`:'', + tc.name||'tool', + ].join('\x1f'); + return stable.trim()?`derived:${_worklogDetailHashKey(stable)}`:''; +} function _filterNewWorklogTools(cards, seenTools){ const out=[]; for(const tc of Array.from(cards||[]).filter(Boolean)){ @@ -7485,8 +7593,9 @@ function _appendWorklogStep(group, anchor, cards, thinkingText, opts){ } if(thinkingText){ const thinkingKey=(opts&&opts.thinkingKey)||`reason:${String(thinkingText).trim()}`; + const thinkingDisclosureKey=(opts&&opts.thinkingDisclosureKey)||thinkingKey; if(!seenReasons||!seenReasons.has(thinkingKey)){ - const thinking=_thinkingActivityNode(thinkingText, false); + const thinking=_thinkingActivityNode(thinkingText, false, thinkingDisclosureKey); if(thinking){ list.appendChild(thinking); wroteProse=true; @@ -8713,6 +8822,7 @@ function renderMessages(options){ return; } } + const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner); const compressionState=(()=>{ let compressionState=_compressionStateForCurrentSession(); @@ -9520,6 +9630,7 @@ function renderMessages(options){ live:false, includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml, thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:'', + thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:'', seenReasons:state.seenReasons, seenTools:state.seenTools, }); @@ -9597,6 +9708,7 @@ function renderMessages(options){ } } } + _restoreWorklogDetailDisclosureState(inner, 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 @@ -10141,6 +10253,13 @@ function _syncToolRowsContainer(tools, isLiveWorklog){ const group=document.createElement('div'); group.className='tool-group'+(shouldOpen?' open':' tool-worklog-tool-group-collapsed'); group.setAttribute('data-tool-worklog-tool-group','1'); + let groupKey='group'; + if(tools.parentElement){ + const steps=Array.from(tools.parentElement.children).filter(child=>child.classList&&child.classList.contains('wl-step-tools')&&child.getAttribute('data-worklog-tools')==='1'); + const stepIdx=steps.indexOf(tools); + if(stepIdx>=0) groupKey=`step:${stepIdx}`; + } + group.setAttribute('data-tool-group-disclosure-key',groupKey); const summary=hasRunning?'Running':_toolWorklogSummary(rows,{live:isLiveWorklog, toolCount:rows.length}); group.innerHTML=`
`; const body=group.querySelector('.tg-rows'); @@ -10255,6 +10374,8 @@ function buildToolCard(tc){ row.dataset.toolDone=String(tc&&tc.done!==false); row.dataset.toolError=String(!!(tc&&tc.is_error)); row.dataset.toolActionLabel=typeof _toolActionLabelText==='function'?_toolActionLabelText(tc):_toolDisplayName(tc); + const disclosureKey=typeof _toolDisclosureIdentity==='function'?_toolDisclosureIdentity(tc):''; + if(disclosureKey) row.setAttribute('data-tool-disclosure-key', disclosureKey); const icon=toolIcon(tc.name); const hasDetail=(tc.snippet&&tc.snippet!==tc.preview)||(tc.args&&Object.keys(tc.args).length>0); let displaySnippet=''; @@ -11533,7 +11654,7 @@ function appendThinking(text='', options){ if(list){ let row=list.querySelector(`.agent-activity-thinking[data-live-thinking="1"][data-live-thinking-key="${CSS.escape(thinkingKey)}"]`); if(!row){ - row=_thinkingActivityNode(clean, false); + row=_thinkingActivityNode(clean, false, thinkingKey); row.setAttribute('data-live-thinking','1'); row.setAttribute('data-live-thinking-key',thinkingKey); if(segmentSeq) row.setAttribute('data-live-segment-seq',segmentSeq); diff --git a/tests/test_issue3592_thinking_settlement.py b/tests/test_issue3592_thinking_settlement.py index 54ebfbb0..aaadffd9 100644 --- a/tests/test_issue3592_thinking_settlement.py +++ b/tests/test_issue3592_thinking_settlement.py @@ -24,7 +24,7 @@ def test_settlement_loop_does_not_inline_thinking_only_messages(): assert "!cards.length&&assistantThinking.has(aIdx)" not in UI_JS, ( "Thinking-only messages must not use the old inline early-continue path" ) - assert "_thinkingActivityNode(thinkingText, false)" in UI_JS, ( + assert "_thinkingActivityNode(thinkingText, false, thinkingDisclosureKey)" in UI_JS, ( "settled reasoning should render as a collapsed Worklog Thinking Card" ) diff --git a/tests/test_issue3709_thinking_double_render.py b/tests/test_issue3709_thinking_double_render.py index 1b624495..0b03d425 100644 --- a/tests/test_issue3709_thinking_double_render.py +++ b/tests/test_issue3709_thinking_double_render.py @@ -86,7 +86,7 @@ def test_settled_thinking_renders_through_worklog_item_path(): assert "_appendWorklogStep(state.group, anchorRow, cards, thinkingText" in body, ( "Settled Thinking should render through the #3401 Worklog item path." ) - assert "_thinkingActivityNode(thinkingText, false)" in UI_JS, ( + assert "_thinkingActivityNode(thinkingText, false, thinkingDisclosureKey)" in UI_JS, ( "Thinking should remain a dedicated Worklog Thinking Card node." ) assert "data-worklog-thinking-card" in UI_JS, ( diff --git a/tests/test_live_activity_timeline.py b/tests/test_live_activity_timeline.py index a93d7f97..12da763c 100644 --- a/tests/test_live_activity_timeline.py +++ b/tests/test_live_activity_timeline.py @@ -53,7 +53,7 @@ def test_per_segment_tool_activity_does_not_include_run_metadata_rows(): assert "Tool finished: ${toolName}" not in UI_JS assert "Running tool: ${toolName}" not in UI_JS assert "_worklogReasonNodeFromText(thinkingText" not in UI_JS - assert "_thinkingActivityNode(clean, false)" in UI_JS + assert "_thinkingActivityNode(clean, false, thinkingKey)" in UI_JS assert "data-live-thinking-key" in UI_JS diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 7123eafa..37fa401c 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -917,7 +917,7 @@ def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_ "provider reasoning metadata should feed the Worklog Thinking Card after exact duplicate suppression" assert "data-worklog-thinking-card" in src, \ "Thinking Cards should be explicit Worklog items, not tool cards" - assert "_thinkingActivityNode(thinkingText, false)" in src, \ + assert "_thinkingActivityNode(thinkingText, false, thinkingDisclosureKey)" in src, \ "settled reasoning should render as a collapsed Worklog Thinking Card" diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 8781ed2e..9fd7221d 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -233,6 +233,122 @@ class TestToolCallGroupingStatic: "When tools are present, thinking is expected and should not be repeated in the label." ) + def test_render_rebuild_preserves_worklog_detail_disclosure_click_state(self): + render_fn = _function_body(UI_JS, "renderMessages") + capture_fn = _function_body(UI_JS, "_captureWorklogDetailDisclosureState") + restore_fn = _function_body(UI_JS, "_restoreWorklogDetailDisclosureState") + apply_fn = _function_body(UI_JS, "_setWorklogDetailDisclosureOpen") + capture_pos = render_fn.index("const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner);") + cache_pos = render_fn.index("if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi)") + cache_return_pos = render_fn.index("return;", cache_pos) + wipe_pos = render_fn.index("inner.innerHTML='';") + restore_pos = render_fn.index("_restoreWorklogDetailDisclosureState(inner, worklogDetailDisclosureState);") + fail_safe_pos = render_fn.find("Fail-safe invariant (#3875)") + assert cache_pos < cache_return_pos < capture_pos, ( + "renderMessages() should not traverse the previous session DOM when " + "the HTML-cache fast path can return early." + ) + assert capture_pos < wipe_pos, ( + "renderMessages() must capture manual Worklog detail open/closed state " + "before wiping msgInner for a rebuild." + ) + assert restore_pos > wipe_pos, ( + "renderMessages() must restore manual Worklog detail state after the " + "new Thinking/Tool DOM has been rebuilt." + ) + assert fail_safe_pos == -1 or restore_pos < fail_safe_pos, ( + "The blank-turn fail-safe must still be allowed to expand otherwise " + "invisible Worklog content after manual detail state is restored." + ) + assert "_worklogDetailDisclosureSelector" in capture_fn, ( + "The rebuild-state capture should use the shared Worklog detail selector." + ) + selector_match = re.search(r"const _worklogDetailDisclosureSelector='([^']+)'", UI_JS) + assert selector_match, "Shared Worklog detail selector is missing." + selector = selector_match.group(1) + assert ".thinking-card" in selector and ".tool-card" in selector, ( + "The rebuild-state capture must cover both Thinking cards and Tool cards." + ) + assert "data-tool-worklog-tool-group" in selector, ( + "The rebuild-state capture must cover multi-tool Worklog detail groups." + ) + assert "_setWorklogDetailDisclosureOpen" in restore_fn, ( + "Restoration should use the shared disclosure-state applier." + ) + assert "tool-worklog-tool-group-collapsed" in apply_fn and "aria-expanded" in apply_fn, ( + "Restoring multi-tool groups must sync both CSS state and accessibility state." + ) + + 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") + append_step_fn = _function_body(UI_JS, "_appendWorklogStep") + build_tool_fn = _function_body(UI_JS, "buildToolCard") + sync_tools_fn = _function_body(UI_JS, "_syncToolRowsContainer") + + thinking_branch = re.search( + r"if\(el\.classList\.contains\('thinking-card'\)\)\{(?P.*?)\n \}\n if\(el\.classList\.contains\('tool-card'\)\)", + key_fn, + re.S, + ) + assert thinking_branch, "Thinking-card disclosure-key branch is missing." + thinking_body = thinking_branch.group("body") + assert "data-thinking-key" in thinking_body and "data-live-thinking-key" in thinking_body, ( + "Thinking-card disclosure keys must prefer render-time stable row keys." + ) + assert ".thinking-card-body pre" not in thinking_body and "textContent" not in thinking_body, ( + "Thinking-card disclosure keys must not depend on streaming body text." + ) + assert "_thinkingActivityNode(clean, false, thinkingKey)" in append_thinking_fn, ( + "Live streaming Thinking rows must stamp the stable thinking key at creation time." + ) + assert "_thinkingActivityNode(thinkingText, false, thinkingDisclosureKey)" in append_step_fn, ( + "Settled Worklog Thinking rows must stamp the stable thinking key at creation time." + ) + assert "thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:''" in _function_body(UI_JS, "renderMessages"), ( + "Settled Worklog Thinking keys should come from activity coordinates, not text." + ) + + tool_branch = re.search( + r"if\(el\.classList\.contains\('tool-card'\)\)\{(?P.*?)\n \}\n if\(el\.matches&&el\.matches\('\.tool-group", + key_fn, + re.S, + ) + assert tool_branch, "Tool-card disclosure-key branch is missing." + tool_body = tool_branch.group("body") + assert "data-tool-disclosure-key" in tool_body, ( + "Tool-card disclosure keys must prefer a stable render-time tool key." + ) + assert ".tool-card-preview" not in tool_body, ( + "Tool-card disclosure keys must not depend on result preview text." + ) + assert "_toolDisclosureIdentity(tc)" in build_tool_fn, ( + "buildToolCard() must stamp a stable disclosure key on each tool row." + ) + assert "tc.snippet" not in _function_body(UI_JS, "_toolDisclosureIdentity"), ( + "Derived tool disclosure keys must not include changing result snippets." + ) + assert "tc.args" not in _function_body(UI_JS, "_toolDisclosureIdentity"), ( + "Derived tool disclosure keys must not include streaming tool arguments." + ) + + group_branch = re.search( + r"if\(el\.matches&&el\.matches\('\.tool-group\[data-tool-worklog-tool-group=\"1\"\],\.tool-worklog-tool-group'\)\)\{(?P.*?)\n \}\n return '';", + key_fn, + re.S, + ) + assert group_branch, "Multi-tool Worklog group disclosure-key branch is missing." + group_body = group_branch.group("body") + assert "data-tool-group-disclosure-key" in group_body, ( + "Multi-tool Worklog groups must prefer a stable render-time group key." + ) + assert "_worklogDetailTextKey" not in group_body and "textContent" not in group_body, ( + "Multi-tool Worklog group disclosure keys must not depend on changing summary text." + ) + assert "data-tool-group-disclosure-key" in sync_tools_fn and "stepIdx" in sync_tools_fn, ( + "Grouped Worklog tool rows must stamp a stable per-step disclosure key." + ) + def test_live_tool_cards_use_grouping_only_when_simplified(self): live_fn = _function_body(UI_JS, "appendLiveToolCard") settled_fn = _function_body(UI_JS, "renderMessages") @@ -455,8 +571,11 @@ class TestToolCallGroupingStatic: ) render_min = re.sub(r"\s+", "", render_fn) assert "thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:''" in render_min, ( - "Settled Worklog should key Thinking Cards by normalized content so exact duplicate " - "Thinking from sibling messages does not render twice." + "Settled Worklog should keep normalized-content Thinking dedupe so sibling messages do not duplicate cards." + ) + assert "thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:''" in render_min, ( + "Settled Worklog should separately key disclosure state by stable activity coordinates " + "so streaming text growth does not reset manual collapse state." ) assert "_appendWorklogStep" in render_fn, ( "Visible assistant anchors, Thinking Cards, and tools should still build the compact Worklog disclosure." @@ -475,7 +594,7 @@ class TestToolCallGroupingStatic: assert "_worklogReasonNodeFromText(thinkingText" not in live_thinking_fn, ( "Provider reasoning should not render as live Worklog process prose." ) - assert "_thinkingActivityNode(clean, false)" in live_thinking_fn and "data-live-thinking" in live_thinking_fn, ( + assert "_thinkingActivityNode(clean, false, thinkingKey)" in live_thinking_fn and "data-live-thinking" in live_thinking_fn, ( "Live provider thinking should render as a collapsed Worklog Thinking Card." ) assert "ensureLiveWorklogContainer" in live_thinking_fn, (