Run chats independently

This commit is contained in:
Georg Bauer
2026-07-27 19:12:59 +02:00
parent 22a939751e
commit 7d865df20f
6 changed files with 401 additions and 37 deletions

View File

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

View File

@@ -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<i32, String>,
#[cfg(target_os = "macos")]
background_chats: HashMap<i32, ChatSnapshot>,
/// Session whose quick-actions menu is open.
session_menu: Option<i32>,
/// 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<i32>,
selected_session: Option<i32>,
composer: String,
queued_inputs: VecDeque<String>,
conversation: Vec<ChatMessage>,
chat_follow_tail: bool,
active_turn: Option<generation::TurnSummary>,
a2ui: crate::a2ui::Store,
a2ui_history: Vec<crate::a2ui::Store>,
a2ui_history_index: Option<usize>,
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<String, iced::widget::image::Handle>,
a2ui_image_requests: HashSet<String>,
a2ui_image_loading: bool,
pending_a2ui_dismissal: Option<String>,
a2ui_auto_switch_pending: bool,
generating: bool,
context_used: u32,
context_limit: u32,
tokens_per_second: Option<f32>,
detail_tab: DetailTab,
active_generation: Option<ActiveGeneration>,
active_compaction: Option<generation::CompactionRequest>,
active_tool_check: Option<generation::ToolResultCheck>,
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
active_tools: Option<crate::agent::ActiveTools>,
tool_cards: Vec<crate::agent::ToolCard>,
pending_tool_approval: Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
active_titling: Option<generation::TitleRequest>,
error: Option<String>,
activity: Option<String>,
context_notice: Option<String>,
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<PathBuf>),
ExportChatPicked(Option<PathBuf>, 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<String>),
A2uiSelectTab(String, String, usize),
A2uiToggleModal(String, String),
A2uiImageLoaded(String, Result<Vec<u8>, String>),
A2uiImageLoaded(Option<i32>, String, Result<Vec<u8>, 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::<Vec<_>>();
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<Message> {
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<Messag
}
keyboard::Key::Character("b") if modifiers.command() => 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<Messag
}
}
fn chat_needs_poll(generating: bool, titling: bool) -> bool {
generating || titling
}
fn active_chat_total(foreground: bool, background: impl IntoIterator<Item = bool>) -> 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 {

View File

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

View File

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

View File

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

View File

@@ -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<NativeMenu, String> {
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<NativeMenu, String> {
&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<NativeMenuEvent> {
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),