diff --git a/migrations/20260725160000_add_last_project/down.sql b/migrations/20260725160000_add_last_project/down.sql new file mode 100644 index 0000000..7f5bfdf --- /dev/null +++ b/migrations/20260725160000_add_last_project/down.sql @@ -0,0 +1 @@ +ALTER TABLE preferences DROP COLUMN last_project_id; diff --git a/migrations/20260725160000_add_last_project/up.sql b/migrations/20260725160000_add_last_project/up.sql new file mode 100644 index 0000000..fc6a979 --- /dev/null +++ b/migrations/20260725160000_add_last_project/up.sql @@ -0,0 +1 @@ +ALTER TABLE preferences ADD COLUMN last_project_id INTEGER; diff --git a/src/app.rs b/src/app.rs index ef5c7f1..6d29397 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,7 +24,7 @@ use crate::settings::{ RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences, StreamingCacheBudget, }; -use iced::widget::{markdown, scrollable}; +use iced::widget::{markdown, scrollable, text_input}; use iced::{Size, Subscription, Task, keyboard, window}; use rfd::AsyncFileDialog; use std::collections::{HashMap, HashSet, VecDeque}; @@ -208,6 +208,14 @@ impl App { Err(error) => return Self::failed(error, main_window), }; let context_limit = preferences.context_tokens.max(0) as u32; + // Reopen on the project we left, with a fresh draft chat. + let last_project = preferences + .last_project_id + .filter(|id| projects.iter().any(|item| item.project.id == *id)); + let drafts = last_project + .map(|id| (id, projects::draft_title(&projects, id))) + .into_iter() + .collect(); let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics_snapshot = metrics.snapshot(); @@ -228,9 +236,9 @@ impl App { preference_draft, preferences_open: false, preference_error: None, - selected_project: None, + selected_project: last_project, selected_session: None, - drafts: HashMap::new(), + drafts, session_menu: None, session_rename: None, expanded_archives: HashSet::new(), @@ -367,8 +375,7 @@ impl App { return window::gain_focus(id); } } - Message::WindowOpened(id) => - { + Message::WindowOpened(id) => { #[cfg(target_os = "macos")] if id == self.main_window && self._native_menu.is_none() { match crate::native_menu::install() { @@ -379,6 +386,9 @@ impl App { } } } + if id == self.main_window && self.selected_project.is_some() { + return focus_composer(); + } } Message::WindowClosed(id) => { if id == self.main_window { @@ -714,7 +724,7 @@ impl App { Some("Stop the active generation before changing sessions.".into()); return Task::none(); } - self.selected_project = Some(project_id); + self.remember_project(project_id); self.selected_session = None; self.conversation.clear(); self.composer.clear(); @@ -747,6 +757,10 @@ impl App { let _ = fs::remove_file(session_checkpoint_path(session_id)); } self.drafts.remove(&project_id); + if self.preferences.last_project_id == Some(project_id) { + self.preferences.last_project_id = None; + let _ = database.set_last_project(None); + } if self.selected_project == Some(project_id) { self.selected_project = None; self.selected_session = None; @@ -761,7 +775,12 @@ impl App { } } } - Message::CreateSession(project_id) => self.create_session(project_id), + Message::CreateSession(project_id) => { + self.create_session(project_id); + if self.draft_selected(project_id) { + return focus_composer(); + } + } Message::DiscardSession(project_id) => self.discard_session(project_id), Message::OpenSessionMenu(session_id) => { self.session_rename = None; @@ -858,7 +877,7 @@ impl App { Ok(messages) => { self.conversation = messages.into_iter().map(ChatMessage::from).collect(); self.composer.clear(); - self.selected_project = Some(project_id); + self.remember_project(project_id); self.selected_session = Some(session_id); let (used, limit, tokens_per_second) = saved_context.unwrap_or_default(); self.context_used = used.max(0) as u32; @@ -1067,6 +1086,14 @@ pub(super) fn chat_scroll_id() -> scrollable::Id { scrollable::Id::new("chat-transcript") } +pub(super) fn composer_id() -> text_input::Id { + text_input::Id::new("chat-composer") +} + +fn focus_composer() -> Task { + text_input::focus(composer_id()) +} + fn scroll_chat_to_end() -> Task { scrollable::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END) } diff --git a/src/app/projects.rs b/src/app/projects.rs index 0bd932e..3d27003 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -47,7 +47,7 @@ impl App { match database.create_project(name, path) { Ok(project) => { - self.selected_project = Some(project.id); + self.remember_project(project.id); self.selected_session = None; self.pending_project_path = None; self.project_name_input.clear(); @@ -70,7 +70,7 @@ impl App { } let title = draft_title(&self.projects, project_id); self.drafts.entry(project_id).or_insert(title); - self.selected_project = Some(project_id); + self.remember_project(project_id); self.selected_session = None; self.conversation.clear(); self.composer.clear(); @@ -104,12 +104,26 @@ impl App { .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.remember_project(project_id); self.selected_session = Some(session.id); self.reload_projects(); Ok(session.id) } + /// Selects a project and stores it as the one to reopen on the next launch. + pub(super) fn remember_project(&mut self, project_id: i32) { + self.selected_project = Some(project_id); + if self.preferences.last_project_id == Some(project_id) { + return; + } + self.preferences.last_project_id = Some(project_id); + if let Some(database) = &mut self.database + && let Err(error) = database.set_last_project(Some(project_id)) + { + self.error = Some(error); + } + } + /// 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() @@ -125,7 +139,7 @@ impl App { } } -fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String { +pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String { let stored = projects .iter() .find(|item| item.project.id == project_id) diff --git a/src/app/view.rs b/src/app/view.rs index f7d465c..d62edd0 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -7,7 +7,7 @@ use model_manager::{download_status_bar, format_bytes, format_duration}; use super::{ ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation, - chat_scroll_id, models_path, + chat_scroll_id, composer_id, models_path, }; use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index 644326a..8ea1267 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -133,6 +133,7 @@ impl App { } } let composer = text_input("Ask DS4Server anything…", &self.composer) + .id(composer_id()) .on_input(Message::ComposerChanged) .on_submit(Message::SubmitPrompt) .padding(12) diff --git a/src/database.rs b/src/database.rs index af8f998..eaa7c4e 100644 --- a/src/database.rs +++ b/src/database.rs @@ -55,6 +55,8 @@ pub struct AppPreferences { pub endpoint_enabled: bool, pub endpoint_cors: bool, pub sidebar_collapsed: bool, + /// Project the app reopens on. Cleared when that project goes away. + pub last_project_id: Option, } impl Default for AppPreferences { @@ -98,6 +100,7 @@ impl Default for AppPreferences { endpoint_enabled: true, endpoint_cors: false, sidebar_collapsed: false, + last_project_id: None, } } } @@ -441,6 +444,14 @@ impl Database { .map_err(|error| error.to_string()) } + pub fn set_last_project(&mut self, project_id: Option) -> Result<(), String> { + diesel::update(preferences::table.find(1)) + .set(preferences::last_project_id.eq(project_id)) + .execute(&mut self.connection) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + #[allow(clippy::too_many_arguments)] pub fn update_preferences( &mut self, @@ -851,6 +862,7 @@ mod tests { assert_eq!(loaded[0].sessions[0].id, ordinary); database.set_sidebar_collapsed(true).unwrap(); + database.set_last_project(Some(project.id)).unwrap(); database.delete_project(project.id).unwrap(); assert!(database.load_projects().unwrap().is_empty()); @@ -859,6 +871,7 @@ mod tests { let mut reopened = Database::open(&path).unwrap(); let preferences = reopened.load_preferences().unwrap(); assert!(preferences.sidebar_collapsed); + assert_eq!(preferences.last_project_id, Some(project.id)); assert_eq!(preferences.selected_model, "glm-5.2"); assert_eq!(preferences.idle_timeout_minutes, 30); assert_eq!(preferences.endpoint_port, 4567); diff --git a/src/schema.rs b/src/schema.rs index da9e329..5383906 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -58,6 +58,7 @@ diesel::table! { endpoint_enabled -> Bool, endpoint_cors -> Bool, sidebar_collapsed -> Bool, + last_project_id -> Nullable, } }