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 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 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. 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, Use **File > Export Chat as Markdown** (`⇧⌘S`) to save the visible conversation,
including reasoning and tool results. System-only messages are omitted. 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 /// 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. /// when its first chat turn is stored, so empty ones vanish on restart.
drafts: HashMap<i32, String>, drafts: HashMap<i32, String>,
#[cfg(target_os = "macos")]
background_chats: HashMap<i32, ChatSnapshot>,
/// Session whose quick-actions menu is open. /// Session whose quick-actions menu is open.
session_menu: Option<i32>, session_menu: Option<i32>,
/// Session waiting for explicit confirmation before deletion. /// Session waiting for explicit confirmation before deletion.
@@ -146,6 +148,58 @@ pub(crate) struct App {
manual_compaction_queued: bool, manual_compaction_queued: bool,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
skip_compaction_once: bool, 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)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -229,10 +283,14 @@ pub(crate) enum Message {
HelpOpened(window::Id), HelpOpened(window::Id),
NewChat, NewChat,
ExportChat, ExportChat,
ExportChatPicked(Option<PathBuf>), ExportChatPicked(Option<PathBuf>, String),
ModelManagerOpened(window::Id), ModelManagerOpened(window::Id),
WindowOpened(window::Id), WindowOpened(window::Id),
WindowCloseRequested(window::Id),
WindowClosed(window::Id), WindowClosed(window::Id),
RequestQuit,
ConfirmQuit,
CancelQuit,
Escape(window::Id), Escape(window::Id),
DismissPanel, DismissPanel,
/// Tab and shift-tab: iced leaves the key to the application, so the fields /// 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>), A2uiAction(String, String, Option<String>),
A2uiSelectTab(String, String, usize), A2uiSelectTab(String, String, usize),
A2uiToggleModal(String, String), A2uiToggleModal(String, String),
A2uiImageLoaded(String, Result<Vec<u8>, String>), A2uiImageLoaded(Option<i32>, String, Result<Vec<u8>, String>),
A2uiPlayMedia(String, String, bool), A2uiPlayMedia(String, String, bool),
RequestA2uiDismiss(String), RequestA2uiDismiss(String),
ConfirmA2uiDismiss, ConfirmA2uiDismiss,
@@ -398,6 +456,8 @@ impl App {
selected_project: last_project, selected_project: last_project,
selected_session: None, selected_session: None,
drafts, drafts,
#[cfg(target_os = "macos")]
background_chats: HashMap::new(),
session_menu: None, session_menu: None,
pending_session_delete: None, pending_session_delete: None,
session_rename: None, session_rename: None,
@@ -480,6 +540,7 @@ impl App {
manual_compaction_queued: false, manual_compaction_queued: false,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
skip_compaction_once: false, skip_compaction_once: false,
quit_confirmation: false,
} }
} }
Err(error) => Self::failed( Err(error) => Self::failed(
@@ -527,6 +588,8 @@ impl App {
selected_project: None, selected_project: None,
selected_session: None, selected_session: None,
drafts: HashMap::new(), drafts: HashMap::new(),
#[cfg(target_os = "macos")]
background_chats: HashMap::new(),
session_menu: None, session_menu: None,
pending_session_delete: None, pending_session_delete: None,
session_rename: None, session_rename: None,
@@ -601,9 +664,180 @@ impl App {
manual_compaction_queued: false, manual_compaction_queued: false,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
skip_compaction_once: false, 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> { pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::Noop => { Message::Noop => {
@@ -645,6 +879,7 @@ impl App {
return Task::none(); return Task::none();
}; };
let file_name = format!("{}.md", export_file_stem(&title)); let file_name = format!("{}.md", export_file_stem(&title));
let content = export_markdown(&title, &self.conversation);
return Task::perform( return Task::perform(
async move { async move {
AsyncFileDialog::new() AsyncFileDialog::new()
@@ -655,13 +890,12 @@ impl App {
.await .await
.map(|file| file.path().to_path_buf()) .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 if let Some(path) = path
&& let Some(title) = self.active_chat_title() && let Err(error) = fs::write(&path, content)
&& let Err(error) = fs::write(&path, export_markdown(title, &self.conversation))
{ {
self.error = Some(format!("Could not export the chat: {error}")); self.error = Some(format!("Could not export the chat: {error}"));
} }
@@ -688,10 +922,13 @@ impl App {
return focus_composer(); return focus_composer();
} }
} }
Message::WindowClosed(id) => { Message::WindowCloseRequested(id) => {
if id == self.main_window { 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) { if self.model_manager_window == Some(id) {
self.model_manager_window = None; self.model_manager_window = None;
self.pending_model_delete = None; self.pending_model_delete = None;
@@ -704,6 +941,14 @@ impl App {
self.help_window = None; 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) => { Message::Escape(id) => {
if self.preferences_window == Some(id) { if self.preferences_window == Some(id) {
return self.update(Message::ClosePreferences); return self.update(Message::ClosePreferences);
@@ -713,7 +958,9 @@ impl App {
} }
} }
Message::DismissPanel => { 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; self.pending_session_delete = None;
} else if self.pending_a2ui_dismissal.is_some() { } else if self.pending_a2ui_dismissal.is_some() {
self.pending_a2ui_dismissal = None; self.pending_a2ui_dismissal = None;
@@ -1091,7 +1338,29 @@ impl App {
self.a2ui_modals.insert(key); 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; self.a2ui_image_loading = false;
match result { match result {
Ok(bytes) => { Ok(bytes) => {
@@ -1245,6 +1514,8 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.poll_titling(); self.poll_titling();
self.poll_generation(); self.poll_generation();
#[cfg(target_os = "macos")]
self.poll_background_chats();
let images = self.load_next_a2ui_image(); let images = self.load_next_a2ui_image();
return images; return images;
} }
@@ -1301,7 +1572,7 @@ impl App {
} }
} }
Message::DeleteProject(project_id) => { Message::DeleteProject(project_id) => {
if self.generating && self.selected_project == Some(project_id) { if self.project_has_active_chat(project_id) {
self.error = self.error =
Some("Stop the active generation before deleting its project.".into()); Some("Stop the active generation before deleting its project.".into());
return Task::none(); return Task::none();
@@ -1322,6 +1593,8 @@ impl App {
Ok(()) => { Ok(()) => {
for session_id in checkpoint_ids { for session_id in checkpoint_ids {
let _ = fs::remove_file(session_checkpoint_path(session_id)); let _ = fs::remove_file(session_checkpoint_path(session_id));
#[cfg(target_os = "macos")]
self.background_chats.remove(&session_id);
} }
self.drafts.remove(&project_id); self.drafts.remove(&project_id);
if self.config.interface.last_project_id == Some(project_id) { if self.config.interface.last_project_id == Some(project_id) {
@@ -1425,7 +1698,7 @@ impl App {
} }
Message::RebuildSessionContext(session_id) => { Message::RebuildSessionContext(session_id) => {
self.session_menu = None; self.session_menu = None;
if self.generating && self.selected_session == Some(session_id) { if self.session_is_active(session_id) {
self.error = self.error =
Some("Stop the active generation before rebuilding context.".into()); Some("Stop the active generation before rebuilding context.".into());
} else { } else {
@@ -1461,18 +1734,24 @@ impl App {
} }
} }
Message::SelectSession(project_id, session_id) => { 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) { if self.selected_session == Some(session_id) {
return Task::none(); return Task::none();
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.stop_agent_jobs(); {
#[cfg(target_os = "macos")] self.leave_current_chat();
self.tool_cards.clear(); 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 let saved_context = self
.projects .projects
.iter() .iter()
@@ -1542,7 +1821,7 @@ impl App {
} }
} }
Message::RequestDeleteSession(session_id) => { Message::RequestDeleteSession(session_id) => {
if self.generating && self.selected_session == Some(session_id) { if self.session_is_active(session_id) {
self.error = self.error =
Some("Stop the active generation before deleting its session.".into()); Some("Stop the active generation before deleting its session.".into());
return Task::none(); return Task::none();
@@ -1563,7 +1842,7 @@ impl App {
let Some(session_id) = self.pending_session_delete.take() else { let Some(session_id) = self.pending_session_delete.take() else {
return Task::none(); return Task::none();
}; };
if self.generating && self.selected_session == Some(session_id) { if self.session_is_active(session_id) {
self.error = self.error =
Some("Stop the active generation before deleting its session.".into()); Some("Stop the active generation before deleting its session.".into());
return Task::none(); return Task::none();
@@ -1572,6 +1851,8 @@ impl App {
match database.delete_session(session_id) { match database.delete_session(session_id) {
Ok(()) => { Ok(()) => {
let _ = fs::remove_file(session_checkpoint_path(session_id)); let _ = fs::remove_file(session_checkpoint_path(session_id));
#[cfg(target_os = "macos")]
self.background_chats.remove(&session_id);
self.finish_cache_change(); self.finish_cache_change();
if self.session_menu == Some(session_id) { if self.session_menu == Some(session_id) {
self.session_menu = None; self.session_menu = None;
@@ -1622,7 +1903,7 @@ impl App {
) )
.then_some(Message::Escape(id)) .then_some(Message::Escape(id))
}), }),
window::close_requests().map(Message::WindowClosed), window::close_requests().map(Message::WindowCloseRequested),
window::close_events().map(Message::WindowClosed), window::close_events().map(Message::WindowClosed),
]; ];
subscriptions subscriptions
@@ -1638,6 +1919,7 @@ impl App {
Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat, Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat,
Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar, Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar,
Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp, Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp,
Some(crate::native_menu::NativeMenuEvent::Quit) => Message::RequestQuit,
Some(crate::native_menu::NativeMenuEvent::Edit(command)) => { Some(crate::native_menu::NativeMenuEvent::Edit(command)) => {
Message::NativeEdit(command) Message::NativeEdit(command)
} }
@@ -1653,7 +1935,11 @@ impl App {
let titling = self.active_titling.is_some(); let titling = self.active_titling.is_some();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
let titling = false; 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( subscriptions.push(
iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick), iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick),
); );
@@ -1824,7 +2110,7 @@ impl App {
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
menu.update( menu.update(
self.selected_project.is_some() && !self.generating, self.selected_project.is_some(),
self.active_chat_title().is_some(), self.active_chat_title().is_some(),
!self.config.interface.sidebar_collapsed, !self.config.interface.sidebar_collapsed,
edit, 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("b") if modifiers.command() => Some(Message::ToggleSidebar),
keyboard::Key::Character("n") if modifiers.command() => Some(Message::NewChat), 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() { keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() {
Message::FocusPrevious Message::FocusPrevious
} else { } 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 { fn export_file_stem(title: &str) -> String {
let stem = title let stem = title
.chars() .chars()
@@ -2242,6 +2537,13 @@ mod tests {
keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT, keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
); );
assert!(matches!(message, Some(Message::OpenModelManager))); 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); let tab = keyboard::Key::Named(keyboard::key::Named::Tab);
assert!(matches!( assert!(matches!(
shortcut(tab.clone(), keyboard::Modifiers::empty()), shortcut(tab.clone(), keyboard::Modifiers::empty()),
@@ -2256,6 +2558,15 @@ mod tests {
assert!(!ModelChoice::Glm52.supports_dspark()); 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] #[test]
fn tab_scrolls_a_field_back_into_the_preferences_viewport() { fn tab_scrolls_a_field_back_into_the_preferences_viewport() {
let viewport = iced::Rectangle { let viewport = iced::Rectangle {

View File

@@ -30,6 +30,7 @@ impl App {
self.a2ui_image_requests.insert(url.clone()); self.a2ui_image_requests.insert(url.clone());
self.a2ui_image_loading = true; self.a2ui_image_loading = true;
let request_url = url.clone(); let request_url = url.clone();
let session_id = self.selected_session;
Task::perform( Task::perform(
async move { async move {
let mut response = ureq::get(&request_url) let mut response = ureq::get(&request_url)
@@ -48,7 +49,7 @@ impl App {
.read_to_vec() .read_to_vec()
.map_err(|error| error.to_string()) .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 /// 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`]. /// database until the first chat turn is stored by [`App::persist_session`].
pub(super) fn create_session(&mut self, project_id: i32) { 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() { if self.database.is_none() {
return; return;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.stop_agent_jobs(); self.leave_current_chat();
#[cfg(target_os = "macos")]
self.tool_cards.clear();
let title = draft_title(&self.projects, project_id); let title = draft_title(&self.projects, project_id);
self.drafts.entry(project_id).or_insert(title); self.drafts.entry(project_id).or_insert(title);
self.remember_project(project_id); 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 /// 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. /// tree, so the layers below have to stay out of the dialog's field order.
pub(super) fn modal_open(&self) -> bool { 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.pending_session_delete.is_some()
|| self.session_rename.is_some() || self.session_rename.is_some()
|| self.menu_session().is_some() || self.menu_session().is_some()
@@ -161,7 +162,9 @@ impl App {
let mut layers = vec![content]; let mut layers = vec![content];
#[cfg(target_os = "macos")] #[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)); layers.push(self.tool_approval_panel(prompt));
} else if let Some(path) = &self.pending_project_path { } else if let Some(path) = &self.pending_project_path {
layers.push(self.project_dialog(path)); layers.push(self.project_dialog(path));
@@ -177,7 +180,9 @@ impl App {
layers.push(panel); layers.push(panel);
} }
#[cfg(not(target_os = "macos"))] #[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)); layers.push(self.project_dialog(path));
} else if let Some((_, title)) = &self.session_rename { } else if let Some((_, title)) = &self.session_rename {
layers.push(self.rename_dialog(title)); layers.push(self.rename_dialog(title));
@@ -197,6 +202,38 @@ impl App {
.into() .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")] #[cfg(target_os = "macos")]
fn tool_approval_panel<'a>( fn tool_approval_panel<'a>(
&'a self, &'a self,
@@ -408,6 +445,9 @@ impl App {
label = label.push(icon(ICON_PIN, 12)); label = label.push(icon(ICON_PIN, 12));
} }
label = label.push(text(&session.title).size(13)); 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![ row![
Space::new().width(26), Space::new().width(26),
button(label) button(label)

View File

@@ -42,6 +42,7 @@ fn main() -> iced::Result {
size: Size::new(1120.0, 720.0), size: Size::new(1120.0, 720.0),
min_size: Some(Size::new(760.0, 480.0)), min_size: Some(Size::new(760.0, 480.0)),
icon: Some(app_icon()), icon: Some(app_icon()),
exit_on_close_request: false,
// The title bar is ours: content runs to the top edge and the // The title bar is ours: content runs to the top edge and the
// native traffic lights float over it. // native traffic lights float over it.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -9,6 +9,7 @@ const NEW_CHAT: &str = "new-chat";
const EXPORT_CHAT: &str = "export-chat"; const EXPORT_CHAT: &str = "export-chat";
const TOGGLE_SIDEBAR: &str = "toggle-sidebar"; const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
const HELP: &str = "help"; const HELP: &str = "help";
const QUIT: &str = "quit";
const UNDO: &str = "undo"; const UNDO: &str = "undo";
const REDO: &str = "redo"; const REDO: &str = "redo";
const CUT: &str = "cut"; const CUT: &str = "cut";
@@ -37,6 +38,7 @@ pub(crate) enum NativeMenuEvent {
ExportChat, ExportChat,
ToggleSidebar, ToggleSidebar,
Help, Help,
Quit,
Edit(EditCommand), Edit(EditCommand),
} }
@@ -100,6 +102,12 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
let copy = edit_item(COPY, "Copy", Code::KeyC, None); let copy = edit_item(COPY, "Copy", Code::KeyC, None);
let paste = edit_item(PASTE, "Paste", Code::KeyV, None); let paste = edit_item(PASTE, "Paste", Code::KeyV, None);
let select_all = edit_item(SELECT_ALL, "Select All", Code::KeyA, 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 application
.append_items(&[ .append_items(&[
@@ -113,7 +121,7 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
&PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None), &PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(), &PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None), &quit,
]) ])
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
file.append_items(&[ file.append_items(&[
@@ -191,6 +199,7 @@ pub(crate) fn next_event() -> Option<NativeMenuEvent> {
EXPORT_CHAT => NativeMenuEvent::ExportChat, EXPORT_CHAT => NativeMenuEvent::ExportChat,
TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar, TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar,
HELP => NativeMenuEvent::Help, HELP => NativeMenuEvent::Help,
QUIT => NativeMenuEvent::Quit,
UNDO => NativeMenuEvent::Edit(EditCommand::Undo), UNDO => NativeMenuEvent::Edit(EditCommand::Undo),
REDO => NativeMenuEvent::Edit(EditCommand::Redo), REDO => NativeMenuEvent::Edit(EditCommand::Redo),
CUT => NativeMenuEvent::Edit(EditCommand::Cut), CUT => NativeMenuEvent::Edit(EditCommand::Cut),