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

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