prev/next A2UI surface and better dismissal

This commit is contained in:
Georg Bauer
2026-07-27 11:52:15 +02:00
parent c9c2d8efd5
commit 4a309091b6
14 changed files with 466 additions and 61 deletions

View File

@@ -307,10 +307,16 @@ impl App {
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
#[cfg(target_os = "macos")]
let model_prompt = if self.config.a2ui_enabled {
format!(
let mut prompt = format!(
"{prompt}\n\nA2UI client metadata:\n{}",
self.a2ui.client_metadata()
)
);
if self.a2ui.active_surface().is_none() {
prompt.push_str(
"\n\nThere is no active A2UI surface. If this response presents UI, its first A2UI message must be createSurface with a new surfaceId and a complete root component tree. Do not update any surfaceId found only in earlier chat history.",
);
}
prompt
} else {
prompt.clone()
};
@@ -652,18 +658,22 @@ impl App {
self.generating = false;
self.activity = Some("Correcting A2UI…".into());
self.tool_cards.clear();
a2ui_feedback = Some(
serde_json::json!({
"version": crate::a2ui::VERSION,
"error": {
"code": "VALIDATION_FAILED",
"surfaceId": error_surface_id,
"path": "/",
"message": validation_errors.join("; ")
}
})
.to_string(),
);
let mut feedback = serde_json::json!({
"version": crate::a2ui::VERSION,
"error": {
"code": "VALIDATION_FAILED",
"surfaceId": error_surface_id,
"path": "/",
"message": validation_errors.join("; ")
}
})
.to_string();
if self.a2ui.active_surface().is_none() {
feedback.push_str(
"\nThere is no active A2UI surface. Correct this by emitting createSurface with a new surfaceId and a complete root component tree; do not retry updateComponents for an earlier surface.",
);
}
a2ui_feedback = Some(feedback);
self.active_generation = None;
break;
}

View File

@@ -3,6 +3,8 @@ use super::*;
impl App {
pub(super) fn clear_a2ui(&mut self) {
self.a2ui.clear();
self.a2ui_history.clear();
self.a2ui_history_index = None;
self.a2ui_tabs.clear();
self.a2ui_modals.clear();
self.a2ui_editors.clear();
@@ -11,15 +13,16 @@ impl App {
self.a2ui_images.clear();
self.a2ui_image_requests.clear();
self.a2ui_image_loading = false;
self.pending_a2ui_dismissal = None;
}
pub(super) fn load_next_a2ui_image(&mut self) -> Task<Message> {
if self.a2ui_image_loading {
return Task::none();
}
let Some(url) = self
.a2ui
.image_urls()
let urls = self.displayed_a2ui_store().image_urls().collect::<Vec<_>>();
let Some(url) = urls
.into_iter()
.find(|url| !self.a2ui_image_requests.contains(url))
else {
return Task::none();
@@ -49,12 +52,32 @@ impl App {
)
}
pub(super) fn displayed_a2ui_store(&self) -> &crate::a2ui::Store {
self.a2ui_history_index
.and_then(|index| self.a2ui_history.get(index))
.unwrap_or(&self.a2ui)
}
pub(super) fn has_previous_a2ui_surface(&self) -> bool {
self.a2ui_history_index
.map_or(!self.a2ui_history.is_empty(), |index| index > 0)
}
pub(super) fn has_next_a2ui_surface(&self) -> bool {
self.a2ui_history_index.is_some_and(|index| {
index + 1 < self.a2ui_history.len() || self.a2ui.active_surface().is_some()
})
}
pub(super) fn change_a2ui_data(
&mut self,
surface_id: String,
path: String,
value: serde_json::Value,
) -> Task<Message> {
if self.a2ui_history_index.is_some() {
return Task::none();
}
let owner = self
.a2ui
.surface(&surface_id)

View File

@@ -39,6 +39,8 @@ const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg");
const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg");
const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.svg");
const ICON_ARROW_LEFT: &[u8] = include_bytes!("../../assets/icons/arrow-left.svg");
const ICON_ARROW_RIGHT: &[u8] = include_bytes!("../../assets/icons/arrow-right.svg");
/// Height of the strip the window content shares with the native title bar.
/// Keep it close to the 28pt macOS title bar so our controls line up with the
@@ -70,6 +72,7 @@ impl App {
|| self.pending_project_path.is_some()
|| self.session_rename.is_some()
|| self.menu_session().is_some()
|| self.pending_a2ui_dismissal.is_some()
|| !self.a2ui_modals.is_empty()
|| {
#[cfg(target_os = "macos")]
@@ -135,6 +138,8 @@ impl App {
layers.push(self.rename_dialog(title));
} else if let Some(session) = self.menu_session() {
layers.push(self.session_menu_panel(session));
} else if let Some(surface_id) = &self.pending_a2ui_dismissal {
layers.push(self.a2ui_dismiss_panel(surface_id));
} else if let Some(panel) = self.a2ui_modal_panel() {
layers.push(panel);
}
@@ -147,6 +152,8 @@ impl App {
layers.push(self.rename_dialog(title));
} else if let Some(session) = self.menu_session() {
layers.push(self.session_menu_panel(session));
} else if let Some(surface_id) = &self.pending_a2ui_dismissal {
layers.push(self.a2ui_dismiss_panel(surface_id));
} else if let Some(panel) = self.a2ui_modal_panel() {
layers.push(panel);
}
@@ -604,6 +611,37 @@ impl App {
)
}
fn a2ui_dismiss_panel<'a>(&self, surface_id: &'a str) -> Element<'a, Message> {
let dialog = container(
column![
text("Dismiss A2UI surface?").size(24),
text(format!(
"The `{surface_id}` surface will be hidden permanently. The next generated UI starts as a new surface."
))
.size(14),
row![
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Dismiss surface").on_press(Message::ConfirmA2uiDismiss),
]
.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))
}),
)
}
pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
self.projects
.iter()

View File

@@ -10,7 +10,7 @@ impl App {
let mut markdown = HashMap::new();
let mut editors = HashMap::new();
let mut filters = HashSet::new();
for surface in self.a2ui.surfaces() {
if let Some(surface) = self.displayed_a2ui_store().active_surface() {
collect_renderer_state(
surface,
"root",
@@ -44,12 +44,10 @@ impl App {
.retain(|key, _| filters.contains(key));
}
pub(super) fn a2ui_surfaces(&self, message_id: i32) -> Element<'_, Message> {
let mut surfaces = column![].spacing(10);
for surface in self.a2ui.surfaces_for_message(message_id) {
surfaces = surfaces.push(self.a2ui_surface(surface));
}
surfaces.into()
pub(super) fn displayed_a2ui_surface(&self) -> Option<Element<'_, Message>> {
self.displayed_a2ui_store()
.active_surface()
.map(|surface| self.a2ui_surface(surface))
}
fn a2ui_surface<'a>(&'a self, surface: &'a Surface) -> Element<'a, Message> {
@@ -66,19 +64,44 @@ impl App {
.color(muted_text())
.into()
};
let dismiss = action_button(text("Dismiss").size(11)).padding([5, 9]);
let dismiss = if self.generating || self.a2ui_history_index.is_some() {
dismiss
} else {
dismiss.on_press(Message::RequestA2uiDismiss(surface.id.clone()))
};
let previous = action_button(icon(ICON_ARROW_LEFT, 15)).padding(5);
let previous = if self.has_previous_a2ui_surface() {
previous.on_press(Message::A2uiPreviousSurface)
} else {
previous
};
let next = action_button(icon(ICON_ARROW_RIGHT, 15)).padding(5);
let next = if self.has_next_a2ui_surface() {
next.on_press(Message::A2uiNextSurface)
} else {
next
};
container(
column![
row![
previous,
next,
text(agent).size(10).color(muted_text()),
Space::new().width(Length::Fill),
text(&surface.id).size(10).color(muted_text()),
],
content,
dismiss,
]
.spacing(8)
.align_y(Alignment::Center),
scrollable(content).height(Length::Fill),
]
.height(Length::Fill)
.spacing(10),
)
.padding(14)
.width(Length::Fill)
.height(Length::Fill)
.style(preference_group_style)
.into()
}
@@ -856,7 +879,7 @@ impl App {
pub(super) fn a2ui_modal_panel(&self) -> Option<Element<'_, Message>> {
let (surface_id, component_id) = self.a2ui_modals.iter().next()?;
let surface = self.a2ui.surface(surface_id)?;
let surface = self.displayed_a2ui_store().surface(surface_id)?;
let component = surface.components.get(component_id)?.as_object()?;
let content_id = component.get("content")?.as_str()?;
let content =

View File

@@ -179,9 +179,6 @@ impl App {
if !cards.is_empty() {
body = body.push(tool_cards(cards));
}
if self.a2ui.surfaces_for_message(message.id).next().is_some() {
body = body.push(self.a2ui_surfaces(message.id));
}
}
let user = message.user;
messages = messages.push(
@@ -293,10 +290,24 @@ impl App {
.spacing(6)
.align_y(Alignment::Center),
);
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![
scrollable(messages)
.id(chat_scroll_id())
.height(Length::Fill),
workspace,
container(composer_content,)
.padding(16)
.width(Length::Fill)