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 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-25 12:32:51 +02:00
parent af0de86ef8
commit d456458133
2 changed files with 74 additions and 43 deletions

View File

@@ -771,7 +771,7 @@ impl App {
Message::RetitleSession(session_id) => { Message::RetitleSession(session_id) => {
self.session_menu = None; self.session_menu = None;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.request_title(session_id); self.retitle_session(session_id);
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
let _ = session_id; let _ = session_id;

View File

@@ -11,6 +11,9 @@ const TITLE_MAX_CHARS: usize = 60;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(super) struct TitleRequest { pub(super) struct TitleRequest {
session_id: i32, 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, active: ActiveGeneration,
content: String, content: String,
} }
@@ -117,6 +120,7 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// A draft session only reaches the database once there is a turn to store. // 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 { let session_id = match self.selected_session {
Some(session_id) => session_id, Some(session_id) => session_id,
None => { None => {
@@ -171,6 +175,10 @@ impl App {
self.generating = true; self.generating = true;
self.tokens_per_second = None; self.tokens_per_second = None;
self.error = 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"))] #[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 /// 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. /// 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")] #[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() { if self.active_titling.is_some() {
self.error = Some("A title is already being generated.".into()); return Err("A title is already being generated.".into());
return;
} }
let model = match ModelChoice::from_id(&self.preferences.selected_model) { let model = ModelChoice::from_id(&self.preferences.selected_model)
Some(model) => model, .ok_or_else(|| "The selected model is not supported.".to_owned())?;
None => { let mut effective = self.preferences.generation().and_then(|generation| {
self.error = Some("The selected model is not supported.".into());
return;
}
};
let effective = self.preferences.generation().and_then(|generation| {
self.preferences.runtime().and_then(|runtime| { self.preferences.runtime().and_then(|runtime| {
crate::settings::effective_settings(model, &generation, &runtime, &models_path()) crate::settings::effective_settings(model, &generation, &runtime, &models_path())
}) })
}); })?;
let mut effective = match effective { let database = self
Ok(settings) => settings, .database
Err(error) => { .as_mut()
self.error = Some(error); .ok_or_else(|| "The project database is unavailable.".to_owned())?;
return; let stored = database
} .load_messages(session_id)
}; .map_err(|error| format!("Could not read the session: {error}"))?;
let Some(database) = &mut self.database else { // The assistant row is inserted empty when a turn starts; an empty turn
return; // is never useful context, and skipping it is what lets the automatic
}; // pass summarize the user's opening message on its own.
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 mut messages = stored let mut messages = stored
.into_iter() .into_iter()
.filter(|message| !message.content.trim().is_empty())
.map(|message| ChatTurn { .map(|message| ChatTurn {
user: message.user, user: message.user,
skip_previous_eos: false, skip_previous_eos: false,
@@ -329,6 +324,9 @@ impl App {
content: message.content, content: message.content,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if messages.is_empty() {
return Err("This session has no messages to summarize yet.".into());
}
messages.push(ChatTurn { messages.push(ChatTurn {
user: true, user: true,
skip_previous_eos: false, skip_previous_eos: false,
@@ -339,10 +337,10 @@ impl App {
effective.turn.max_generated_tokens = TITLE_MAX_TOKENS; effective.turn.max_generated_tokens = TITLE_MAX_TOKENS;
effective.turn.reasoning_mode = ReasoningMode::Direct; effective.turn.reasoning_mode = ReasoningMode::Direct;
let Some(service) = &self.generation_service else { let service = self
self.error = Some("The model runtime is unavailable.".into()); .generation_service
return; .as_ref()
}; .ok_or_else(|| "The model runtime is unavailable.".to_owned())?;
let idle_timeout = let idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
match service.generate( match service.generate(
@@ -355,18 +353,36 @@ impl App {
Ok(active) => { Ok(active) => {
self.active_titling = Some(TitleRequest { self.active_titling = Some(TitleRequest {
session_id, session_id,
expected: self.session_title(session_id).unwrap_or_default(),
active, active,
content: String::new(), content: String::new(),
}); });
self.error = None; Ok(())
} }
Err(error) => { Err(error) => {
self.generation_service = None; 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 /// Drains the one-shot title generation. Returns true while it is running so
/// the caller keeps ticking. /// the caller keeps ticking.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -395,7 +411,7 @@ impl App {
if let Some(error) = failure { if let Some(error) = failure {
self.error = Some(format!("Could not generate a title: {error}")); self.error = Some(format!("Could not generate a title: {error}"));
} else if let Some(title) = session_title(&request.content) { } else if let Some(title) = session_title(&request.content) {
self.apply_title(request.session_id, &title); self.apply_title(&request, &title);
} else { } else {
self.error = Some("The model did not return a usable title.".into()); self.error = Some("The model did not return a usable title.".into());
} }
@@ -403,11 +419,26 @@ impl App {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn apply_title(&mut self, session_id: i32, title: &str) { fn session_title(&self, session_id: i32) -> Option<String> {
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 { let Some(database) = &mut self.database else {
return; return;
}; };
match database.rename_session(session_id, title) { match database.rename_session(request.session_id, title) {
Ok(()) => { Ok(()) => {
self.error = None; self.error = None;
self.reload_projects(); self.reload_projects();