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:
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,6 +138,27 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::database::{Project, Session};
|
||||
|
||||
#[test]
|
||||
fn session_states_are_exclusive_and_ordered() {
|
||||
assert_eq!(SessionState::from_id("pinned"), Some(SessionState::Pinned));
|
||||
assert_eq!(SessionState::from_id("nonsense"), None);
|
||||
assert_eq!(SessionState::default(), SessionState::Normal);
|
||||
let mut ranks = [
|
||||
SessionState::Archived,
|
||||
SessionState::Normal,
|
||||
SessionState::Pinned,
|
||||
];
|
||||
ranks.sort_by_key(|state| state.rank());
|
||||
assert_eq!(
|
||||
ranks,
|
||||
[
|
||||
SessionState::Pinned,
|
||||
SessionState::Normal,
|
||||
SessionState::Archived
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_titles_follow_the_stored_session_count() {
|
||||
let projects = vec![
|
||||
@@ -164,13 +185,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn session(id: i32, project_id: i32) -> Session {
|
||||
Session {
|
||||
Session::fixture(
|
||||
id,
|
||||
project_id,
|
||||
title: format!("Session {id}"),
|
||||
context_used: 0,
|
||||
context_limit: 0,
|
||||
last_tokens_per_second: None,
|
||||
}
|
||||
&format!("Session {id}"),
|
||||
SessionState::Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
207
src/app/view.rs
207
src/app/view.rs
@@ -9,7 +9,7 @@ use super::{
|
||||
ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation,
|
||||
chat_scroll_id, models_path,
|
||||
};
|
||||
use crate::database::{ProjectWithSessions, Session};
|
||||
use crate::database::{ProjectWithSessions, Session, SessionState};
|
||||
use crate::model::{
|
||||
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,
|
||||
};
|
||||
@@ -34,6 +34,8 @@ const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg")
|
||||
const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg");
|
||||
const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
|
||||
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
||||
const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg");
|
||||
const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.svg");
|
||||
|
||||
impl App {
|
||||
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
|
||||
@@ -72,6 +74,10 @@ impl App {
|
||||
layers.push(self.preferences_panel());
|
||||
} else if let Some(path) = &self.pending_project_path {
|
||||
layers.push(self.project_dialog(path));
|
||||
} else if let Some((_, title)) = &self.session_rename {
|
||||
layers.push(self.rename_dialog(title));
|
||||
} else if let Some(session) = self.menu_session() {
|
||||
layers.push(self.session_menu_panel(session));
|
||||
}
|
||||
|
||||
stack(layers)
|
||||
@@ -146,30 +152,44 @@ impl App {
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
|
||||
for session in &item.sessions {
|
||||
let session_selected = selected && self.selected_session == Some(session.id);
|
||||
for session in item
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.state() != SessionState::Archived)
|
||||
{
|
||||
projects = projects.push(self.session_row(project.id, session, selected));
|
||||
}
|
||||
|
||||
let archived = item
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.state() == SessionState::Archived)
|
||||
.collect::<Vec<_>>();
|
||||
if !archived.is_empty() {
|
||||
let expanded = self.expanded_archives.contains(&project.id);
|
||||
projects = projects.push(
|
||||
row![
|
||||
Space::with_width(26),
|
||||
button(
|
||||
row![icon(ICON_CHAT, 15), text(&session.title).size(13)]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
text(format!(
|
||||
"{} Archived sessions ({})",
|
||||
if expanded { "▾" } else { "›" },
|
||||
archived.len()
|
||||
))
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::SelectSession(project.id, session.id))
|
||||
.style(if session_selected {
|
||||
button::secondary
|
||||
} else {
|
||||
button::text
|
||||
}),
|
||||
button(icon(ICON_TRASH, 14))
|
||||
.on_press(Message::DeleteSession(session.id))
|
||||
.style(button::text),
|
||||
.on_press(Message::ToggleArchivedSessions(project.id))
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
if expanded {
|
||||
for session in archived {
|
||||
projects = projects.push(self.session_row(project.id, session, selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(title) = self.drafts.get(&project.id) {
|
||||
@@ -217,6 +237,42 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn session_row<'a>(
|
||||
&'a self,
|
||||
project_id: i32,
|
||||
session: &'a Session,
|
||||
project_selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let session_selected = project_selected && self.selected_session == Some(session.id);
|
||||
let mut label = row![icon(ICON_CHAT, 15)]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
if session.state() == SessionState::Pinned {
|
||||
label = label.push(icon(ICON_PIN, 12));
|
||||
}
|
||||
label = label.push(text(&session.title).size(13));
|
||||
row![
|
||||
Space::with_width(26),
|
||||
button(label)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::SelectSession(project_id, session.id))
|
||||
.style(if session_selected {
|
||||
button::secondary
|
||||
} else {
|
||||
button::text
|
||||
}),
|
||||
button(icon(ICON_MORE, 14))
|
||||
.on_press(Message::OpenSessionMenu(session.id))
|
||||
.style(button::text),
|
||||
button(icon(ICON_TRASH, 14))
|
||||
.on_press(Message::DeleteSession(session.id))
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn detail(&self) -> Element<'_, Message> {
|
||||
let chat_active = self.detail_tab == DetailTab::Chat;
|
||||
let stats_active = self.detail_tab == DetailTab::Stats;
|
||||
@@ -287,6 +343,110 @@ impl App {
|
||||
)
|
||||
}
|
||||
|
||||
fn menu_session(&self) -> Option<&Session> {
|
||||
let session_id = self.session_menu?;
|
||||
self.projects
|
||||
.iter()
|
||||
.flat_map(|project| &project.sessions)
|
||||
.find(|session| session.id == session_id)
|
||||
}
|
||||
|
||||
/// Quick actions for one session. Which lifecycle moves are offered depends
|
||||
/// on the state the session is in.
|
||||
fn session_menu_panel<'a>(&self, session: &'a Session) -> Element<'a, Message> {
|
||||
let state = session.state();
|
||||
let mut actions = column![
|
||||
menu_action(ICON_NEW_SESSION, "Rename session")
|
||||
.on_press(Message::StartRenameSession(session.id)),
|
||||
menu_action(ICON_SPARK, "Retitle with AI")
|
||||
.on_press(Message::RetitleSession(session.id)),
|
||||
]
|
||||
.spacing(4);
|
||||
actions = match state {
|
||||
SessionState::Normal => actions.push(
|
||||
menu_action(ICON_PIN, "Pin session")
|
||||
.on_press(Message::SetSessionState(session.id, SessionState::Pinned)),
|
||||
),
|
||||
SessionState::Pinned => actions.push(
|
||||
menu_action(ICON_PIN, "Unpin session")
|
||||
.on_press(Message::SetSessionState(session.id, SessionState::Normal)),
|
||||
),
|
||||
SessionState::Archived => actions,
|
||||
};
|
||||
actions = match state {
|
||||
SessionState::Archived => actions.push(
|
||||
menu_action(ICON_ARCHIVE, "Unarchive session")
|
||||
.on_press(Message::SetSessionState(session.id, SessionState::Normal)),
|
||||
),
|
||||
_ => actions.push(
|
||||
menu_action(ICON_ARCHIVE, "Archive session")
|
||||
.on_press(Message::SetSessionState(session.id, SessionState::Archived)),
|
||||
),
|
||||
};
|
||||
|
||||
let dialog = container(
|
||||
column![
|
||||
text(&session.title).size(16),
|
||||
text(match state {
|
||||
SessionState::Normal => "Session",
|
||||
SessionState::Pinned => "Pinned session",
|
||||
SessionState::Archived => "Archived session",
|
||||
})
|
||||
.size(12)
|
||||
.color(muted_text()),
|
||||
actions,
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
action_button("Close").on_press(Message::DismissPanel),
|
||||
],
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(20)
|
||||
.width(320)
|
||||
.style(overview_style);
|
||||
|
||||
opaque(
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn rename_dialog<'a>(&self, title: &'a str) -> Element<'a, Message> {
|
||||
let dialog = container(
|
||||
column![
|
||||
text("Rename session").size(24),
|
||||
text_input("Session title", title)
|
||||
.on_input(Message::SessionTitleChanged)
|
||||
.on_submit(Message::ConfirmRenameSession)
|
||||
.padding(10),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
action_button("Cancel").on_press(Message::DismissPanel),
|
||||
action_button("Rename").on_press(Message::ConfirmRenameSession),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(22)
|
||||
.width(440)
|
||||
.style(overview_style);
|
||||
|
||||
opaque(
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
|
||||
self.projects
|
||||
.iter()
|
||||
@@ -360,6 +520,17 @@ fn action_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Mes
|
||||
button(content).padding([8, 14]).style(action_button_style)
|
||||
}
|
||||
|
||||
fn menu_action(glyph: &'static [u8], label: &'static str) -> Button<'static, Message> {
|
||||
button(
|
||||
row![icon(glyph, 15), text(label).size(13)]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([8, 10])
|
||||
.style(button::text)
|
||||
}
|
||||
|
||||
fn metric_card(label: &'static str, value: String, detail: String) -> Element<'static, Message> {
|
||||
container(
|
||||
column![
|
||||
@@ -581,7 +752,9 @@ fn sidebar_style(_: &Theme) -> container::Style {
|
||||
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
||||
}
|
||||
|
||||
fn icon(data: &'static [u8], size: u16) -> Svg<'static> {
|
||||
// The lifetime is free: the handle owns its bytes and the style closure is
|
||||
// 'static, so an icon can join a row that borrows shorter-lived data.
|
||||
fn icon<'a>(data: &'static [u8], size: u16) -> Svg<'a> {
|
||||
svg(svg::Handle::from_memory(data))
|
||||
.width(size as f32)
|
||||
.height(size as f32)
|
||||
|
||||
@@ -31,14 +31,18 @@ impl App {
|
||||
let project = &item.project;
|
||||
let active_title = self.active_session_title(item);
|
||||
let selected_title = active_title.unwrap_or(project.name.as_str());
|
||||
let header = row![
|
||||
icon(ICON_FOLDER, 19),
|
||||
text(selected_title).size(18),
|
||||
icon(ICON_MORE, 18),
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center);
|
||||
let mut header = row![icon(ICON_FOLDER, 19), text(selected_title).size(18)];
|
||||
if let Some(session) = self.selected_session(item) {
|
||||
header = header.push(
|
||||
button(icon(ICON_MORE, 18))
|
||||
.on_press(Message::OpenSessionMenu(session.id))
|
||||
.style(button::text),
|
||||
);
|
||||
}
|
||||
let header = header
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let body: Element<'_, Message> = if let Some(title) = active_title {
|
||||
let mut messages = column![].spacing(12);
|
||||
|
||||
Reference in New Issue
Block a user