fix: show first-turn chats in sidebar immediately

This commit is contained in:
Michael Lam committed 2026-05-03 20:10:05 -07:00
1 parent bf7bc6b4c4
commit 9ed0639319
4 files changed
+126 -9

No files matched your search

+15 -6
View File
@@ -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.
+14 -3
View File
@@ -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||[]});
+28
View File
@@ -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;
@@ -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."
)