prev/next A2UI surface and better dismissal
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user