Add quick actions to sessions

Rename, AI retitle, pin and archive, reached from the "..." button on
every session row and in the chat header. Sessions carry one lifecycle
state (normal, pinned, archived) rather than independent flags, so the
sidebar groups them without an unrepresentable pinned-and-archived case.

The AI retitle runs as a one-shot against the transient KV cache through
a new CheckpointTarget::OneShot, leaving no session or checkpoint behind
while still reporting as local work in the metrics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-25 12:15:58 +02:00
parent cc9e09ff1d
commit 35087580d5
12 changed files with 687 additions and 45 deletions

View File

@@ -1,5 +1,20 @@
use super::*;
/// Asks for a title short enough to fit a sidebar row.
const TITLE_INSTRUCTION: &str = "Give this conversation a short title of at most six words. \
Reply with the title alone: no quotes, no trailing period, no explanation.";
const TITLE_MAX_TOKENS: i32 = 48;
const TITLE_MAX_CHARS: usize = 60;
/// A one-shot generation that produces a session title. It runs against the
/// transient KV cache, so it never creates or touches a stored session.
#[cfg(target_os = "macos")]
pub(super) struct TitleRequest {
session_id: i32,
active: ActiveGeneration,
content: String,
}
#[derive(Clone, Debug)]
pub(crate) struct ChatMessage {
pub(super) id: i32,
@@ -262,4 +277,162 @@ impl App {
#[cfg(not(target_os = "macos"))]
false
}
/// 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.
#[cfg(target_os = "macos")]
pub(super) fn request_title(&mut self, session_id: i32) {
if self.active_titling.is_some() {
self.error = Some("A title is already being generated.".into());
return;
}
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| {
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 mut messages = stored
.into_iter()
.map(|message| ChatTurn {
user: message.user,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: message.content,
})
.collect::<Vec<_>>();
messages.push(ChatTurn {
user: true,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: TITLE_INSTRUCTION.to_owned(),
});
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 idle_timeout =
Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60);
match service.generate(
effective.engine,
effective.turn,
messages,
CheckpointTarget::OneShot(transient_cache_path()),
idle_timeout,
) {
Ok(active) => {
self.active_titling = Some(TitleRequest {
session_id,
active,
content: String::new(),
});
self.error = None;
}
Err(error) => {
self.generation_service = None;
self.error = Some(error);
}
}
}
/// Drains the one-shot title generation. Returns true while it is running so
/// the caller keeps ticking.
#[cfg(target_os = "macos")]
pub(super) fn poll_titling(&mut self) -> bool {
let Some(mut request) = self.active_titling.take() else {
return false;
};
let failure = loop {
match request.active.events.try_recv() {
Ok(GenerationEvent::Chunk {
reasoning: false,
content,
}) => request.content.push_str(&content),
Ok(GenerationEvent::Finished(Err(error))) => break Some(error),
Ok(GenerationEvent::Finished(Ok(_))) => break None,
Ok(_) => {}
Err(TryRecvError::Empty) => {
self.active_titling = Some(request);
return true;
}
Err(TryRecvError::Disconnected) => {
break Some("The model runtime stopped unexpectedly.".to_owned());
}
}
};
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);
} else {
self.error = Some("The model did not return a usable title.".into());
}
false
}
#[cfg(target_os = "macos")]
fn apply_title(&mut self, session_id: i32, title: &str) {
let Some(database) = &mut self.database else {
return;
};
match database.rename_session(session_id, title) {
Ok(()) => {
self.error = None;
self.reload_projects();
}
Err(error) => self.error = Some(format!("Could not save the session title: {error}")),
}
}
}
/// Reduces a model reply to a single sidebar-sized line, or `None` if nothing
/// usable came back.
pub(super) fn session_title(reply: &str) -> Option<String> {
let title = reply
.lines()
.map(str::trim)
.find(|line| !line.is_empty())?
.trim_matches(|character: char| {
character.is_whitespace() || matches!(character, '"' | '\'' | '`' | '*' | '#' | '.')
})
.trim();
if title.is_empty() {
return None;
}
Some(match title.char_indices().nth(TITLE_MAX_CHARS) {
Some((index, _)) => format!("{}", title[..index].trim_end()),
None => title.to_owned(),
})
}