feat: show active elapsed timer in compact activity

This commit is contained in:
Michael Lam 2026-05-05 13:42:47 -07:00
parent 0ea3dfbdd1
commit 2c5acb9725
5 changed files with 120 additions and 5 deletions

View file

@ -5986,7 +5986,11 @@ def _handle_chat_start(handler, body):
daemon=True, daemon=True,
) )
thr.start() thr.start()
response = {"stream_id": stream_id, "session_id": s.session_id} response = {
"stream_id": stream_id,
"session_id": s.session_id,
"pending_started_at": s.pending_started_at,
}
if normalized_model: if normalized_model:
response["effective_model"] = model response["effective_model"] = model
if model_provider: if model_provider:

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -243,6 +243,9 @@ async function send(){
} }
streamId=startData.stream_id; streamId=startData.stream_id;
S.activeStreamId = streamId; S.activeStreamId = streamId;
if(S.session&&typeof startData.pending_started_at==='number'){
S.session.pending_started_at=startData.pending_started_at;
}
if(S.session&&S.session.session_id===activeSid){ if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id = streamId; S.session.active_stream_id = streamId;
} }

View file

@ -1420,6 +1420,72 @@ function _formatTurnDuration(seconds){
if(h)return`${h}h ${m}m`; if(h)return`${h}h ${m}m`;
return`${m}m ${s}s`; return`${m}m ${s}s`;
} }
function _formatActiveElapsedTimer(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
const total=Math.max(0,Math.floor(n));
const m=Math.floor(total/60);
const s=total%60;
return`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
let _activityElapsedTimer=null;
let _activityElapsedTimerGroup=null;
function _activityElapsedStartedAt(group){
if(!group)return null;
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
?group.dataset.turnStartedAt
:(S.session&&S.session.pending_started_at);
const started=Number(raw);
return Number.isFinite(started)&&started>0?started:null;
}
function _activityElapsedLabel(group){
const started=_activityElapsedStartedAt(group);
if(!started)return'';
return _formatActiveElapsedTimer((Date.now()/1000)-started);
}
function _setActivityElapsedStartedAt(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const started=_activityElapsedStartedAt(group);
if(started)group.setAttribute('data-turn-started-at',String(started));
}
function _updateActiveActivityElapsedTimer(){
const group=_activityElapsedTimerGroup;
if(!group||!group.isConnected||group.getAttribute('data-live-tool-call-group')!=='1'){
_clearActivityElapsedTimer();
return;
}
const durationEl=group.querySelector('.tool-call-group-duration');
const label=_activityElapsedLabel(group);
if(label){
group.setAttribute('data-active-turn-elapsed',label);
}else{
group.removeAttribute('data-active-turn-elapsed');
}
if(durationEl){
durationEl.textContent=label?`Working ${label}`:'';
durationEl.style.display=label?'':'none';
}
}
function _startActivityElapsedTimer(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
_setActivityElapsedStartedAt(group);
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup!==group)_clearActivityElapsedTimer();
_activityElapsedTimerGroup=group;
_updateActiveActivityElapsedTimer();
if(!_activityElapsedTimer)_activityElapsedTimer=setInterval(_updateActiveActivityElapsedTimer,1000);
}
function _clearActivityElapsedTimer(){
if(_activityElapsedTimer){
clearInterval(_activityElapsedTimer);
_activityElapsedTimer=null;
}
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup.isConnected){
_activityElapsedTimerGroup.removeAttribute('data-active-turn-elapsed');
const durationEl=_activityElapsedTimerGroup.querySelector('.tool-call-group-duration');
if(durationEl){durationEl.textContent='';durationEl.style.display='none';}
}
_activityElapsedTimerGroup=null;
}
const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings'; const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings';
@ -2438,6 +2504,7 @@ function setBusy(v){
S.busy=v; S.busy=v;
updateSendBtn(); updateSendBtn();
if(!v){ if(!v){
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
setStatus(''); setStatus('');
setComposerStatus(''); setComposerStatus('');
const sid=_queueDrainSid||(S.session&&S.session.session_id); const sid=_queueDrainSid||(S.session&&S.session.session_id);
@ -3727,7 +3794,9 @@ function ensureActivityGroup(inner, opts){
if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group); if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group);
else inner.appendChild(group); else inner.appendChild(group);
} }
if(live) _setActivityElapsedStartedAt(group);
_syncToolCallGroupSummary(group); _syncToolCallGroupSummary(group);
if(live) _startActivityElapsedTimer(group);
return group; return group;
} }
function _compressionStateForCurrentSession(){ function _compressionStateForCurrentSession(){
@ -4807,9 +4876,17 @@ function _syncToolCallGroupSummary(group){
} }
if(list) list.textContent=parts.join(' · ')||'tools / thinking'; if(list) list.textContent=parts.join(' · ')||'tools / thinking';
if(durationEl){ if(durationEl){
const durationText=_formatTurnDuration(group.dataset.turnDuration); if(group.getAttribute('data-live-tool-call-group')==='1'){
durationEl.textContent=durationText?`Done in ${durationText}`:''; const activeText=_activityElapsedLabel(group);
durationEl.style.display=durationText?'':'none'; if(activeText) group.setAttribute('data-active-turn-elapsed',activeText);
else group.removeAttribute('data-active-turn-elapsed');
durationEl.textContent=activeText?`Working ${activeText}`:'';
durationEl.style.display=activeText?'':'none';
}else{
const durationText=_formatTurnDuration(group.dataset.turnDuration);
durationEl.textContent=durationText?`Done in ${durationText}`:'';
durationEl.style.display=durationText?'':'none';
}
} }
if(badge) badge.textContent=String(total); if(badge) badge.textContent=String(total);
} }
@ -4896,6 +4973,7 @@ function appendLiveToolCard(tc){
} }
function clearLiveToolCards(){ function clearLiveToolCards(){
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
const inner=_assistantTurnBlocks($('liveAssistantTurn')); const inner=_assistantTurnBlocks($('liveAssistantTurn'));
if(inner) inner.querySelectorAll('.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove()); if(inner) inner.querySelectorAll('.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove());
// Reset the per-turn user expand intent so the next turn starts at the // Reset the per-turn user expand intent so the next turn starts at the
@ -5699,7 +5777,10 @@ function removeThinking(){
if(blocks) blocks.querySelectorAll('.agent-activity-thinking').forEach(el=>el.remove()); if(blocks) blocks.querySelectorAll('.agent-activity-thinking').forEach(el=>el.remove());
if(blocks) blocks.querySelectorAll('.tool-call-group[data-agent-activity-group="1"]').forEach(group=>{ if(blocks) blocks.querySelectorAll('.tool-call-group[data-agent-activity-group="1"]').forEach(group=>{
_syncToolCallGroupSummary(group); _syncToolCallGroupSummary(group);
if(!group.querySelector('.tool-card-row,.agent-activity-thinking')) group.remove(); if(!group.querySelector('.tool-card-row,.agent-activity-thinking')){
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
group.remove();
}
}); });
if(turn&&blocks&&!blocks.children.length) turn.remove(); if(turn&&blocks&&!blocks.children.length) turn.remove();
} }

View file

@ -8,6 +8,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8") STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
@ -61,3 +62,29 @@ def test_ui_formats_and_renders_turn_duration_in_footer_and_activity_summary():
assert ".msg-duration-inline" in CSS and ".tool-call-group-duration" in CSS, ( assert ".msg-duration-inline" in CSS and ".tool-call-group-duration" in CSS, (
"Duration UI should have explicit CSS hooks for the footer chip and compact activity summary." "Duration UI should have explicit CSS hooks for the footer chip and compact activity summary."
) )
def test_active_compact_activity_elapsed_timer_uses_persisted_start_time():
assert '"pending_started_at": s.pending_started_at' in ROUTES_PY, (
"/api/chat/start should return the persisted pending_started_at timestamp "
"so the live timer starts from backend/session truth."
)
assert "startData.pending_started_at" in MESSAGES_JS, (
"send() should copy chat-start pending_started_at into S.session before "
"attaching the live stream."
)
assert "function _formatActiveElapsedTimer" in UI_JS and "padStart(2,'0')" in UI_JS, (
"ui.js should format the running timer in MM:SS form."
)
assert "data-turn-started-at" in UI_JS and "data-active-turn-elapsed" in UI_JS, (
"Live compact Activity groups need stable start-time and active-elapsed "
"hooks for browser QA and reconnect/rerender safety."
)
assert "Working " in UI_JS, (
"The in-progress Activity summary should distinguish the live counter "
"from the settled 'Done in …' duration."
)
assert "setInterval" in UI_JS and "_clearActivityElapsedTimer" in UI_JS, (
"The active elapsed label should tick while running and clear its interval "
"on terminal/error/session-switch cleanup paths."
)