From 51c1b2a7dc89897982e4e91ef4dd6c96612c3aab Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 25 Jul 2026 12:02:04 +0200 Subject: [PATCH] feat: better session creation handling --- src/app.rs | 14 ++++- src/app/generation.rs | 21 +++++-- src/app/projects.rs | 124 +++++++++++++++++++++++++++++++++--------- src/app/view.rs | 68 ++++++++++++++++------- src/app/view/chat.rs | 13 ++--- 5 files changed, 182 insertions(+), 58 deletions(-) diff --git a/src/app.rs b/src/app.rs index 58ee90a..7fba409 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,7 +27,7 @@ use crate::settings::{ use iced::widget::{markdown, scrollable}; use iced::{Size, Subscription, Task, keyboard, window}; use rfd::AsyncFileDialog; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::fs; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -55,6 +55,9 @@ pub(crate) struct App { preference_error: Option, selected_project: Option, selected_session: Option, + /// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row + /// when its first chat turn is stored, so empty ones vanish on restart. + drafts: HashMap, choosing_folder: bool, pending_project_path: Option, project_name_input: String, @@ -163,7 +166,8 @@ pub(crate) enum Message { CancelProject, SelectProject(i32), DeleteProject(i32), - CreateSession, + CreateSession(i32), + DiscardSession(i32), SelectSession(i32, i32), DeleteSession(i32), ShowChat, @@ -204,6 +208,7 @@ impl App { preference_error: None, selected_project: None, selected_session: None, + drafts: HashMap::new(), choosing_folder: false, pending_project_path: None, project_name_input: String::new(), @@ -275,6 +280,7 @@ impl App { preference_error: None, selected_project: None, selected_session: None, + drafts: HashMap::new(), choosing_folder: false, pending_project_path: None, project_name_input: String::new(), @@ -675,6 +681,7 @@ impl App { for session_id in checkpoint_ids { let _ = fs::remove_file(session_checkpoint_path(session_id)); } + self.drafts.remove(&project_id); if self.selected_project == Some(project_id) { self.selected_project = None; self.selected_session = None; @@ -689,7 +696,8 @@ impl App { } } } - Message::CreateSession => self.create_session(), + Message::CreateSession(project_id) => self.create_session(project_id), + Message::DiscardSession(project_id) => self.discard_session(project_id), Message::SelectSession(project_id, session_id) => { if self.generating && self.selected_session != Some(session_id) { self.error = diff --git a/src/app/generation.rs b/src/app/generation.rs index fec2bf1..09317f6 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -51,7 +51,7 @@ impl From for ChatMessage { impl App { pub(super) fn start_generation(&mut self) { - if self.generating || self.selected_session.is_none() { + if self.generating || self.selected_project.is_none() { return; } let prompt = self.composer.trim().to_owned(); @@ -78,9 +78,6 @@ impl App { } }; let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; - let session_id = self - .selected_session - .expect("a selected session was checked"); #[cfg(target_os = "macos")] let mut messages = self .conversation @@ -104,6 +101,22 @@ impl App { #[cfg(target_os = "macos")] { + // A draft session only reaches the database once there is a turn to store. + let session_id = match self.selected_session { + Some(session_id) => session_id, + None => { + let Some(project_id) = self.selected_project else { + return; + }; + match self.persist_session(project_id) { + Ok(session_id) => session_id, + Err(error) => { + self.error = Some(format!("Could not create the session: {error}")); + return; + } + } + } + }; let Some(service) = &self.generation_service else { self.error = Some("The model runtime is unavailable.".into()); return; diff --git a/src/app/projects.rs b/src/app/projects.rs index a2ae827..5b976cf 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -58,37 +58,61 @@ impl App { } } - pub(super) fn create_session(&mut self) { + /// Opens an unsaved session on a project and selects it. Nothing reaches the + /// database until the first chat turn is stored by [`App::persist_session`]. + pub(super) fn create_session(&mut self, project_id: i32) { if self.generating { self.error = Some("Stop the active generation before creating a session.".into()); return; } - let Some(project_id) = self.selected_project else { - self.error = Some("Select a project first.".into()); + if self.database.is_none() { return; - }; - let default_number = self - .selected_project() - .map(|project| project.sessions.len() + 1) - .unwrap_or(1); - let title = format!("Session {default_number}"); - let Some(database) = &mut self.database else { - return; - }; - - match database.create_session(project_id, &title) { - Ok(session) => { - self.selected_session = Some(session.id); - self.conversation.clear(); - self.composer.clear(); - self.context_used = 0; - self.context_limit = self.preferences.context_tokens.max(0) as u32; - self.tokens_per_second = None; - self.error = None; - self.reload_projects(); - } - Err(error) => self.error = Some(error), } + let title = draft_title(&self.projects, project_id); + self.drafts.entry(project_id).or_insert(title); + self.selected_project = Some(project_id); + self.selected_session = None; + self.conversation.clear(); + self.composer.clear(); + self.context_used = 0; + self.context_limit = self.preferences.context_tokens.max(0) as u32; + self.tokens_per_second = None; + self.error = None; + } + + pub(super) fn discard_session(&mut self, project_id: i32) { + if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) { + self.conversation.clear(); + self.composer.clear(); + self.context_used = 0; + self.tokens_per_second = None; + } + self.error = None; + } + + /// Turns the draft on `project_id` into a real session row. Called when the + /// first chat turn is about to be written, never before. + pub(super) fn persist_session(&mut self, project_id: i32) -> Result { + let title = self + .drafts + .get(&project_id) + .cloned() + .unwrap_or_else(|| draft_title(&self.projects, project_id)); + let database = self + .database + .as_mut() + .ok_or_else(|| "The project database is unavailable.".to_owned())?; + let session = database.create_session(project_id, &title)?; + self.drafts.remove(&project_id); + self.selected_project = Some(project_id); + self.selected_session = Some(session.id); + self.reload_projects(); + Ok(session.id) + } + + /// True when the sidebar row for this project's draft is the active chat. + pub(super) fn draft_selected(&self, project_id: i32) -> bool { + self.selected_project == Some(project_id) && self.selected_session.is_none() } pub(super) fn reload_projects(&mut self) { @@ -100,3 +124,53 @@ impl App { } } } + +fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String { + let stored = projects + .iter() + .find(|item| item.project.id == project_id) + .map_or(0, |item| item.sessions.len()); + format!("Session {}", stored + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::{Project, Session}; + + #[test] + fn draft_titles_follow_the_stored_session_count() { + let projects = vec![ + ProjectWithSessions { + project: project(1, "First"), + sessions: vec![session(1, 1), session(2, 1)], + }, + ProjectWithSessions { + project: project(2, "Second"), + sessions: Vec::new(), + }, + ]; + assert_eq!(draft_title(&projects, 1), "Session 3"); + assert_eq!(draft_title(&projects, 2), "Session 1"); + assert_eq!(draft_title(&projects, 99), "Session 1"); + } + + fn project(id: i32, name: &str) -> Project { + Project { + id, + name: name.into(), + path: format!("/tmp/{name}"), + } + } + + fn session(id: i32, project_id: i32) -> Session { + Session { + id, + project_id, + title: format!("Session {id}"), + context_used: 0, + context_limit: 0, + last_tokens_per_second: None, + } + } +} diff --git a/src/app/view.rs b/src/app/view.rs index 324164b..44682f1 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -81,16 +81,6 @@ impl App { } fn sidebar(&self) -> Element<'_, Message> { - let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),] - .spacing(9) - .align_y(Alignment::Center); - let new_session = if self.selected_project.is_some() { - button(new_session_content) - .width(Length::Fill) - .on_press(Message::CreateSession) - } else { - button(new_session_content).width(Length::Fill) - }; let preference_content = row![ icon(ICON_SETTINGS, 17), text("Preferences").size(14), @@ -116,16 +106,12 @@ impl App { .width(Length::Fill) .on_press(Message::ChooseProjectFolder) }; - let header = column![ - row![ - text("DS4Server").size(20), - Space::with_width(Length::Fill), - text("Local").size(12), - ] - .align_y(Alignment::Center), - new_session.style(button::text), + let header = row![ + text("DS4Server").size(20), + Space::with_width(Length::Fill), + text("Local").size(12), ] - .spacing(10); + .align_y(Alignment::Center); let mut projects = column![ row![ text("PROJECTS").size(11), @@ -149,6 +135,9 @@ impl App { .width(Length::Fill) .on_press(Message::SelectProject(project.id)) .style(button::text), + button(icon(ICON_NEW_SESSION, 15)) + .on_press(Message::CreateSession(project.id)) + .style(button::text), button(icon(ICON_TRASH, 15)) .on_press(Message::DeleteProject(project.id)) .style(button::text), @@ -182,6 +171,35 @@ impl App { .align_y(Alignment::Center), ); } + + if let Some(title) = self.drafts.get(&project.id) { + let draft_selected = self.draft_selected(project.id); + projects = projects.push( + row![ + Space::with_width(26), + button( + row![ + icon(ICON_CHAT, 15), + text(title).size(13).color(muted_text()) + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .on_press(Message::CreateSession(project.id)) + .style(if draft_selected { + button::secondary + } else { + button::text + }), + button(icon(ICON_TRASH, 14)) + .on_press(Message::DiscardSession(project.id)) + .style(button::text), + ] + .spacing(3) + .align_y(Alignment::Center), + ); + } } container( @@ -281,6 +299,18 @@ impl App { .iter() .find(|session| Some(session.id) == self.selected_session) } + + /// Title of the active chat surface: a stored session, or this project's + /// unsaved draft. `None` means no chat is open for the project. + fn active_session_title<'a>(&'a self, project: &'a ProjectWithSessions) -> Option<&'a str> { + match self.selected_session(project) { + Some(session) => Some(session.title.as_str()), + None if self.draft_selected(project.project.id) => { + self.drafts.get(&project.project.id).map(String::as_str) + } + None => None, + } + } } fn preference_input_row<'a>( diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index 245ec40..af746f1 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -29,10 +29,8 @@ impl App { }; let project = &item.project; - let selected_title = self - .selected_session(item) - .map(|session| session.title.as_str()) - .unwrap_or(project.name.as_str()); + 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), @@ -42,12 +40,12 @@ impl App { .spacing(10) .align_y(Alignment::Center); - let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) { + let body: Element<'_, Message> = if let Some(title) = active_title { let mut messages = column![].spacing(12); if self.conversation.is_empty() { messages = messages.push( column![ - text(&session.title).size(26), + text(title).size(26), text("Run DeepSeek locally with the Rust Metal engine.").size(14), ] .spacing(8), @@ -191,7 +189,8 @@ impl App { "Choose a session" }) .size(24), - text("Create a session above or select one from the sidebar.").size(14), + text("Use the new session icon next to the project, or pick one from the sidebar.") + .size(14), ] .spacing(8) .align_x(Alignment::Center),