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