feat: moved a2ui surface to a separate panel

This commit is contained in:
Georg Bauer
2026-07-27 12:58:46 +02:00
parent ef1c574a7f
commit f69623ad02
5 changed files with 93 additions and 17 deletions

View File

@@ -90,6 +90,7 @@ pub(crate) struct App {
pub(super) a2ui_image_requests: HashSet<String>, pub(super) a2ui_image_requests: HashSet<String>,
pub(super) a2ui_image_loading: bool, pub(super) a2ui_image_loading: bool,
pub(super) pending_a2ui_dismissal: Option<String>, pub(super) pending_a2ui_dismissal: Option<String>,
pub(super) a2ui_auto_switch_pending: bool,
pub(super) generating: bool, pub(super) generating: bool,
pub(super) context_used: u32, pub(super) context_used: u32,
pub(super) context_limit: u32, pub(super) context_limit: u32,
@@ -141,6 +142,7 @@ pub(crate) struct App {
pub(super) enum DetailTab { pub(super) enum DetailTab {
#[default] #[default]
Chat, Chat,
A2ui,
Stats, Stats,
} }
@@ -264,6 +266,7 @@ pub(crate) enum Message {
SetSessionState(i32, SessionState), SetSessionState(i32, SessionState),
ToggleArchivedSessions(i32), ToggleArchivedSessions(i32),
ShowChat, ShowChat,
ShowA2ui,
ShowStats, ShowStats,
ToggleSidebar, ToggleSidebar,
StartSidebarDrag, StartSidebarDrag,
@@ -346,6 +349,7 @@ impl App {
a2ui_image_requests: HashSet::new(), a2ui_image_requests: HashSet::new(),
a2ui_image_loading: false, a2ui_image_loading: false,
pending_a2ui_dismissal: None, pending_a2ui_dismissal: None,
a2ui_auto_switch_pending: false,
generating: false, generating: false,
context_used: 0, context_used: 0,
context_limit, context_limit,
@@ -464,6 +468,7 @@ impl App {
a2ui_image_requests: HashSet::new(), a2ui_image_requests: HashSet::new(),
a2ui_image_loading: false, a2ui_image_loading: false,
pending_a2ui_dismissal: None, pending_a2ui_dismissal: None,
a2ui_auto_switch_pending: false,
generating: false, generating: false,
context_used: 0, context_used: 0,
context_limit, context_limit,
@@ -571,7 +576,11 @@ impl App {
Message::FocusPrevious => { Message::FocusPrevious => {
return iced::widget::operation::focus_previous().chain(reveal_focused()); 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 => { Message::ShowStats => {
self.detail_tab = DetailTab::Stats; self.detail_tab = DetailTab::Stats;
self.scan_kv_cache(); self.scan_kv_cache();

View File

@@ -149,8 +149,9 @@ fn sync_a2ui_message(
database: &mut Option<Database>, database: &mut Option<Database>,
session_id: Option<i32>, session_id: Option<i32>,
message: &mut ChatMessage, message: &mut ChatMessage,
) { ) -> bool {
let lines = crate::a2ui::extract_lines(&message.content); 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) { for (index, line) in lines.iter().enumerate().skip(message.a2ui_lines_processed) {
let applied = match &line.value { let applied = match &line.value {
Ok(value) => store.apply(value.clone(), line.raw.clone(), message.id), Ok(value) => store.apply(value.clone(), line.raw.clone(), message.id),
@@ -158,6 +159,13 @@ fn sync_a2ui_message(
}; };
match applied { match applied {
Ok(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 { if let Some(reply) = applied.reply {
message.a2ui_replies.push(reply); message.a2ui_replies.push(reply);
} }
@@ -185,6 +193,7 @@ fn sync_a2ui_message(
} }
message.a2ui_lines_processed = lines.len(); message.a2ui_lines_processed = lines.len();
message.refresh_markdown(); message.refresh_markdown();
renderable_surface_updated
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -300,6 +309,7 @@ impl App {
)); ));
return; return;
} }
self.a2ui_auto_switch_pending = true;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.tool_cards.clear(); self.tool_cards.clear();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -617,13 +627,17 @@ impl App {
&& self.config.a2ui_enabled && self.config.a2ui_enabled
&& let Some(message) = self.conversation.last_mut() && let Some(message) = self.conversation.last_mut()
{ {
sync_a2ui_message( let renderable_surface_updated = sync_a2ui_message(
&mut self.a2ui, &mut self.a2ui,
&mut self.database, &mut self.database,
self.selected_session, self.selected_session,
message, message,
); );
a2ui_changed = true; 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 { Ok(GenerationEvent::Context {
@@ -943,6 +957,9 @@ impl App {
); );
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
let queued = queued_prompt(self.queued_inputs.drain(..)); 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() { let reminders = if self.system_prompt_reminder_due() {
self.system_prompt_reminders(model) self.system_prompt_reminders(model)
} else { } else {
@@ -1576,6 +1593,7 @@ mod tests {
use super::{ use super::{
ChatMessage, TOOL_PROTOCOL_CORRECTION, compacted_context_start, correction_already_sent, ChatMessage, TOOL_PROTOCOL_CORRECTION, compacted_context_start, correction_already_sent,
has_chat_after_last_compaction, has_misplaced_tool_call, is_empty_response, queued_prompt, has_chat_after_last_compaction, has_misplaced_tool_call, is_empty_response, queued_prompt,
sync_a2ui_message,
}; };
use crate::model::ModelChoice; use crate::model::ModelChoice;
@@ -1672,6 +1690,36 @@ mod tests {
assert_eq!(rows.len(), 2); 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] #[test]
fn last_compaction_selects_its_tail_without_hiding_history() { fn last_compaction_selects_its_tail_without_hiding_history() {
let message = |id: i32, compaction: bool, tail: Option<i32>| ChatMessage { let message = |id: i32, compaction: bool, tail: Option<i32>| ChatMessage {

View File

@@ -431,6 +431,7 @@ impl App {
fn detail_tabs(&self) -> Element<'_, Message> { fn detail_tabs(&self) -> Element<'_, Message> {
let chat_active = self.detail_tab == DetailTab::Chat; 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 stats_active = self.detail_tab == DetailTab::Stats;
let tabs = container( let tabs = container(
row![ row![
@@ -440,6 +441,12 @@ impl App {
.padding([0, 16]) .padding([0, 16])
.on_press(Message::ShowChat) .on_press(Message::ShowChat)
.style(move |theme, status| segmented_button_style(theme, status, chat_active)), .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)) button(text("Stats").size(13))
.width(88) .width(88)
.height(TITLE_BAR_CONTROL - 4.0) .height(TITLE_BAR_CONTROL - 4.0)
@@ -461,6 +468,7 @@ impl App {
fn detail(&self) -> Element<'_, Message> { fn detail(&self) -> Element<'_, Message> {
match self.detail_tab { match self.detail_tab {
DetailTab::Chat => self.chat_detail(), DetailTab::Chat => self.chat_detail(),
DetailTab::A2ui => self.a2ui_detail(),
DetailTab::Stats => self.stats_dashboard(), DetailTab::Stats => self.stats_dashboard(),
} }
} }

View File

@@ -6,6 +6,30 @@ use std::collections::{BTreeSet, HashMap, HashSet};
use time::{Month, OffsetDateTime}; use time::{Month, OffsetDateTime};
impl App { 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) { pub(crate) fn sync_a2ui_renderer_state(&mut self) {
let mut markdown = HashMap::new(); let mut markdown = HashMap::new();
let mut editors = HashMap::new(); let mut editors = HashMap::new();

View File

@@ -293,21 +293,8 @@ impl App {
let transcript = scrollable(messages) let transcript = scrollable(messages)
.id(chat_scroll_id()) .id(chat_scroll_id())
.height(Length::Fill); .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![ let conversation = column![
workspace, transcript,
container(composer_content,) container(composer_content,)
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)