diff --git a/api/routes.py b/api/routes.py index e2139a0c..3e6dd65c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5986,7 +5986,11 @@ def _handle_chat_start(handler, body): daemon=True, ) 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: response["effective_model"] = model if model_provider: diff --git a/docs/pr-media/1716/active-elapsed-timer.png b/docs/pr-media/1716/active-elapsed-timer.png new file mode 100644 index 00000000..59f468e9 Binary files /dev/null and b/docs/pr-media/1716/active-elapsed-timer.png differ diff --git a/static/messages.js b/static/messages.js index c32d8fdc..b54212ce 100644 --- a/static/messages.js +++ b/static/messages.js @@ -243,6 +243,9 @@ async function send(){ } streamId=startData.stream_id; 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){ S.session.active_stream_id = streamId; } diff --git a/static/ui.js b/static/ui.js index 72adb997..af499ba8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1420,6 +1420,72 @@ function _formatTurnDuration(seconds){ if(h)return`${h}h ${m}m`; 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'; @@ -2438,6 +2504,7 @@ function setBusy(v){ S.busy=v; updateSendBtn(); if(!v){ + if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer(); setStatus(''); setComposerStatus(''); 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); else inner.appendChild(group); } + if(live) _setActivityElapsedStartedAt(group); _syncToolCallGroupSummary(group); + if(live) _startActivityElapsedTimer(group); return group; } function _compressionStateForCurrentSession(){ @@ -4807,9 +4876,17 @@ function _syncToolCallGroupSummary(group){ } if(list) list.textContent=parts.join(' · ')||'tools / thinking'; if(durationEl){ - const durationText=_formatTurnDuration(group.dataset.turnDuration); - durationEl.textContent=durationText?`Done in ${durationText}`:''; - durationEl.style.display=durationText?'':'none'; + if(group.getAttribute('data-live-tool-call-group')==='1'){ + const activeText=_activityElapsedLabel(group); + 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); } @@ -4896,6 +4973,7 @@ function appendLiveToolCard(tc){ } function clearLiveToolCards(){ + if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer(); 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()); // 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('.tool-call-group[data-agent-activity-group="1"]').forEach(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(); } diff --git a/tests/test_turn_duration_display.py b/tests/test_turn_duration_display.py index b87deb74..2bd1aef5 100644 --- a/tests/test_turn_duration_display.py +++ b/tests/test_turn_duration_display.py @@ -8,6 +8,7 @@ from pathlib import Path REPO = Path(__file__).resolve().parent.parent STREAMING_PY = (REPO / "api" / "streaming.py").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") 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, ( "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." + )