fix(tui): stop stale cached session title from pinning New Session

build_session_snapshot restored the title from the in-memory cache before
the disk lifecycle merge, and the cache is only refreshed at the end of
the function. A snapshot taken before the first user message therefore
pinned the placeholder title forever: every later snapshot overwrote
the conversation-derived title with the stale cached copy.

Title now resolves in priority order:
1. disk record, when the session already exists (user renames survive
   autosave, #2934/#4397);
2. in-memory cache, when no disk record exists for the session yet;
3. the title computed from the conversation (first user message).

A placeholder that survived from an earlier snapshot yields to the
computed title once a user message exists, healing both fresh and
pre-existing sessions. The placeholder string is centralized in
DEFAULT_SESSION_TITLE so the healing rule cannot drift from the
generator.

Regression tests: stale cached placeholder no longer overrides the
generated title; a persisted placeholder record yields to the computed
title. Existing picker-rename tests (rename survives autosave) still
pass. Full codewhale-tui suite: 9708 passed; 10 failures all verified
pre-existing on main (6) or parallel-flaky (4, pass in isolation).

Reviewed by a sub-agent reviewer: no Critical/Major findings; Minor
findings addressed (comments corrected, placeholder centralized, cache
assertions completed); one documented edge (a session deliberately
renamed to the literal placeholder title yields to the computed title).
This commit is contained in:
Shizuku
2026-08-07 13:48:57 +08:00
committed by CodeWhale Bot
parent efcf47a1d1
commit bf69e7ff54
5 changed files with 128 additions and 7 deletions
@@ -228,7 +228,7 @@ pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult {
app.tool_evidence.clear();
app.current_session_id = Some(new_id.clone());
app.current_session_metadata = None;
app.session_title = Some("New Session".to_string());
app.session_title = Some(crate::session_manager::DEFAULT_SESSION_TITLE.to_string());
app.scroll_to_bottom();
CommandResult::with_message_and_action(
+13 -3
View File
@@ -1316,7 +1316,11 @@ pub(crate) fn workspace_scope_matches(saved_workspace: &Path, current_workspace:
}
fn is_empty_auto_created_session(session: &SessionMetadata) -> bool {
session.message_count == 0 && session.title.trim().eq_ignore_ascii_case("New Session")
session.message_count == 0
&& session
.title
.trim()
.eq_ignore_ascii_case(DEFAULT_SESSION_TITLE)
}
fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
@@ -1471,6 +1475,12 @@ pub fn create_saved_session(
)
}
/// Placeholder title used for a session that has no first user message yet.
/// `build_session_snapshot` (tui/ui/frame.rs) treats a title equal to this
/// constant as an auto-generated placeholder and lets the conversation-derived
/// title win once a user message exists. Keep this string stable on purpose.
pub(crate) const DEFAULT_SESSION_TITLE: &str = "New Session";
/// Create a new `SavedSession` from conversation state with optional mode label
pub fn create_saved_session_with_mode(
messages: &[Message],
@@ -1520,7 +1530,7 @@ pub fn create_saved_session_with_id_and_mode(
_ => None,
})
})
.unwrap_or_else(|| "New Session".to_string());
.unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string());
SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
@@ -2082,7 +2092,7 @@ mod tests {
messages: Vec::new(),
metadata: SessionMetadata {
id: id.to_string(),
title: "New Session".to_string(),
title: DEFAULT_SESSION_TITLE.to_string(),
created_at: updated_at,
updated_at,
message_count: 0,
+5 -1
View File
@@ -277,7 +277,11 @@ fn candidate_ids(
/// An auto-created, never-used session is not something to resume into.
/// Mirrors the filter `get_latest_session_for_workspace` applies.
fn is_empty_placeholder(metadata: &crate::session_manager::SessionMetadata) -> bool {
metadata.message_count == 0 && metadata.title.trim().eq_ignore_ascii_case("New Session")
metadata.message_count == 0
&& metadata
.title
.trim()
.eq_ignore_ascii_case(crate::session_manager::DEFAULT_SESSION_TITLE)
}
fn plural_sessions(count: usize) -> &'static str {
+30 -2
View File
@@ -357,13 +357,13 @@ pub(crate) fn build_session_snapshot(
Some(app.mode.as_setting()),
)
};
let computed_title = session.metadata.title.clone();
if let Some(cached) = app
.current_session_metadata
.as_ref()
.filter(|cached| cached.id == session.metadata.id)
{
session.metadata.created_at = cached.created_at;
session.metadata.title.clone_from(&cached.title);
session
.metadata
.parent_session_id
@@ -375,7 +375,35 @@ pub(crate) fn build_session_snapshot(
// Re-reading here is what makes "an archive or rename cannot be reverted
// by autosave" true regardless of which surface applied it or when
// (#2934 / #4397). One bounded metadata-prefix read, not a transcript scan.
let _ = manager.merge_persisted_lifecycle(&mut session.metadata);
let merged = manager.merge_persisted_lifecycle(&mut session.metadata);
// Title resolution, in priority order:
// 1. Disk, when the session already exists (#2934/#4397: a rename applied
// through the session manager is persisted and must survive autosave).
// 2. The in-memory cache, when there is no disk record for the session
// yet. (The session picker normally persists renames to disk first via
// `rename_selected`; this branch covers sessions that have never been
// saved, where the cache is the only title source.)
// 3. The title computed from the conversation (first user message).
// The cache is NOT a candidate on its own: it is only refreshed at the
// end of this function, so a snapshot taken before any user message
// pins it to the `DEFAULT_SESSION_TITLE` placeholder, and restoring it
// would prevent every later title update (the bug this block fixes).
if !merged
&& let Some(cached) = app.current_session_metadata.as_ref()
&& cached.id == session.metadata.id
{
session.metadata.title.clone_from(&cached.title);
}
if session.metadata.title == crate::session_manager::DEFAULT_SESSION_TITLE
&& computed_title != crate::session_manager::DEFAULT_SESSION_TITLE
{
// The placeholder survived from an earlier snapshot; the conversation
// now has a real first user message, so let the computed title win.
// Known edge: a session deliberately renamed to the literal
// placeholder title is treated the same way and yields to the
// computed title on the next snapshot.
session.metadata.title = computed_title;
}
if let Some(cached) = app.current_session_metadata.as_mut()
&& cached.id == session.metadata.id
{
+79
View File
@@ -14165,6 +14165,85 @@ fn picker_renamed_active_title_survives_automatic_snapshot() {
assert_eq!(snapshot.metadata.forked_from_message_count, Some(7));
}
#[test]
fn stale_cached_placeholder_title_does_not_override_generated_title() {
let mut app = create_test_app();
let manager = SessionManager::new(tempfile::tempdir().expect("tempdir").path().to_path_buf())
.expect("session manager");
app.api_messages.push(crate::models::Message {
role: "user".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: "Please fix the login bug".to_string(),
cache_control: None,
}],
});
// Cache pinned to the placeholder title — exactly what the old behavior
// left behind when the first snapshot ran before any user message existed.
let mut cached = crate::session_manager::create_saved_session_with_id_and_mode(
"session-title-bug".to_string(),
&app.api_messages,
&app.model,
&app.workspace,
0,
app.system_prompt.as_ref(),
Some(app.mode.as_setting()),
)
.metadata;
cached.title = "New Session".to_string();
app.current_session_id = Some(cached.id.clone());
app.current_session_metadata = Some(cached);
let snapshot = build_session_snapshot(&mut app, &manager).expect("snapshot");
assert_eq!(snapshot.metadata.title, "Please fix the login bug");
assert_eq!(
app.current_session_metadata
.as_ref()
.map(|metadata| metadata.title.as_str()),
Some("Please fix the login bug")
);
}
#[test]
fn persisted_placeholder_title_yields_to_computed_title_when_conversation_has_content() {
let mut app = create_test_app();
let dir = tempfile::tempdir().expect("tempdir");
let manager = SessionManager::new(dir.path().join("sessions")).expect("session manager");
// A session whose first save happened before any user message existed:
// the old behavior pinned its title to the "New Session" placeholder on
// disk, and a later snapshot must let the computed title win.
let stale = crate::session_manager::create_saved_session_with_id_and_mode(
"session-stale-title".to_string(),
&[],
&app.model,
&app.workspace,
0,
app.system_prompt.as_ref(),
Some(app.mode.as_setting()),
);
assert_eq!(stale.metadata.title, "New Session");
manager.save_session(&stale).expect("save stale session");
app.api_messages.push(crate::models::Message {
role: "user".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: "fix me".to_string(),
cache_control: None,
}],
});
app.current_session_id = Some(stale.metadata.id.clone());
app.current_session_metadata = Some(stale.metadata.clone());
let snapshot = build_session_snapshot(&mut app, &manager).expect("snapshot");
assert_eq!(snapshot.metadata.title, "fix me");
assert_eq!(
app.current_session_metadata
.as_ref()
.map(|metadata| metadata.title.as_str()),
Some("fix me")
);
}
#[test]
fn picker_rename_of_inactive_session_does_not_touch_active_metadata() {
let mut app = create_test_app();