mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
fix: persist activity disclosure state
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -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;
|
||||
|
||||
+46
-5
@@ -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=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="const g=this.closest('.tool-call-group');const c=g.classList.toggle('tool-call-group-collapsed');this.setAttribute('aria-expanded',String(!c));if(typeof _onLiveActivityToggle==='function')_onLiveActivityToggle(g);"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-duration"></span></button><div class="tool-call-group-body"></div>`;
|
||||
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="_toggleActivityGroup(this)"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-duration"></span></button><div class="tool-call-group-body"></div>`;
|
||||
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"]');
|
||||
|
||||
@@ -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=`<button[^`]*?_onLiveActivityToggle[^`]*?`",
|
||||
src, re.DOTALL,
|
||||
assert "onclick=\"_toggleActivityGroup(this)\"" in m.group(0), (
|
||||
"ensureActivityGroup() summary button should use the shared toggle helper"
|
||||
)
|
||||
assert m2, (
|
||||
"ensureActivityGroup() inline onclick must invoke "
|
||||
"_onLiveActivityToggle(g) so user clicks update the tracker"
|
||||
toggle_body = re.search(
|
||||
r"function _toggleActivityGroup\(summary\)\{(.*?)\n\}",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert toggle_body and "_onLiveActivityToggle(group)" in toggle_body.group(1), (
|
||||
"_toggleActivityGroup() must invoke _onLiveActivityToggle(group) "
|
||||
"so user clicks update the tracker"
|
||||
)
|
||||
|
||||
def test_clear_live_tool_cards_resets_expand_intent(self):
|
||||
|
||||
@@ -171,6 +171,49 @@ class TestToolCallGroupingStatic:
|
||||
"Live grouping must preserve data-live-tid so tool_start/tool_complete updates still replace the correct card."
|
||||
)
|
||||
|
||||
def test_activity_disclosure_state_is_session_and_turn_scoped(self):
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
toggle_fn = _function_body(UI_JS, "_toggleActivityGroup")
|
||||
key_fn = _function_body(UI_JS, "_activityDisclosureStorageKey")
|
||||
render_fn = _function_body(UI_JS, "renderMessages")
|
||||
live_fn = _function_body(UI_JS, "appendLiveToolCard")
|
||||
thinking_fn = _function_body(UI_JS, "appendThinking")
|
||||
done_fn = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
assert "hermes-activity-disclosure:" in UI_JS, (
|
||||
"Activity disclosure state should use a dedicated localStorage namespace."
|
||||
)
|
||||
assert "S.session.session_id" in key_fn, (
|
||||
"Activity disclosure state must be scoped to the current chat/session."
|
||||
)
|
||||
assert "data-activity-disclosure-key" in helper, (
|
||||
"Each Activity group needs a stable per-turn key for persisted disclosure state."
|
||||
)
|
||||
assert "_readActivityDisclosureState" in helper, (
|
||||
"ensureActivityGroup() should hydrate the saved open/closed state before using defaults."
|
||||
)
|
||||
assert "_writeActivityDisclosureState" in toggle_fn, (
|
||||
"Clicking the Activity summary should persist the new open/closed state."
|
||||
)
|
||||
assert "assistant:" in render_fn, (
|
||||
"Settled Activity groups should be keyed by assistant message index."
|
||||
)
|
||||
assert "live:" in live_fn + thinking_fn, (
|
||||
"Live Activity groups should be keyed by active stream id."
|
||||
)
|
||||
assert "_copyActivityDisclosureState('live:'+streamId, 'assistant:'" in done_fn, (
|
||||
"When a live turn settles, its saved disclosure state should transfer to the persisted assistant turn."
|
||||
)
|
||||
|
||||
def test_live_tool_activity_defaults_collapsed_unless_saved_open(self):
|
||||
live_fn = _function_body(UI_JS, "appendLiveToolCard")
|
||||
helper = _function_body(UI_JS, "ensureActivityGroup")
|
||||
assert "collapsed:false" not in re.sub(r"\s+", "", live_fn), (
|
||||
"Compact live tool activity should not force-open every time a chat is revisited."
|
||||
)
|
||||
assert "savedState==='open'" in helper or 'savedState==="open"' in helper, (
|
||||
"A previously-open Activity group should still restore open from persisted state."
|
||||
)
|
||||
|
||||
def test_tools_and_thinking_share_one_collapsed_activity_dropdown(self):
|
||||
ui_min = re.sub(r"\s+", "", UI_JS)
|
||||
assert "functionensureActivityGroup(" in ui_min, (
|
||||
|
||||
Reference in New Issue
Block a user