diff --git a/DESIGN.md b/DESIGN.md index a0aefcfe..bafbb6bd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -140,7 +140,7 @@ Use almost no shadows in the transcript. Shadows are reserved for popovers, drop ### Tool/thinking activity group -Collapsed by default in settled history and during live runs. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention. +Collapsed by default in settled history and during live runs unless the user has explicitly opened that Activity row before. Persist open/closed disclosure state per chat and per turn, so switching away from a chat and coming back preserves the mode the user left it in. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention. ### Tool card diff --git a/docs/pr-media/activity-disclosure/activity-expanded.png b/docs/pr-media/activity-disclosure/activity-expanded.png new file mode 100644 index 00000000..97f579e2 Binary files /dev/null and b/docs/pr-media/activity-disclosure/activity-expanded.png differ diff --git a/docs/pr-media/activity-disclosure/activity-persisted-closed.png b/docs/pr-media/activity-disclosure/activity-persisted-closed.png new file mode 100644 index 00000000..44c74c64 Binary files /dev/null and b/docs/pr-media/activity-disclosure/activity-persisted-closed.png differ diff --git a/static/messages.js b/static/messages.js index fe2d4a87..dbf83874 100644 --- a/static/messages.js +++ b/static/messages.js @@ -927,6 +927,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } else { S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true})); } + if(typeof _copyActivityDisclosureState==='function'&&lastAsst){ + const assistantIdx=S.messages.indexOf(lastAsst); + if(assistantIdx>=0) _copyActivityDisclosureState('live:'+streamId, 'assistant:'+assistantIdx); + } if(uploaded.length){ const lastUser=[...S.messages].reverse().find(m=>m.role==='user'); if(lastUser)lastUser.attachments=uploaded; diff --git a/static/ui.js b/static/ui.js index 84e0ee0d..2e226515 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3779,12 +3779,45 @@ function _thinkingActivityNode(text){ // finalized into a settled assistant turn (the live attribute is removed in // _convertLiveActivityGroupToSettled / when liveAssistantTurn loses its id). let _liveActivityUserExpanded; +const _activityDisclosureStoragePrefix='hermes-activity-disclosure:'; +function _activityDisclosureStorageKey(activityKey){ + if(!activityKey||!S.session||!S.session.session_id) return null; + return _activityDisclosureStoragePrefix+S.session.session_id+':'+activityKey; +} +function _readActivityDisclosureState(activityKey){ + const key=_activityDisclosureStorageKey(activityKey); + if(!key) return null; + try{ + const saved=localStorage.getItem(key); + return saved==='open'||saved==='closed'?saved:null; + }catch(_){return null;} +} +function _writeActivityDisclosureState(activityKey, open){ + const key=_activityDisclosureStorageKey(activityKey); + if(!key) return; + try{localStorage.setItem(key, open?'open':'closed');}catch(_){} +} +function _copyActivityDisclosureState(fromActivityKey, toActivityKey){ + const state=_readActivityDisclosureState(fromActivityKey); + if(state) _writeActivityDisclosureState(toActivityKey, state==='open'); +} +function _activityKeyForLiveTurn(){ + return S.activeStreamId?'live:'+S.activeStreamId:null; +} function _onLiveActivityToggle(group){ if(!group) return; // Only track explicit user clicks on the live group, not programmatic toggles. if(group.getAttribute('data-live-tool-call-group')!=='1') return; _liveActivityUserExpanded = !group.classList.contains('tool-call-group-collapsed'); } +function _toggleActivityGroup(summary){ + const group=summary&&summary.closest?summary.closest('.tool-call-group'):null; + if(!group) return; + const collapsed=group.classList.toggle('tool-call-group-collapsed'); + summary.setAttribute('aria-expanded',String(!collapsed)); + _writeActivityDisclosureState(group.getAttribute('data-activity-disclosure-key'), !collapsed); + if(typeof _onLiveActivityToggle==='function') _onLiveActivityToggle(group); +} function _clearLiveActivityUserIntent(){ _liveActivityUserExpanded = undefined; } @@ -3792,23 +3825,31 @@ function ensureActivityGroup(inner, opts){ opts=opts||{}; if(!inner) return null; const live=!!opts.live; + const activityKey=opts.activityKey||(live?_activityKeyForLiveTurn():null); const selector=live?'.tool-call-group[data-live-tool-call-group="1"]':'.tool-call-group[data-agent-activity-group="1"]'; let group=inner.querySelector(selector); if(!group){ group=document.createElement('div'); let collapsed=opts.collapsed!==false; + const savedState=_readActivityDisclosureState(activityKey); // Restore the user's explicit expand intent when recreating the live - // activity group within the same turn (#1298). + // activity group within the same turn (#1298), then let persisted chat/turn + // state win across session switches and reloads. if(live && _liveActivityUserExpanded === true) collapsed=false; else if(live && _liveActivityUserExpanded === false) collapsed=true; + if(savedState==='open') collapsed=false; + else if(savedState==='closed') collapsed=true; group.className='tool-call-group agent-activity-group'+(collapsed?' tool-call-group-collapsed':''); group.setAttribute('data-tool-call-group','1'); group.setAttribute('data-agent-activity-group','1'); + if(activityKey) group.setAttribute('data-activity-disclosure-key',activityKey); if(live) group.setAttribute('data-live-tool-call-group','1'); - group.innerHTML=`
`; + group.innerHTML=`
`; const anchor=opts.anchor||null; if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group); else inner.appendChild(group); + }else if(activityKey&&!group.getAttribute('data-activity-disclosure-key')){ + group.setAttribute('data-activity-disclosure-key',activityKey); } if(live) _setActivityElapsedStartedAt(group); _syncToolCallGroupSummary(group); @@ -4663,7 +4704,7 @@ function renderMessages(options){ if(!anchorRow) continue; const anchorParent=anchorRow.parentElement; const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow; - const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode}); + const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode,activityKey:`assistant:${aIdx}`}); const sourceMsg=S.messages[aIdx]||{}; if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration)); const body=group&&group.querySelector('.tool-call-group-body'); @@ -4972,7 +5013,7 @@ function appendLiveToolCard(tc){ } const children=Array.from(inner.children); const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')).pop(); - const group=ensureActivityGroup(inner,{live:true,collapsed:false,anchor}); + const group=ensureActivityGroup(inner,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()}); const body=group.querySelector('.tool-call-group-body'); // Update existing card in place (tool_complete after tool_start) if(tid){ @@ -5764,7 +5805,7 @@ function appendThinking(text=''){ el.id!=='toolRunningRow' && el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking') ).pop(); - const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor}); + const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()}); const body=group&&group.querySelector('.tool-call-group-body'); if(!body) return; let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); diff --git a/tests/test_issue1298_cancel_and_activity.py b/tests/test_issue1298_cancel_and_activity.py index fe6e363d..90c00a49 100644 --- a/tests/test_issue1298_cancel_and_activity.py +++ b/tests/test_issue1298_cancel_and_activity.py @@ -338,26 +338,32 @@ class TestIssue1298ActivityGroupExpandPersistence: ) def test_inline_onclick_records_user_intent(self): - """The summary button's inline onclick must call _onLiveActivityToggle + """The summary button's click path must call _onLiveActivityToggle so user clicks update the tracker (#1298).""" src = (REPO_ROOT / "static" / "ui.js").read_text() # The summary button is built inline inside ensureActivityGroup. assert "_onLiveActivityToggle" in src, ( "_onLiveActivityToggle helper must be defined" ) - # The inline onclick string must include the call so user toggles - # are captured into _liveActivityUserExpanded. + assert "function _toggleActivityGroup" in src, ( + "Activity summary clicks should route through the shared toggle helper" + ) + # The inline onclick may delegate to _toggleActivityGroup(); that helper + # must still call _onLiveActivityToggle(group) so user toggles are + # captured into _liveActivityUserExpanded. m = re.search(r'class="tool-call-group-summary"[^`]*`', src) assert m, "live activity summary button template must be present" - # The onclick fragment is in the same template literal that builds - # the button — pull a wider window - m2 = re.search( - r"group\.innerHTML=`