mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
feat: surface live activity timeline
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Live chat Activity now shows observable run events, model/run metadata, clearer working elapsed text, and explicit waiting/stale states instead of an empty `Thinking…` placeholder when no reasoning text is available.
|
||||
|
||||
## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2322,6 +2322,15 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
|
||||
.tool-call-group-body{display:block;padding-left:var(--space-3);}
|
||||
.tool-call-group.tool-call-group-collapsed .tool-call-group-body{display:none;}
|
||||
.agent-activity-status{display:grid;grid-template-columns:18px minmax(0,1fr) auto;align-items:start;gap:var(--space-2);padding:5px 0;color:var(--muted);font-size:var(--font-size-xs);line-height:1.45;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 60%,transparent);}
|
||||
.agent-activity-status:last-child{border-bottom:0;}
|
||||
.agent-activity-status-icon{display:inline-flex;align-items:center;justify-content:center;min-height:18px;opacity:.72;color:var(--muted);}
|
||||
.agent-activity-status-copy{display:flex;flex-direction:column;min-width:0;gap:1px;}
|
||||
.agent-activity-status-label{color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.agent-activity-status-detail{color:var(--muted);opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.agent-activity-status-time{font-variant-numeric:tabular-nums;opacity:.55;white-space:nowrap;}
|
||||
.agent-activity-status-waiting .agent-activity-status-label{color:var(--muted);}
|
||||
.agent-activity-status-error .agent-activity-status-label{color:var(--error);}
|
||||
.tool-call-group-label{font-weight:600;color:var(--muted);position:relative;display:inline-block;overflow:hidden;}
|
||||
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label{
|
||||
color:var(--muted);
|
||||
|
||||
+95
-8
@@ -2208,6 +2208,7 @@ function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressi
|
||||
function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}}
|
||||
let _activityElapsedTimer=null;
|
||||
let _activityElapsedTimerGroup=null;
|
||||
function _activityNowSeconds(){return Date.now()/1000;}
|
||||
function _activityElapsedStartedAt(group){
|
||||
if(!group)return null;
|
||||
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
|
||||
@@ -2219,7 +2220,52 @@ function _activityElapsedStartedAt(group){
|
||||
function _activityElapsedLabel(group){
|
||||
const started=_activityElapsedStartedAt(group);
|
||||
if(!started)return'';
|
||||
return _formatActiveElapsedTimer((Date.now()/1000)-started);
|
||||
return _formatActiveElapsedTimer(_activityNowSeconds()-started);
|
||||
}
|
||||
function _activityMarkObserved(group, ts){
|
||||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
|
||||
const stamp=Number(ts||_activityNowSeconds());
|
||||
if(Number.isFinite(stamp)&&stamp>0) group.setAttribute('data-last-activity-at',String(stamp));
|
||||
}
|
||||
function _activityLastObservedAge(group){
|
||||
const stamp=Number(group&&group.getAttribute('data-last-activity-at'));
|
||||
if(!Number.isFinite(stamp)||stamp<=0)return null;
|
||||
return Math.max(0,_activityNowSeconds()-stamp);
|
||||
}
|
||||
function _activityClockLabel(ts){
|
||||
const stamp=Number(ts||_activityNowSeconds());
|
||||
if(!Number.isFinite(stamp)||stamp<=0)return'';
|
||||
try{return new Date(stamp*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});}catch(_){return'';}
|
||||
}
|
||||
function _activityStatusNode({kind='info',label='',detail='',status='done',ts=null,id=''}){
|
||||
const row=document.createElement('div');
|
||||
row.className=`agent-activity-status agent-activity-status-${kind} agent-activity-status-${status}`;
|
||||
if(id) row.setAttribute('data-activity-event-id',id);
|
||||
if(ts) row.setAttribute('data-activity-at',String(ts));
|
||||
const iconMap={run:li('play',13),model:li('bot',13),waiting:'<span class="tool-card-running-dot"></span>',thinking:li('lightbulb',13),tool:li('wrench',13),done:li('check',13),warning:li('alert-triangle',13)};
|
||||
row.innerHTML=`<span class="agent-activity-status-icon">${iconMap[kind]||iconMap.info||li('circle',13)}</span><span class="agent-activity-status-copy"><span class="agent-activity-status-label">${esc(label)}</span>${detail?`<span class="agent-activity-status-detail">${esc(detail)}</span>`:''}</span><span class="agent-activity-status-time">${esc(_activityClockLabel(ts))}</span>`;
|
||||
return row;
|
||||
}
|
||||
function _appendActivityEvent(group, event){
|
||||
if(!group)return null;
|
||||
const body=group.querySelector('.tool-call-group-body');
|
||||
if(!body)return null;
|
||||
const eventId=event&&event.id;
|
||||
let row=eventId?body.querySelector(`.agent-activity-status[data-activity-event-id="${CSS.escape(eventId)}"]`):null;
|
||||
const next=_activityStatusNode(event||{});
|
||||
if(row){row.replaceWith(next);row=next;}
|
||||
else{body.appendChild(next);row=next;}
|
||||
_activityMarkObserved(group,event&&event.ts);
|
||||
return row;
|
||||
}
|
||||
function _ensureLiveActivityBaseline(group){
|
||||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
|
||||
const started=_activityElapsedStartedAt(group)||_activityNowSeconds();
|
||||
if(!group.getAttribute('data-turn-started-at')) group.setAttribute('data-turn-started-at',String(started));
|
||||
if(!group.getAttribute('data-last-activity-at')) group.setAttribute('data-last-activity-at',String(started));
|
||||
_appendActivityEvent(group,{id:'run-started',kind:'run',label:'Run started',detail:'Observable activity will appear here as the agent works.',status:'done',ts:started});
|
||||
const modelLabel=(S.session&&S.session.model)?getModelLabel(S.session.model):'';
|
||||
if(modelLabel)_appendActivityEvent(group,{id:'run-model',kind:'model',label:`Model: ${modelLabel}`,detail:S.activeProfile&&S.activeProfile!=='default'?`Profile: ${S.activeProfile}`:'',status:'done',ts:started});
|
||||
}
|
||||
function _setActivityElapsedStartedAt(group){
|
||||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
|
||||
@@ -2240,8 +2286,10 @@ function _updateActiveActivityElapsedTimer(){
|
||||
group.removeAttribute('data-active-turn-elapsed');
|
||||
}
|
||||
if(durationEl){
|
||||
durationEl.textContent=label?`Working ${label}`:'';
|
||||
durationEl.style.display=label?'':'none';
|
||||
const activeText=label?`Working for ${label}`:'';
|
||||
const progressText=_activityLiveProgressLabel(group);
|
||||
durationEl.textContent=[progressText, activeText].filter(Boolean).join(' · ');
|
||||
durationEl.style.display=durationEl.textContent?'':'none';
|
||||
}
|
||||
}
|
||||
function _startActivityElapsedTimer(group){
|
||||
@@ -5219,7 +5267,10 @@ function ensureActivityGroup(inner, opts){
|
||||
}else if(activityKey&&!group.getAttribute('data-activity-disclosure-key')){
|
||||
group.setAttribute('data-activity-disclosure-key',activityKey);
|
||||
}
|
||||
if(live) _setActivityElapsedStartedAt(group);
|
||||
if(live){
|
||||
_setActivityElapsedStartedAt(group);
|
||||
_ensureLiveActivityBaseline(group);
|
||||
}
|
||||
_syncToolCallGroupSummary(group);
|
||||
if(live) _startActivityElapsedTimer(group);
|
||||
return group;
|
||||
@@ -6657,7 +6708,9 @@ function _syncToolCallGroupSummary(group){
|
||||
const label=group.querySelector('.tool-call-group-label');
|
||||
const durationEl=group.querySelector('.tool-call-group-duration');
|
||||
if(label){
|
||||
if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
|
||||
if(group.getAttribute('data-live-tool-call-group')==='1'){
|
||||
label.textContent=toolCount?`Activity: ${toolCount} tool${toolCount===1?'':'s'}`:'Activity · Running';
|
||||
}else if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
|
||||
else label.textContent='Activity';
|
||||
label.setAttribute('data-sweep-label', label.textContent);
|
||||
}
|
||||
@@ -6691,9 +6744,14 @@ function _activityProgressLabelForToolName(name){
|
||||
|
||||
function _activityLiveProgressLabel(group){
|
||||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1') return '';
|
||||
const idleAge=_activityLastObservedAge(group);
|
||||
if(idleAge!==null&&idleAge>=90) return `No recent activity for ${_formatActiveElapsedTimer(idleAge)}`;
|
||||
const running=group.querySelector('.tool-card.tool-card-running .tool-card-name');
|
||||
const latest=running || Array.from(group.querySelectorAll('.tool-card-name')).pop();
|
||||
return _activityProgressLabelForToolName(latest?latest.textContent:'');
|
||||
const waiting=group.querySelector('.agent-activity-status-waiting .agent-activity-status-label');
|
||||
if(latest) return _activityProgressLabelForToolName(latest.textContent);
|
||||
if(waiting&&waiting.textContent) return waiting.textContent;
|
||||
return 'Starting agent';
|
||||
}
|
||||
|
||||
// ── Live tool card helpers (called during SSE streaming) ──
|
||||
@@ -6759,6 +6817,19 @@ function appendLiveToolCard(tc){
|
||||
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:true,anchor,activityKey:_activityKeyForLiveTurn()});
|
||||
const body=group.querySelector('.tool-call-group-body');
|
||||
const toolName=_toolDisplayName(tc);
|
||||
const toolEventId=tid?`tool-${tid}`:`tool-${String(tc.name||'tool').replace(/[^a-z0-9_-]/gi,'_')}`;
|
||||
const toolDone=tc.done!==false;
|
||||
_appendActivityEvent(group,{
|
||||
id:toolEventId,
|
||||
kind:'tool',
|
||||
label:toolDone?`Tool finished: ${toolName}`:`Running tool: ${toolName}`,
|
||||
detail:tc.preview||tc.snippet||'',
|
||||
status:toolDone?(tc.is_error?'error':'done'):'waiting',
|
||||
ts:_activityNowSeconds(),
|
||||
});
|
||||
const waiting=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"] .agent-activity-status-label');
|
||||
if(waiting&&!toolDone) waiting.textContent='Waiting on tool result';
|
||||
// Update existing card in place (tool_complete after tool_start)
|
||||
if(tid){
|
||||
const existing=body.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
|
||||
@@ -7583,6 +7654,7 @@ function appendThinking(text='', options){
|
||||
return;
|
||||
}
|
||||
const thinkingText=String(text||'').trim()||'Thinking…';
|
||||
const cleanThinking=_sanitizeThinkingDisplayText(thinkingText);
|
||||
const allChildren=Array.from(blocks.children);
|
||||
const anchor=allChildren.filter(el=>
|
||||
el.id!=='toolRunningRow' &&
|
||||
@@ -7591,6 +7663,20 @@ function appendThinking(text='', options){
|
||||
const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
|
||||
const body=group&&group.querySelector('.tool-call-group-body');
|
||||
if(!body) return;
|
||||
if(!cleanThinking||cleanThinking==='Thinking…'){
|
||||
const label=body.querySelector('.tool-card.tool-card-running')?'Waiting on tool result':'Waiting on model';
|
||||
const detail=body.querySelector('.tool-card-row')
|
||||
? 'The agent is running; tool results and response text will appear here.'
|
||||
: 'No tool activity has been reported yet.';
|
||||
_appendActivityEvent(group,{id:'thinking-placeholder',kind:'waiting',label,detail,status:'waiting',ts:_activityNowSeconds()});
|
||||
const active=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(active) active.removeAttribute('data-thinking-active');
|
||||
_syncToolCallGroupSummary(group);
|
||||
scrollIfPinned();
|
||||
return;
|
||||
}
|
||||
const placeholder=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"]');
|
||||
if(placeholder) placeholder.remove();
|
||||
let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||||
if(!row){
|
||||
const thinkingCards=Array.from(body.querySelectorAll('.agent-activity-thinking'));
|
||||
@@ -7598,12 +7684,13 @@ function appendThinking(text='', options){
|
||||
if(row) row.setAttribute('data-thinking-active','1');
|
||||
}
|
||||
if(!row){
|
||||
row=_thinkingActivityNode(thinkingText, false);
|
||||
row=_thinkingActivityNode(cleanThinking, false);
|
||||
row.setAttribute('data-thinking-active','1');
|
||||
body.appendChild(row);
|
||||
}else{
|
||||
_renderThinkingInto(row,thinkingText);
|
||||
_renderThinkingInto(row,cleanThinking);
|
||||
}
|
||||
_activityMarkObserved(group);
|
||||
_syncToolCallGroupSummary(group);
|
||||
scrollIfPinned();
|
||||
if(_scrollPinned){
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Regression coverage for live Activity timeline UX.
|
||||
|
||||
The live Activity disclosure should surface observable run telemetry instead of a
|
||||
blank Thinking placeholder while preserving the quiet tool/thinking metadata
|
||||
family.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_live_activity_group_has_observable_baseline_events():
|
||||
assert "function _ensureLiveActivityBaseline(group)" in UI_JS
|
||||
assert "Run started" in UI_JS
|
||||
assert "Observable activity will appear here as the agent works." in UI_JS
|
||||
assert "Model: ${modelLabel}" in UI_JS
|
||||
assert "_ensureLiveActivityBaseline(group);" in UI_JS
|
||||
|
||||
|
||||
def test_empty_thinking_placeholder_becomes_status_row_not_raw_thinking_card():
|
||||
assert "data-activity-event-id=\"thinking-placeholder\"" in UI_JS
|
||||
assert "Waiting on model" in UI_JS
|
||||
assert "No tool activity has been reported yet." in UI_JS
|
||||
assert "Waiting on tool result" in UI_JS
|
||||
assert "_thinkingActivityNode(cleanThinking, false)" in UI_JS
|
||||
|
||||
|
||||
def test_tool_events_update_activity_timeline_and_summary():
|
||||
assert "Tool finished: ${toolName}" in UI_JS
|
||||
assert "Running tool: ${toolName}" in UI_JS
|
||||
assert "No recent activity for ${_formatActiveElapsedTimer(idleAge)}" in UI_JS
|
||||
assert "Activity · Running" in UI_JS
|
||||
assert "Working for ${label}" in UI_JS
|
||||
|
||||
|
||||
def test_activity_status_rows_have_quiet_metadata_styling():
|
||||
assert ".agent-activity-status{" in STYLE_CSS
|
||||
assert "grid-template-columns:18px minmax(0,1fr) auto" in STYLE_CSS
|
||||
assert ".agent-activity-status-detail" in STYLE_CSS
|
||||
assert ".agent-activity-status-time" in STYLE_CSS
|
||||
assert ".agent-activity-status-error .agent-activity-status-label{color:var(--error);}" in STYLE_CSS
|
||||
Reference in New Issue
Block a user