From d456458133179f12b3f4f7e8561985d20bc71f44 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 25 Jul 2026 12:32:51 +0200 Subject: [PATCH] Title new sessions from their first chat The one-shot from the session quick actions now also runs once when a draft session becomes real, summarizing the opening message instead of leaving the "Session N" placeholder. Failures stay silent on this path: the user did not ask for a title, so a busy runtime must not raise an error banner. A rename made while the request is queued wins over its result. Co-Authored-By: Claude Opus 5 --- src/app.rs | 2 +- src/app/generation.rs | 115 +++++++++++++++++++++++++++--------------- 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/src/app.rs b/src/app.rs index bc38f0e..3c2f603 100644 --- a/src/app.rs +++ b/src/app.rs @@ -771,7 +771,7 @@ impl App { Message::RetitleSession(session_id) => { self.session_menu = None; #[cfg(target_os = "macos")] - self.request_title(session_id); + self.retitle_session(session_id); #[cfg(not(target_os = "macos"))] { let _ = session_id; diff --git a/src/app/generation.rs b/src/app/generation.rs index 8b40e56..0927d13 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -11,6 +11,9 @@ const TITLE_MAX_CHARS: usize = 60; #[cfg(target_os = "macos")] pub(super) struct TitleRequest { session_id: i32, + /// Title the session had when the request was queued. If it changed since, + /// somebody renamed it by hand and that newer intent wins. + expected: String, active: ActiveGeneration, content: String, } @@ -117,6 +120,7 @@ impl App { #[cfg(target_os = "macos")] { // A draft session only reaches the database once there is a turn to store. + let opening_turn = self.selected_session.is_none(); let session_id = match self.selected_session { Some(session_id) => session_id, None => { @@ -171,6 +175,10 @@ impl App { self.generating = true; self.tokens_per_second = None; self.error = None; + if opening_turn { + // Queued behind the reply, so the answer is never delayed by it. + self.request_first_title(session_id); + } } #[cfg(not(target_os = "macos"))] { @@ -280,47 +288,34 @@ impl App { /// Asks the model for a session title without opening a session for it: the /// turn runs against the shared transient KV cache and only its text is kept. + /// + /// Reports failures to the caller instead of the error banner: the automatic + /// first-chat titling must stay silent, while the menu action shows them. #[cfg(target_os = "macos")] - pub(super) fn request_title(&mut self, session_id: i32) { + fn request_title(&mut self, session_id: i32) -> Result<(), String> { if self.active_titling.is_some() { - self.error = Some("A title is already being generated.".into()); - return; + return Err("A title is already being generated.".into()); } - let model = match ModelChoice::from_id(&self.preferences.selected_model) { - Some(model) => model, - None => { - self.error = Some("The selected model is not supported.".into()); - return; - } - }; - let effective = self.preferences.generation().and_then(|generation| { + let model = ModelChoice::from_id(&self.preferences.selected_model) + .ok_or_else(|| "The selected model is not supported.".to_owned())?; + let mut effective = self.preferences.generation().and_then(|generation| { self.preferences.runtime().and_then(|runtime| { crate::settings::effective_settings(model, &generation, &runtime, &models_path()) }) - }); - let mut effective = match effective { - Ok(settings) => settings, - Err(error) => { - self.error = Some(error); - return; - } - }; - let Some(database) = &mut self.database else { - return; - }; - let stored = match database.load_messages(session_id) { - Ok(stored) => stored, - Err(error) => { - self.error = Some(format!("Could not read the session: {error}")); - return; - } - }; - if stored.is_empty() { - self.error = Some("This session has no messages to summarize yet.".into()); - return; - } + })?; + let database = self + .database + .as_mut() + .ok_or_else(|| "The project database is unavailable.".to_owned())?; + let stored = database + .load_messages(session_id) + .map_err(|error| format!("Could not read the session: {error}"))?; + // The assistant row is inserted empty when a turn starts; an empty turn + // is never useful context, and skipping it is what lets the automatic + // pass summarize the user's opening message on its own. let mut messages = stored .into_iter() + .filter(|message| !message.content.trim().is_empty()) .map(|message| ChatTurn { user: message.user, skip_previous_eos: false, @@ -329,6 +324,9 @@ impl App { content: message.content, }) .collect::>(); + if messages.is_empty() { + return Err("This session has no messages to summarize yet.".into()); + } messages.push(ChatTurn { user: true, skip_previous_eos: false, @@ -339,10 +337,10 @@ impl App { effective.turn.max_generated_tokens = TITLE_MAX_TOKENS; effective.turn.reasoning_mode = ReasoningMode::Direct; - let Some(service) = &self.generation_service else { - self.error = Some("The model runtime is unavailable.".into()); - return; - }; + let service = self + .generation_service + .as_ref() + .ok_or_else(|| "The model runtime is unavailable.".to_owned())?; let idle_timeout = Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); match service.generate( @@ -355,18 +353,36 @@ impl App { Ok(active) => { self.active_titling = Some(TitleRequest { session_id, + expected: self.session_title(session_id).unwrap_or_default(), active, content: String::new(), }); - self.error = None; + Ok(()) } Err(error) => { self.generation_service = None; - self.error = Some(error); + Err(error) } } } + /// The "Retitle with AI" menu action, which reports why it could not run. + #[cfg(target_os = "macos")] + pub(super) fn retitle_session(&mut self, session_id: i32) { + match self.request_title(session_id) { + Ok(()) => self.error = None, + Err(error) => self.error = Some(error), + } + } + + /// Titles a session from its opening message. Queued behind the reply that + /// is already running, and skipped without complaint if it cannot start — + /// the user did not ask for this, so it must never interrupt them. + #[cfg(target_os = "macos")] + pub(super) fn request_first_title(&mut self, session_id: i32) { + let _ = self.request_title(session_id); + } + /// Drains the one-shot title generation. Returns true while it is running so /// the caller keeps ticking. #[cfg(target_os = "macos")] @@ -395,7 +411,7 @@ impl App { if let Some(error) = failure { self.error = Some(format!("Could not generate a title: {error}")); } else if let Some(title) = session_title(&request.content) { - self.apply_title(request.session_id, &title); + self.apply_title(&request, &title); } else { self.error = Some("The model did not return a usable title.".into()); } @@ -403,11 +419,26 @@ impl App { } #[cfg(target_os = "macos")] - fn apply_title(&mut self, session_id: i32, title: &str) { + fn session_title(&self, session_id: i32) -> Option { + self.projects + .iter() + .flat_map(|project| &project.sessions) + .find(|session| session.id == session_id) + .map(|session| session.title.clone()) + } + + #[cfg(target_os = "macos")] + fn apply_title(&mut self, request: &TitleRequest, title: &str) { + let Some(current) = self.session_title(request.session_id) else { + return; + }; + if current != request.expected { + return; + } let Some(database) = &mut self.database else { return; }; - match database.rename_session(session_id, title) { + match database.rename_session(request.session_id, title) { Ok(()) => { self.error = None; self.reload_projects();