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

@@ -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)