diff --git a/src/app.rs b/src/app.rs index 58b79b8..29fb0f2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -90,6 +90,7 @@ pub(crate) struct App { pub(super) a2ui_image_requests: HashSet, pub(super) a2ui_image_loading: bool, pub(super) pending_a2ui_dismissal: Option, + pub(super) a2ui_auto_switch_pending: bool, pub(super) generating: bool, pub(super) context_used: u32, pub(super) context_limit: u32, @@ -141,6 +142,7 @@ pub(crate) struct App { pub(super) enum DetailTab { #[default] Chat, + A2ui, Stats, } @@ -264,6 +266,7 @@ pub(crate) enum Message { SetSessionState(i32, SessionState), ToggleArchivedSessions(i32), ShowChat, + ShowA2ui, ShowStats, ToggleSidebar, StartSidebarDrag, @@ -346,6 +349,7 @@ impl App { a2ui_image_requests: HashSet::new(), a2ui_image_loading: false, pending_a2ui_dismissal: None, + a2ui_auto_switch_pending: false, generating: false, context_used: 0, context_limit, @@ -464,6 +468,7 @@ impl App { a2ui_image_requests: HashSet::new(), a2ui_image_loading: false, pending_a2ui_dismissal: None, + a2ui_auto_switch_pending: false, generating: false, context_used: 0, context_limit, @@ -571,7 +576,11 @@ impl App { Message::FocusPrevious => { return iced::widget::operation::focus_previous().chain(reveal_focused()); } - Message::ShowChat => self.detail_tab = DetailTab::Chat, + Message::ShowChat => { + self.detail_tab = DetailTab::Chat; + return scroll_chat_to_end(); + } + Message::ShowA2ui => self.detail_tab = DetailTab::A2ui, Message::ShowStats => { self.detail_tab = DetailTab::Stats; self.scan_kv_cache(); diff --git a/src/app/generation.rs b/src/app/generation.rs index f451b9f..805cc0f 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -149,8 +149,9 @@ fn sync_a2ui_message( database: &mut Option, session_id: Option, message: &mut ChatMessage, -) { +) -> bool { let lines = crate::a2ui::extract_lines(&message.content); + let mut renderable_surface_updated = false; for (index, line) in lines.iter().enumerate().skip(message.a2ui_lines_processed) { let applied = match &line.value { Ok(value) => store.apply(value.clone(), line.raw.clone(), message.id), @@ -158,6 +159,13 @@ fn sync_a2ui_message( }; match applied { Ok(applied) => { + renderable_surface_updated |= line.value.as_ref().is_ok_and(|value| { + crate::a2ui::message_surface_id(value).is_some_and(|id| { + store.active_surface().is_some_and(|surface| { + surface.id == id && surface.components.contains_key("root") + }) + }) + }); if let Some(reply) = applied.reply { message.a2ui_replies.push(reply); } @@ -185,6 +193,7 @@ fn sync_a2ui_message( } message.a2ui_lines_processed = lines.len(); message.refresh_markdown(); + renderable_surface_updated } #[cfg(target_os = "macos")] @@ -300,6 +309,7 @@ impl App { )); return; } + self.a2ui_auto_switch_pending = true; #[cfg(target_os = "macos")] self.tool_cards.clear(); #[cfg(target_os = "macos")] @@ -617,13 +627,17 @@ impl App { && self.config.a2ui_enabled && let Some(message) = self.conversation.last_mut() { - sync_a2ui_message( + let renderable_surface_updated = sync_a2ui_message( &mut self.a2ui, &mut self.database, self.selected_session, message, ); a2ui_changed = true; + if self.a2ui_auto_switch_pending && renderable_surface_updated { + self.detail_tab = DetailTab::A2ui; + self.a2ui_auto_switch_pending = false; + } } } Ok(GenerationEvent::Context { @@ -943,6 +957,9 @@ impl App { ); let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; let queued = queued_prompt(self.queued_inputs.drain(..)); + if queued.is_some() { + self.a2ui_auto_switch_pending = true; + } let reminders = if self.system_prompt_reminder_due() { self.system_prompt_reminders(model) } else { @@ -1576,6 +1593,7 @@ mod tests { use super::{ ChatMessage, TOOL_PROTOCOL_CORRECTION, compacted_context_start, correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call, is_empty_response, queued_prompt, + sync_a2ui_message, }; use crate::model::ModelChoice; @@ -1672,6 +1690,36 @@ mod tests { assert_eq!(rows.len(), 2); } + #[test] + fn a2ui_auto_switch_waits_for_a_renderable_surface() { + let mut store = crate::a2ui::Store::default(); + let mut database = None; + let mut message = assistant( + None, + r#"```a2ui +{"version":"v1.0","createSurface":{"surfaceId":"answer","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}} +```"#, + ); + assert!(!sync_a2ui_message( + &mut store, + &mut database, + None, + &mut message + )); + + message.content = r#"```a2ui +{"version":"v1.0","createSurface":{"surfaceId":"answer","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}} +{"version":"v1.0","updateComponents":{"surfaceId":"answer","components":[{"id":"root","component":"Text","text":"Ready"}]}} +```"# + .to_owned(); + assert!(sync_a2ui_message( + &mut store, + &mut database, + None, + &mut message + )); + } + #[test] fn last_compaction_selects_its_tail_without_hiding_history() { let message = |id: i32, compaction: bool, tail: Option| ChatMessage { diff --git a/src/app/view.rs b/src/app/view.rs index eef46ee..917906b 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -431,6 +431,7 @@ impl App { fn detail_tabs(&self) -> Element<'_, Message> { let chat_active = self.detail_tab == DetailTab::Chat; + let a2ui_active = self.detail_tab == DetailTab::A2ui; let stats_active = self.detail_tab == DetailTab::Stats; let tabs = container( row![ @@ -440,6 +441,12 @@ impl App { .padding([0, 16]) .on_press(Message::ShowChat) .style(move |theme, status| segmented_button_style(theme, status, chat_active)), + button(text("A2UI").size(13)) + .width(88) + .height(TITLE_BAR_CONTROL - 4.0) + .padding([0, 16]) + .on_press(Message::ShowA2ui) + .style(move |theme, status| segmented_button_style(theme, status, a2ui_active)), button(text("Stats").size(13)) .width(88) .height(TITLE_BAR_CONTROL - 4.0) @@ -461,6 +468,7 @@ impl App { fn detail(&self) -> Element<'_, Message> { match self.detail_tab { DetailTab::Chat => self.chat_detail(), + DetailTab::A2ui => self.a2ui_detail(), DetailTab::Stats => self.stats_dashboard(), } } diff --git a/src/app/view/a2ui.rs b/src/app/view/a2ui.rs index 0450baa..b2fc57d 100644 --- a/src/app/view/a2ui.rs +++ b/src/app/view/a2ui.rs @@ -6,6 +6,30 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use time::{Month, OffsetDateTime}; impl App { + pub(super) fn a2ui_detail(&self) -> Element<'_, Message> { + let content = self.displayed_a2ui_surface().unwrap_or_else(|| { + container( + column![ + text("No A2UI surface").size(24), + text("Interactive surfaces from DS4 will appear here.") + .size(14) + .color(muted_text()), + ] + .spacing(8) + .align_x(Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + }); + container(content) + .max_width(860) + .center_x(Length::Fill) + .height(Length::Fill) + .padding(24) + .into() + } + pub(crate) fn sync_a2ui_renderer_state(&mut self) { let mut markdown = HashMap::new(); let mut editors = HashMap::new(); diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index e95332c..1227120 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -293,21 +293,8 @@ impl App { let transcript = scrollable(messages) .id(chat_scroll_id()) .height(Length::Fill); - let workspace: Element<'_, Message> = - if let Some(surface) = self.displayed_a2ui_surface() { - column![ - container(surface).height(Length::FillPortion(1)), - rule::horizontal(1), - container(transcript).height(Length::FillPortion(1)), - ] - .height(Length::Fill) - .spacing(8) - .into() - } else { - transcript.into() - }; let conversation = column![ - workspace, + transcript, container(composer_content,) .padding(16) .width(Length::Fill)