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) => {
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;

View File

@@ -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::<Vec<_>>();
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<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 {
return;
};
match database.rename_session(session_id, title) {
match database.rename_session(request.session_id, title) {
Ok(()) => {
self.error = None;
self.reload_projects();