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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user