From 9ed063931977eb30e931d0675c3d263c0734e057 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 3 May 2026 20:10:05 -0700 Subject: [PATCH] fix: show first-turn chats in sidebar immediately --- api/models.py | 21 +++++-- static/messages.js | 17 ++++- static/sessions.js | 28 +++++++++ tests/test_sidebar_first_turn_visibility.py | 69 +++++++++++++++++++++ 4 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 tests/test_sidebar_first_turn_visibility.py diff --git a/api/models.py b/api/models.py index 66253bbd..6a04ce18 100644 --- a/api/models.py +++ b/api/models.py @@ -524,20 +524,27 @@ class Session: def compact(self, include_runtime=False, active_stream_ids=None) -> dict: active_stream_ids = active_stream_ids if active_stream_ids is not None else set() + has_pending_user_message = bool(self.pending_user_message) + message_count = ( + self._metadata_message_count + if self._metadata_message_count is not None + else len(self.messages) + ) + if has_pending_user_message: + message_count = max(message_count, 1) + last_message_at = _last_message_timestamp(self.messages) or self.updated_at + if has_pending_user_message and self.pending_started_at: + last_message_at = self.pending_started_at return { 'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'model_provider': self.model_provider, - 'message_count': ( - self._metadata_message_count - if self._metadata_message_count is not None - else len(self.messages) - ), + 'message_count': message_count, 'created_at': self.created_at, 'updated_at': self.updated_at, - 'last_message_at': _last_message_timestamp(self.messages) or self.updated_at, + 'last_message_at': last_message_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, @@ -556,6 +563,7 @@ class Session: **({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}), 'active_stream_id': self.active_stream_id, 'pending_user_message': self.pending_user_message, + 'has_pending_user_message': has_pending_user_message, 'is_cli_session': self.is_cli_session, 'source_tag': self.source_tag, 'raw_source': self.raw_source, @@ -926,6 +934,7 @@ def all_sessions(): s.get('title', 'Untitled') == 'Untitled' and s.get('message_count', 0) == 0 and not s.get('active_stream_id') + and not s.get('has_pending_user_message') )] result = [s for s in result if not _hide_from_default_sidebar(s)] # Backfill: sessions created before Sprint 22 have no profile tag. diff --git a/static/messages.js b/static/messages.js index 36cd47ad..1e50807b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -177,6 +177,9 @@ async function send(){ S.toolCalls=[]; // clear tool calls from previous turn clearLiveToolCards(); // clear any leftover live cards from last turn S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); + if(typeof upsertActiveSessionForLocalTurn==='function'){ + upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); + } INFLIGHT[activeSid]={messages:[...S.messages],uploaded:uploadedNames,toolCalls:[]}; if(typeof saveInflightState==='function'){ saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]}); @@ -193,13 +196,18 @@ async function send(){ const provisionalTitle=displayText.slice(0,64); S.session.title=provisionalTitle; syncTopbar(); - // Persist it and refresh the sidebar now -- don't wait for done + // Persist it in the background; keep the optimistic sidebar cache as the + // immediate source of truth until /api/chat/start saves pending state. api('/api/session/rename',{method:'POST',body:JSON.stringify({ session_id:activeSid, title:provisionalTitle })}).catch(()=>{}); // fire-and-forget, server refines on done - renderSessionList(); // session appears in sidebar immediately + if(typeof upsertActiveSessionForLocalTurn==='function'){ + upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()}); + }else if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); + } else if(typeof upsertActiveSessionForLocalTurn==='function'){ + upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); } else { - renderSessionList(); // ensure it's visible even if already titled + renderSessionListFromCache(); // ensure it's visible even if already titled } // Start the agent via POST, get a stream_id back @@ -230,6 +238,9 @@ async function send(){ if(S.session&&S.session.session_id===activeSid){ S.session.active_stream_id = streamId; } + if(typeof upsertActiveSessionForLocalTurn==='function'){ + upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); + } markInflight(activeSid, streamId); if(typeof saveInflightState==='function'){ saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:INFLIGHT[activeSid].toolCalls||[]}); diff --git a/static/sessions.js b/static/sessions.js index 6e6c4fe6..715aa168 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1768,6 +1768,34 @@ function _activeSessionIdForSidebar(){ return null; } +function upsertActiveSessionForLocalTurn({title='', messageCount=0, timestampMs=Date.now()}={}){ + if(!S.session||!S.session.session_id) return; + const sid=S.session.session_id; + const nowSec=Math.floor((Number(timestampMs)||Date.now())/1000); + const localCount=Array.isArray(S.messages)?S.messages.length:0; + const count=Math.max(Number(S.session.message_count||0),Number(messageCount||0),localCount,1); + S.session.message_count=count; + S.session.last_message_at=nowSec; + S.session.updated_at=nowSec; + if((S.session.title==='Untitled'||!S.session.title)&&title){ + S.session.title=title; + } + const existingIdx=_allSessions.findIndex(s=>s&&s.session_id===sid); + const row={ + ...S.session, + session_id:sid, + title:S.session.title||title||'New chat', + message_count:count, + last_message_at:nowSec, + updated_at:nowSec, + profile:S.session.profile||S.activeProfile||'default', + is_streaming:true, + }; + if(existingIdx>=0) _allSessions[existingIdx]={..._allSessions[existingIdx],...row}; + else _allSessions.unshift(row); + renderSessionListFromCache(); +} + function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; diff --git a/tests/test_sidebar_first_turn_visibility.py b/tests/test_sidebar_first_turn_visibility.py new file mode 100644 index 00000000..260f4165 --- /dev/null +++ b/tests/test_sidebar_first_turn_visibility.py @@ -0,0 +1,69 @@ +"""Regressions for first-turn sessions appearing in the sidebar immediately.""" + +import pathlib + +REPO = pathlib.Path(__file__).parent.parent + + +def read(rel: str) -> str: + return (REPO / rel).read_text(encoding="utf-8") + + +class TestSidebarFirstTurnVisibility: + def test_messages_send_optimistically_upserts_active_sidebar_row(self): + src = read("static/messages.js") + assert "upsertActiveSessionForLocalTurn" in src, ( + "send() must optimistically upsert the active session into the sidebar " + "as soon as the local user message is pushed." + ) + push_idx = src.index("S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true);") + helper_idx = src.index("upsertActiveSessionForLocalTurn", push_idx) + start_idx = src.index("api('/api/chat/start'", push_idx) + assert helper_idx < start_idx, ( + "The sidebar row must be rendered before /api/chat/start returns so " + "tool calls are reachable while the first agent turn is still running." + ) + pre_start = src[helper_idx:start_idx] + assert "renderSessionList();" not in pre_start, ( + "Do not re-fetch /api/sessions before /api/chat/start saves pending state; " + "that race can overwrite the optimistic first-turn row with an empty list." + ) + + def test_sessions_js_has_local_turn_upsert_helper(self): + src = read("static/sessions.js") + assert "function upsertActiveSessionForLocalTurn" in src + start = src.index("function upsertActiveSessionForLocalTurn") + end = src.index("function renderSessionListFromCache", start) + body = src[start:end] + assert "_allSessions.unshift" in body or "_allSessions.splice" in body, ( + "Helper must add a missing active session to the cached sidebar list." + ) + assert "S.session.message_count" in body and "S.messages.length" in body, ( + "Helper must treat the locally pushed user message as a real sidebar message." + ) + assert "is_streaming:true" in body.replace(" ", ""), ( + "Optimistic row should render as streaming until the backend reconciles." + ) + + def test_backend_compact_counts_pending_first_turn_as_visible(self): + src = read("api/models.py") + compact = src[src.index("def compact"):src.index("def _get_profile_home")] + assert "has_pending_user_message" in compact and "pending_user_message" in compact, ( + "Session.compact() must account for pending_user_message in sidebar metadata." + ) + assert "message_count = max(message_count, 1)" in compact, ( + "Pending first user turn should make message_count non-zero for /api/sessions." + ) + assert "pending_started_at" in compact and "last_message_at" in compact, ( + "Pending first user turn should sort by pending_started_at in the sidebar." + ) + + def test_backend_index_filter_keeps_pending_first_turn_sessions(self): + src = read("api/models.py") + index_filter_start = src.index("# Hide empty Untitled sessions from the UI entirely") + index_filter_end = src.index("result = [s for s in result if not _hide_from_default_sidebar", index_filter_start) + index_filter = src[index_filter_start:index_filter_end] + assert "has_pending_user_message" in index_filter, ( + "The index-path empty-session filter must exempt pending first-turn sessions, " + "matching the full-scan fallback." + )