Preserve worklog detail disclosure state

This commit is contained in:
Frank Song committed 2026-06-13 07:43:12 +08:00
1 parent dee23a3528
commit 9f0e76c182
7 files changed
+254 -10

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Fixed
- **Manual Thinking and Tool detail collapse/expand choices now survive live transcript refreshes (#4062).** `renderMessages()` captures open/closed state for Worklog Thinking cards, Tool cards, and multi-tool detail groups before rebuilding the transcript, then restores it afterward, so new streaming results no longer reset every detail row back to the global Worklog-details default.
## [v0.51.372] — 2026-06-12 — Release MK (markdown link-label inline code, /use skill autocomplete, mobile Worklog overflow)
### Fixed
+124 -3
View File
@@ -6562,6 +6562,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<s.length;i++){
hash^=s.charCodeAt(i);
hash=Math.imul(hash,16777619)>>>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=`<button class="thinking-copy-btn" onclick="event.stopPropagation();_copyThinkingText(this)" title="${t('copy')}" aria-label="${t('copy')}">${li('copy',12)}</button>`;
@@ -6572,10 +6669,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;
@@ -6796,6 +6894,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)){
@@ -6823,8 +6931,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;
@@ -8039,6 +8148,7 @@ function renderMessages(options){
return;
}
}
const worklogDetailDisclosureState=_captureWorklogDetailDisclosureState(inner);
const compressionState=(()=>{
let compressionState=_compressionStateForCurrentSession();
@@ -8845,6 +8955,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,
});
@@ -8853,6 +8964,7 @@ function renderMessages(options){
_syncToolCallGroupSummary(state.group);
});
}
_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
@@ -9339,6 +9451,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=`<button type="button" class="tool-group-head tool-worklog-tool-group-head" aria-expanded="${shouldOpen?'true':'false'}" onclick="_toggleToolWorklogGroup(this)"><span class="tg-sum tool-worklog-tool-group-label">${esc(summary)}</span><span class="tool-call-group-chevron tg-caret">${li('chevron-right',12)}</span></button><div class="tool-group-body tool-worklog-tool-group-body"><div class="tg-rows tool-worklog-tool-group-rows"></div></div>`;
const body=group.querySelector('.tg-rows');
@@ -9450,6 +9569,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='';
@@ -10616,7 +10737,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);
+1 -1
View File
@@ -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"
)
@@ -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, (
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+122 -3
View File
@@ -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<body>.*?)\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<body>.*?)\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<body>.*?)\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, (