diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 14a16e9..853a68b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -9,6 +9,14 @@ Choose **Add project** to give the coding agent access to a folder. The agent's file tools are bounded to that project. Use **File > New Chat** (`⌘N`) to start a chat in the active project. Chats are saved after their first message and can be renamed, pinned, archived, compacted, rebuilt, or deleted from the sidebar. +An active chat has a green dot beside its title. You can switch chats or start +another one while it works; each chat keeps its own transcript, queued prompts, +tools, approvals, context, and interactive UI state. Model inference shares the +single loaded runtime so model weights are not duplicated, while independent +tool work continues concurrently. + +Quitting with active chats asks for confirmation. Confirming stops their model +and tool work; canceling leaves every chat running. Use **File > Export Chat as Markdown** (`⇧⌘S`) to save the visible conversation, including reasoning and tool results. System-only messages are omitted. diff --git a/src/app.rs b/src/app.rs index 529d3af..d04cd11 100644 --- a/src/app.rs +++ b/src/app.rs @@ -66,6 +66,8 @@ pub(crate) struct App { /// 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, + #[cfg(target_os = "macos")] + background_chats: HashMap, /// Session whose quick-actions menu is open. session_menu: Option, /// Session waiting for explicit confirmation before deletion. @@ -146,6 +148,58 @@ pub(crate) struct App { manual_compaction_queued: bool, #[cfg(target_os = "macos")] skip_compaction_once: bool, + quit_confirmation: bool, +} + +#[cfg(target_os = "macos")] +struct ChatSnapshot { + selected_project: Option, + selected_session: Option, + composer: String, + queued_inputs: VecDeque, + conversation: Vec, + chat_follow_tail: bool, + active_turn: Option, + a2ui: crate::a2ui::Store, + a2ui_history: Vec, + a2ui_history_index: Option, + a2ui_tabs: HashMap<(String, String), usize>, + a2ui_modals: HashSet<(String, String)>, + a2ui_editors: HashMap<(String, String, String), text_editor::Content>, + a2ui_markdown: HashMap<(String, String, String), markdown::Content>, + a2ui_choice_filters: HashMap<(String, String, String), String>, + a2ui_images: HashMap, + a2ui_image_requests: HashSet, + a2ui_image_loading: bool, + pending_a2ui_dismissal: Option, + a2ui_auto_switch_pending: bool, + generating: bool, + context_used: u32, + context_limit: u32, + tokens_per_second: Option, + detail_tab: DetailTab, + active_generation: Option, + active_compaction: Option, + active_tool_check: Option, + agent_tools: Option<(i32, Arc>)>, + active_tools: Option, + tool_cards: Vec, + pending_tool_approval: Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender)>, + active_titling: Option, + error: Option, + activity: Option, + context_notice: Option, + stop_requested: bool, + system_prompt_seen_at: u32, + manual_compaction_queued: bool, + skip_compaction_once: bool, +} + +#[cfg(target_os = "macos")] +impl ChatSnapshot { + fn needs_poll(&self) -> bool { + chat_needs_poll(self.generating, self.active_titling.is_some()) + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -229,10 +283,14 @@ pub(crate) enum Message { HelpOpened(window::Id), NewChat, ExportChat, - ExportChatPicked(Option), + ExportChatPicked(Option, String), ModelManagerOpened(window::Id), WindowOpened(window::Id), + WindowCloseRequested(window::Id), WindowClosed(window::Id), + RequestQuit, + ConfirmQuit, + CancelQuit, Escape(window::Id), DismissPanel, /// Tab and shift-tab: iced leaves the key to the application, so the fields @@ -286,7 +344,7 @@ pub(crate) enum Message { A2uiAction(String, String, Option), A2uiSelectTab(String, String, usize), A2uiToggleModal(String, String), - A2uiImageLoaded(String, Result, String>), + A2uiImageLoaded(Option, String, Result, String>), A2uiPlayMedia(String, String, bool), RequestA2uiDismiss(String), ConfirmA2uiDismiss, @@ -398,6 +456,8 @@ impl App { selected_project: last_project, selected_session: None, drafts, + #[cfg(target_os = "macos")] + background_chats: HashMap::new(), session_menu: None, pending_session_delete: None, session_rename: None, @@ -480,6 +540,7 @@ impl App { manual_compaction_queued: false, #[cfg(target_os = "macos")] skip_compaction_once: false, + quit_confirmation: false, } } Err(error) => Self::failed( @@ -527,6 +588,8 @@ impl App { selected_project: None, selected_session: None, drafts: HashMap::new(), + #[cfg(target_os = "macos")] + background_chats: HashMap::new(), session_menu: None, pending_session_delete: None, session_rename: None, @@ -601,9 +664,180 @@ impl App { manual_compaction_queued: false, #[cfg(target_os = "macos")] skip_compaction_once: false, + quit_confirmation: false, } } + #[cfg(target_os = "macos")] + fn take_chat_snapshot(&mut self) -> ChatSnapshot { + ChatSnapshot { + selected_project: self.selected_project.take(), + selected_session: self.selected_session.take(), + composer: std::mem::take(&mut self.composer), + queued_inputs: std::mem::take(&mut self.queued_inputs), + conversation: std::mem::take(&mut self.conversation), + chat_follow_tail: std::mem::replace(&mut self.chat_follow_tail, true), + active_turn: self.active_turn.take(), + a2ui: std::mem::take(&mut self.a2ui), + a2ui_history: std::mem::take(&mut self.a2ui_history), + a2ui_history_index: self.a2ui_history_index.take(), + a2ui_tabs: std::mem::take(&mut self.a2ui_tabs), + a2ui_modals: std::mem::take(&mut self.a2ui_modals), + a2ui_editors: std::mem::take(&mut self.a2ui_editors), + a2ui_markdown: std::mem::take(&mut self.a2ui_markdown), + a2ui_choice_filters: std::mem::take(&mut self.a2ui_choice_filters), + a2ui_images: std::mem::take(&mut self.a2ui_images), + a2ui_image_requests: std::mem::take(&mut self.a2ui_image_requests), + a2ui_image_loading: std::mem::take(&mut self.a2ui_image_loading), + pending_a2ui_dismissal: self.pending_a2ui_dismissal.take(), + a2ui_auto_switch_pending: std::mem::take(&mut self.a2ui_auto_switch_pending), + generating: std::mem::take(&mut self.generating), + context_used: std::mem::take(&mut self.context_used), + context_limit: std::mem::replace( + &mut self.context_limit, + self.config.generation.context_tokens.max(0) as u32, + ), + tokens_per_second: self.tokens_per_second.take(), + detail_tab: std::mem::take(&mut self.detail_tab), + active_generation: self.active_generation.take(), + active_compaction: self.active_compaction.take(), + active_tool_check: self.active_tool_check.take(), + agent_tools: self.agent_tools.take(), + active_tools: self.active_tools.take(), + tool_cards: std::mem::take(&mut self.tool_cards), + pending_tool_approval: self.pending_tool_approval.take(), + active_titling: self.active_titling.take(), + error: self.error.take(), + activity: self.activity.take(), + context_notice: self.context_notice.take(), + stop_requested: std::mem::take(&mut self.stop_requested), + system_prompt_seen_at: std::mem::take(&mut self.system_prompt_seen_at), + manual_compaction_queued: std::mem::take(&mut self.manual_compaction_queued), + skip_compaction_once: std::mem::take(&mut self.skip_compaction_once), + } + } + + #[cfg(target_os = "macos")] + fn restore_chat_snapshot(&mut self, snapshot: ChatSnapshot) { + self.selected_project = snapshot.selected_project; + self.selected_session = snapshot.selected_session; + self.composer = snapshot.composer; + self.queued_inputs = snapshot.queued_inputs; + self.conversation = snapshot.conversation; + self.chat_follow_tail = snapshot.chat_follow_tail; + self.active_turn = snapshot.active_turn; + self.a2ui = snapshot.a2ui; + self.a2ui_history = snapshot.a2ui_history; + self.a2ui_history_index = snapshot.a2ui_history_index; + self.a2ui_tabs = snapshot.a2ui_tabs; + self.a2ui_modals = snapshot.a2ui_modals; + self.a2ui_editors = snapshot.a2ui_editors; + self.a2ui_markdown = snapshot.a2ui_markdown; + self.a2ui_choice_filters = snapshot.a2ui_choice_filters; + self.a2ui_images = snapshot.a2ui_images; + self.a2ui_image_requests = snapshot.a2ui_image_requests; + self.a2ui_image_loading = snapshot.a2ui_image_loading; + self.pending_a2ui_dismissal = snapshot.pending_a2ui_dismissal; + self.a2ui_auto_switch_pending = snapshot.a2ui_auto_switch_pending; + self.generating = snapshot.generating; + self.context_used = snapshot.context_used; + self.context_limit = snapshot.context_limit; + self.tokens_per_second = snapshot.tokens_per_second; + self.detail_tab = snapshot.detail_tab; + self.active_generation = snapshot.active_generation; + self.active_compaction = snapshot.active_compaction; + self.active_tool_check = snapshot.active_tool_check; + self.agent_tools = snapshot.agent_tools; + self.active_tools = snapshot.active_tools; + self.tool_cards = snapshot.tool_cards; + self.pending_tool_approval = snapshot.pending_tool_approval; + self.active_titling = snapshot.active_titling; + self.error = snapshot.error; + self.activity = snapshot.activity; + self.context_notice = snapshot.context_notice; + self.stop_requested = snapshot.stop_requested; + self.system_prompt_seen_at = snapshot.system_prompt_seen_at; + self.manual_compaction_queued = snapshot.manual_compaction_queued; + self.skip_compaction_once = snapshot.skip_compaction_once; + } + + #[cfg(target_os = "macos")] + pub(super) fn leave_current_chat(&mut self) { + let snapshot = self.take_chat_snapshot(); + if let Some(session_id) = snapshot.selected_session + && (snapshot.needs_poll() || snapshot.a2ui_image_loading) + { + self.background_chats.insert(session_id, snapshot); + } + } + + #[cfg(target_os = "macos")] + fn restore_background_chat(&mut self, session_id: i32) -> bool { + let Some(mut snapshot) = self.background_chats.remove(&session_id) else { + return false; + }; + snapshot.chat_follow_tail = true; + self.restore_chat_snapshot(snapshot); + true + } + + #[cfg(target_os = "macos")] + fn poll_background_chats(&mut self) { + let session_ids = self + .background_chats + .iter() + .filter_map(|(session_id, chat)| chat.needs_poll().then_some(*session_id)) + .collect::>(); + for session_id in session_ids { + let foreground = self.take_chat_snapshot(); + let Some(background) = self.background_chats.remove(&session_id) else { + self.restore_chat_snapshot(foreground); + continue; + }; + self.restore_chat_snapshot(background); + self.poll_titling(); + self.poll_generation(); + let background = self.take_chat_snapshot(); + self.background_chats.insert(session_id, background); + self.restore_chat_snapshot(foreground); + } + } + + #[cfg(target_os = "macos")] + pub(super) fn session_is_active(&self, session_id: i32) -> bool { + (self.selected_session == Some(session_id) && self.generating) + || self + .background_chats + .get(&session_id) + .is_some_and(|chat| chat.generating) + } + + #[cfg(not(target_os = "macos"))] + pub(super) fn session_is_active(&self, _session_id: i32) -> bool { + false + } + + fn active_chat_count(&self) -> usize { + #[cfg(target_os = "macos")] + return active_chat_total( + self.generating, + self.background_chats.values().map(|chat| chat.generating), + ); + #[cfg(not(target_os = "macos"))] + active_chat_total(self.generating, std::iter::empty()) + } + + fn project_has_active_chat(&self, project_id: i32) -> bool { + self.projects + .iter() + .find(|item| item.project.id == project_id) + .is_some_and(|item| { + item.sessions + .iter() + .any(|session| self.session_is_active(session.id)) + }) + } + pub(crate) fn update(&mut self, message: Message) -> Task { match message { Message::Noop => { @@ -645,6 +879,7 @@ impl App { return Task::none(); }; let file_name = format!("{}.md", export_file_stem(&title)); + let content = export_markdown(&title, &self.conversation); return Task::perform( async move { AsyncFileDialog::new() @@ -655,13 +890,12 @@ impl App { .await .map(|file| file.path().to_path_buf()) }, - Message::ExportChatPicked, + move |path| Message::ExportChatPicked(path, content), ); } - Message::ExportChatPicked(path) => { + Message::ExportChatPicked(path, content) => { if let Some(path) = path - && let Some(title) = self.active_chat_title() - && let Err(error) = fs::write(&path, export_markdown(title, &self.conversation)) + && let Err(error) = fs::write(&path, content) { self.error = Some(format!("Could not export the chat: {error}")); } @@ -688,10 +922,13 @@ impl App { return focus_composer(); } } - Message::WindowClosed(id) => { + Message::WindowCloseRequested(id) => { if id == self.main_window { - return iced::exit(); + return self.update(Message::RequestQuit); } + return window::close(id); + } + Message::WindowClosed(id) => { if self.model_manager_window == Some(id) { self.model_manager_window = None; self.pending_model_delete = None; @@ -704,6 +941,14 @@ impl App { self.help_window = None; } } + Message::RequestQuit => { + if self.active_chat_count() == 0 { + return iced::exit(); + } + self.quit_confirmation = true; + } + Message::ConfirmQuit => return iced::exit(), + Message::CancelQuit => self.quit_confirmation = false, Message::Escape(id) => { if self.preferences_window == Some(id) { return self.update(Message::ClosePreferences); @@ -713,7 +958,9 @@ impl App { } } Message::DismissPanel => { - if self.pending_session_delete.is_some() { + if self.quit_confirmation { + self.quit_confirmation = false; + } else if self.pending_session_delete.is_some() { self.pending_session_delete = None; } else if self.pending_a2ui_dismissal.is_some() { self.pending_a2ui_dismissal = None; @@ -1091,7 +1338,29 @@ impl App { self.a2ui_modals.insert(key); } } - Message::A2uiImageLoaded(url, result) => { + Message::A2uiImageLoaded(session_id, url, result) => { + if session_id != self.selected_session { + #[cfg(target_os = "macos")] + { + let foreground = self.take_chat_snapshot(); + if let Some(background) = session_id + .and_then(|session_id| self.background_chats.remove(&session_id)) + { + self.restore_chat_snapshot(background); + let task = + self.update(Message::A2uiImageLoaded(session_id, url, result)); + let background = self.take_chat_snapshot(); + self.background_chats.insert( + session_id.expect("background chat has a session"), + background, + ); + self.restore_chat_snapshot(foreground); + return task; + } + self.restore_chat_snapshot(foreground); + } + return Task::none(); + } self.a2ui_image_loading = false; match result { Ok(bytes) => { @@ -1245,6 +1514,8 @@ impl App { #[cfg(target_os = "macos")] self.poll_titling(); self.poll_generation(); + #[cfg(target_os = "macos")] + self.poll_background_chats(); let images = self.load_next_a2ui_image(); return images; } @@ -1301,7 +1572,7 @@ impl App { } } Message::DeleteProject(project_id) => { - if self.generating && self.selected_project == Some(project_id) { + if self.project_has_active_chat(project_id) { self.error = Some("Stop the active generation before deleting its project.".into()); return Task::none(); @@ -1322,6 +1593,8 @@ impl App { Ok(()) => { for session_id in checkpoint_ids { let _ = fs::remove_file(session_checkpoint_path(session_id)); + #[cfg(target_os = "macos")] + self.background_chats.remove(&session_id); } self.drafts.remove(&project_id); if self.config.interface.last_project_id == Some(project_id) { @@ -1425,7 +1698,7 @@ impl App { } Message::RebuildSessionContext(session_id) => { self.session_menu = None; - if self.generating && self.selected_session == Some(session_id) { + if self.session_is_active(session_id) { self.error = Some("Stop the active generation before rebuilding context.".into()); } else { @@ -1461,18 +1734,24 @@ impl App { } } Message::SelectSession(project_id, session_id) => { - if self.generating && self.selected_session != Some(session_id) { - self.error = - Some("Stop the active generation before changing sessions.".into()); - return Task::none(); - } if self.selected_session == Some(session_id) { return Task::none(); } #[cfg(target_os = "macos")] - self.stop_agent_jobs(); - #[cfg(target_os = "macos")] - self.tool_cards.clear(); + { + self.leave_current_chat(); + if self.restore_background_chat(session_id) { + self.chat_follow_tail = true; + self.remember_project(project_id); + if let Some(database) = &mut self.database + && let Err(error) = database.touch_session(session_id) + { + self.error = Some(format!("Could not update the session: {error}")); + } + self.reload_projects(); + return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]); + } + } let saved_context = self .projects .iter() @@ -1542,7 +1821,7 @@ impl App { } } Message::RequestDeleteSession(session_id) => { - if self.generating && self.selected_session == Some(session_id) { + if self.session_is_active(session_id) { self.error = Some("Stop the active generation before deleting its session.".into()); return Task::none(); @@ -1563,7 +1842,7 @@ impl App { let Some(session_id) = self.pending_session_delete.take() else { return Task::none(); }; - if self.generating && self.selected_session == Some(session_id) { + if self.session_is_active(session_id) { self.error = Some("Stop the active generation before deleting its session.".into()); return Task::none(); @@ -1572,6 +1851,8 @@ impl App { match database.delete_session(session_id) { Ok(()) => { let _ = fs::remove_file(session_checkpoint_path(session_id)); + #[cfg(target_os = "macos")] + self.background_chats.remove(&session_id); self.finish_cache_change(); if self.session_menu == Some(session_id) { self.session_menu = None; @@ -1622,7 +1903,7 @@ impl App { ) .then_some(Message::Escape(id)) }), - window::close_requests().map(Message::WindowClosed), + window::close_requests().map(Message::WindowCloseRequested), window::close_events().map(Message::WindowClosed), ]; subscriptions @@ -1638,6 +1919,7 @@ impl App { Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat, Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar, Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp, + Some(crate::native_menu::NativeMenuEvent::Quit) => Message::RequestQuit, Some(crate::native_menu::NativeMenuEvent::Edit(command)) => { Message::NativeEdit(command) } @@ -1653,7 +1935,11 @@ impl App { let titling = self.active_titling.is_some(); #[cfg(not(target_os = "macos"))] let titling = false; - if self.generating || titling { + #[cfg(target_os = "macos")] + let background_polling = self.background_chats.values().any(ChatSnapshot::needs_poll); + #[cfg(not(target_os = "macos"))] + let background_polling = false; + if self.generating || titling || background_polling { subscriptions.push( iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick), ); @@ -1824,7 +2110,7 @@ impl App { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); menu.update( - self.selected_project.is_some() && !self.generating, + self.selected_project.is_some(), self.active_chat_title().is_some(), !self.config.interface.sidebar_collapsed, edit, @@ -1893,6 +2179,7 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option Some(Message::ToggleSidebar), keyboard::Key::Character("n") if modifiers.command() => Some(Message::NewChat), + keyboard::Key::Character("q") if modifiers.command() => Some(Message::RequestQuit), keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() { Message::FocusPrevious } else { @@ -1902,6 +2189,14 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option bool { + generating || titling +} + +fn active_chat_total(foreground: bool, background: impl IntoIterator) -> usize { + usize::from(foreground) + background.into_iter().filter(|active| *active).count() +} + fn export_file_stem(title: &str) -> String { let stem = title .chars() @@ -2242,6 +2537,13 @@ mod tests { keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT, ); assert!(matches!(message, Some(Message::OpenModelManager))); + assert!(matches!( + shortcut( + keyboard::Key::Character("q".into()), + keyboard::Modifiers::COMMAND + ), + Some(Message::RequestQuit) + )); let tab = keyboard::Key::Named(keyboard::key::Named::Tab); assert!(matches!( shortcut(tab.clone(), keyboard::Modifiers::empty()), @@ -2256,6 +2558,15 @@ mod tests { assert!(!ModelChoice::Glm52.supports_dspark()); } + #[test] + fn background_activity_drives_polling_indicators_and_quit_confirmation() { + assert!(chat_needs_poll(true, false)); + assert!(chat_needs_poll(false, true)); + assert!(!chat_needs_poll(false, false)); + assert_eq!(active_chat_total(false, [true, false, true]), 2); + assert_eq!(active_chat_total(true, [true, false, true]), 3); + } + #[test] fn tab_scrolls_a_field_back_into_the_preferences_viewport() { let viewport = iced::Rectangle { diff --git a/src/app/projects.rs b/src/app/projects.rs index e03f8e6..a0e7b76 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -30,6 +30,7 @@ impl App { self.a2ui_image_requests.insert(url.clone()); self.a2ui_image_loading = true; let request_url = url.clone(); + let session_id = self.selected_session; Task::perform( async move { let mut response = ureq::get(&request_url) @@ -48,7 +49,7 @@ impl App { .read_to_vec() .map_err(|error| error.to_string()) }, - move |result| Message::A2uiImageLoaded(url, result), + move |result| Message::A2uiImageLoaded(session_id, url, result), ) } @@ -159,17 +160,11 @@ impl App { /// 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; - } if self.database.is_none() { return; } #[cfg(target_os = "macos")] - self.stop_agent_jobs(); - #[cfg(target_os = "macos")] - self.tool_cards.clear(); + self.leave_current_chat(); let title = draft_title(&self.projects, project_id); self.drafts.entry(project_id).or_insert(title); self.remember_project(project_id); diff --git a/src/app/view.rs b/src/app/view.rs index 2d7e2ff..8e9fe95 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -101,7 +101,8 @@ impl App { /// Whether a dialog covers the window. Focus moves through the whole widget /// tree, so the layers below have to stay out of the dialog's field order. pub(super) fn modal_open(&self) -> bool { - self.pending_project_path.is_some() + self.quit_confirmation + || self.pending_project_path.is_some() || self.pending_session_delete.is_some() || self.session_rename.is_some() || self.menu_session().is_some() @@ -161,7 +162,9 @@ impl App { let mut layers = vec![content]; #[cfg(target_os = "macos")] - if let Some((prompt, _)) = &self.pending_tool_approval { + if self.quit_confirmation { + layers.push(self.quit_confirmation_panel()); + } else if let Some((prompt, _)) = &self.pending_tool_approval { layers.push(self.tool_approval_panel(prompt)); } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); @@ -177,7 +180,9 @@ impl App { layers.push(panel); } #[cfg(not(target_os = "macos"))] - if let Some(path) = &self.pending_project_path { + if self.quit_confirmation { + layers.push(self.quit_confirmation_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)); @@ -197,6 +202,38 @@ impl App { .into() } + fn quit_confirmation_panel(&self) -> Element<'_, Message> { + let active = self.active_chat_count(); + let dialog = container( + column![ + text("Quit while chats are active?").size(22), + text(format!( + "{active} active chat{} will be stopped.", + if active == 1 { "" } else { "s" } + )) + .size(13), + row![ + Space::new().width(Length::Fill), + action_button("Cancel").on_press(Message::CancelQuit), + danger_button("Quit").on_press(Message::ConfirmQuit), + ] + .spacing(8), + ] + .spacing(12), + ) + .padding(22) + .width(460) + .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)) + }), + ) + } + #[cfg(target_os = "macos")] fn tool_approval_panel<'a>( &'a self, @@ -408,6 +445,9 @@ impl App { label = label.push(icon(ICON_PIN, 12)); } label = label.push(text(&session.title).size(13)); + if self.session_is_active(session.id) { + label = label.push(text("●").size(10).color(app_theme().palette().success)); + } row![ Space::new().width(26), button(label) diff --git a/src/main.rs b/src/main.rs index 099aecc..df4d374 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ fn main() -> iced::Result { size: Size::new(1120.0, 720.0), min_size: Some(Size::new(760.0, 480.0)), icon: Some(app_icon()), + exit_on_close_request: false, // The title bar is ours: content runs to the top edge and the // native traffic lights float over it. #[cfg(target_os = "macos")] diff --git a/src/native_menu.rs b/src/native_menu.rs index 8e6289c..6c02f69 100644 --- a/src/native_menu.rs +++ b/src/native_menu.rs @@ -9,6 +9,7 @@ const NEW_CHAT: &str = "new-chat"; const EXPORT_CHAT: &str = "export-chat"; const TOGGLE_SIDEBAR: &str = "toggle-sidebar"; const HELP: &str = "help"; +const QUIT: &str = "quit"; const UNDO: &str = "undo"; const REDO: &str = "redo"; const CUT: &str = "cut"; @@ -37,6 +38,7 @@ pub(crate) enum NativeMenuEvent { ExportChat, ToggleSidebar, Help, + Quit, Edit(EditCommand), } @@ -100,6 +102,12 @@ pub(crate) fn install() -> Result { let copy = edit_item(COPY, "Copy", Code::KeyC, None); let paste = edit_item(PASTE, "Paste", Code::KeyV, None); let select_all = edit_item(SELECT_ALL, "Select All", Code::KeyA, None); + let quit = MenuItem::with_id( + QUIT, + "Quit DS4Server", + true, + Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyQ)), + ); application .append_items(&[ @@ -113,7 +121,7 @@ pub(crate) fn install() -> Result { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &quit, ]) .map_err(|error| error.to_string())?; file.append_items(&[ @@ -191,6 +199,7 @@ pub(crate) fn next_event() -> Option { EXPORT_CHAT => NativeMenuEvent::ExportChat, TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar, HELP => NativeMenuEvent::Help, + QUIT => NativeMenuEvent::Quit, UNDO => NativeMenuEvent::Edit(EditCommand::Undo), REDO => NativeMenuEvent::Edit(EditCommand::Redo), CUT => NativeMenuEvent::Edit(EditCommand::Cut),