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

@@ -71,7 +71,7 @@ const FUNCTIONS: &[&str] = &[
pub(crate) const SYSTEM_PROMPT: &str = r#"A2UI local-chat rendering is enabled. Use the newest A2UI v1.0 protocol. You may present interactive native UI by emitting newline-delimited messages inside a fenced `a2ui` block. Keep ordinary prose outside the block. Use catalogId `https://ds4server.local/a2ui/v1_0/catalog.json`.
Every line must be one JSON object with `version":"v1.0"` and exactly one of `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`, `callFunction`, or `actionResponse`. Create a surface before updating it. Components are a flat adjacency list and the root component has id `root`. Compose complete UIs by combining basic components through container child ids; include every referenced child, tab child, and list template component. Reuse a surfaceId to update it incrementally; never recreate an existing surface. `createSurface` may include initial `components`, `dataModel`, and `surfaceProperties`. For `actionResponse`, put `actionId` beside `version` and put only `value` or `error` inside `actionResponse`.
Every line must be one JSON object with `version":"v1.0"` and exactly one of `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`, `callFunction`, or `actionResponse`. Create a surface before updating it. Treat the current client metadata as authoritative: if its surfaces object is empty, prior surfaces in chat history were dismissed and you must create a new surface instead of updating them. Components are a flat adjacency list and the root component has id `root`. Compose complete UIs by combining basic components through container child ids; include every referenced child, tab child, and list template component. Reuse the active surfaceId to update it incrementally; never recreate an existing surface. `createSurface` may include initial `components`, `dataModel`, and `surfaceProperties`. For `actionResponse`, put `actionId` beside `version` and put only `value` or `error` inside `actionResponse`.
Catalog components:
- Basic: Text(text Markdown,variant), Image(url,description,fit,variant including avatar), Icon(name), Video(url,posterUrl), AudioPlayer(url,description), Divider(axis), Row/Column(children,justify start|center|end|spaceBetween|spaceAround|spaceEvenly|stretch,align start|center|end|stretch), List(children,direction,align), Card(child), Modal(trigger,content), Tabs(tabs[{title,child}]), Button(child,variant,action:{event:{name,context,wantResponse}}), TextField(label,value:{path},variant shortText|longText|number|obscured,placeholder), CheckBox(label,value:{path}), Slider(value:{path},min,max), DateTimeInput(label,value:{path},enableDate,enableTime,min,max), ChoicePicker(label,options[{label,value}],value:{path},variant multipleSelection|mutuallyExclusive,displayStyle checkbox|chips,filterable). Put numeric weight on direct Row/Column children to distribute available space.
@@ -98,6 +98,7 @@ pub(crate) struct Surface {
#[derive(Clone, Default)]
pub(crate) struct Store {
surfaces: BTreeMap<String, Surface>,
surface_order: Vec<String>,
pending_actions: BTreeMap<String, (String, Option<String>)>,
next_action_id: u64,
}
@@ -115,6 +116,25 @@ pub(crate) struct Applied {
pub(crate) open_url: Option<String>,
}
pub(crate) fn replay_epochs<'a>(
records: impl IntoIterator<Item = (i32, i32, bool, &'a str)>,
) -> (Vec<Store>, Store, Vec<String>) {
let mut history = Vec::new();
let mut active = Store::default();
let mut errors = Vec::new();
for (record_id, message_id, dismissed, raw) in records {
if dismissed {
if active.active_surface().is_some() {
history.push(active.clone());
}
active.clear();
} else if let Err(error) = active.apply_raw(raw, message_id) {
errors.push(format!("A2UI message {record_id}: {error}"));
}
}
(history, active, errors)
}
pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet<String>, String> {
fn visit(
surface: &Surface,
@@ -188,16 +208,11 @@ pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet
impl Store {
pub(crate) fn clear(&mut self) {
self.surfaces.clear();
self.surface_order.clear();
self.pending_actions.clear();
self.next_action_id = 0;
}
pub(crate) fn surfaces_for_message(&self, message_id: i32) -> impl Iterator<Item = &Surface> {
self.surfaces
.values()
.filter(move |surface| surface.owner_message_id == message_id)
}
pub(crate) fn surfaces(&self) -> impl Iterator<Item = &Surface> {
self.surfaces.values()
}
@@ -206,8 +221,14 @@ impl Store {
self.surfaces.get(id)
}
pub(crate) fn active_surface(&self) -> Option<&Surface> {
self.surface_order
.last()
.and_then(|id| self.surfaces.get(id))
}
pub(crate) fn image_urls(&self) -> impl Iterator<Item = String> + '_ {
self.surfaces.values().flat_map(|surface| {
self.active_surface().into_iter().flat_map(|surface| {
surface.components.values().filter_map(|component| {
let component = component.as_object()?;
let field = match component.get("component").and_then(Value::as_str) {
@@ -342,6 +363,7 @@ impl Store {
owner_message_id,
},
);
self.surface_order.push(id.to_owned());
let surface = self.surfaces.get_mut(id).unwrap();
if let Some(data) = payload.get("dataModel") {
if !data.is_object() {
@@ -419,6 +441,7 @@ impl Store {
if self.surfaces.remove(id).is_none() {
return Err(format!("surface `{id}` has not been created"));
}
self.surface_order.retain(|surface_id| surface_id != id);
}
"callFunction" => {
let call_id = required_string(envelope, "functionCallId")?;
@@ -595,8 +618,8 @@ impl Store {
pub(crate) fn client_metadata(&self) -> Value {
let surfaces = self
.surfaces
.values()
.active_surface()
.into_iter()
.filter(|surface| surface.send_data_model)
.map(|surface| (surface.id.clone(), surface.data.clone()))
.collect::<Map<_, _>>();
@@ -608,12 +631,10 @@ impl Store {
}
}
});
if !surfaces.is_empty() {
metadata["a2uiClientDataModel"] = json!({
"version": VERSION,
"surfaces": surfaces,
});
}
metadata["a2uiClientDataModel"] = json!({
"version": VERSION,
"surfaces": surfaces,
});
metadata
}
}
@@ -1981,6 +2002,57 @@ mod tests {
assert!(store.surface("s").is_none());
}
#[test]
fn latest_created_surface_is_the_active_surface() {
let mut store = Store::default();
for id in ["first", "second"] {
apply(
&mut store,
json!({"version":VERSION,"createSurface":{"surfaceId":id,"catalogId":CATALOG_ID,"sendDataModel":true,"dataModel":{"id":id}}}),
)
.unwrap();
}
assert_eq!(store.active_surface().unwrap().id, "second");
let metadata = store.client_metadata();
assert_eq!(
metadata.pointer("/a2uiClientDataModel/surfaces/second/id"),
Some(&json!("second"))
);
assert!(
metadata
.pointer("/a2uiClientDataModel/surfaces/first")
.is_none()
);
apply(
&mut store,
json!({"version":VERSION,"deleteSurface":{"surfaceId":"second"}}),
)
.unwrap();
assert_eq!(store.active_surface().unwrap().id, "first");
}
#[test]
fn dismissal_boundaries_replay_history_and_a_fresh_active_epoch() {
let first = json!({"version":VERSION,"createSurface":{"surfaceId":"first","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"First"}]}}).to_string();
let update = json!({"version":VERSION,"updateComponents":{"surfaceId":"first","components":[{"id":"root","component":"Text","text":"First final"}]}}).to_string();
let second = json!({"version":VERSION,"createSurface":{"surfaceId":"second","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"Second"}]}}).to_string();
let records = [
(1, 10, false, first.as_str()),
(2, 11, false, update.as_str()),
(3, 12, true, "{}"),
(4, 13, false, second.as_str()),
];
let (history, active, errors) = replay_epochs(records);
assert!(errors.is_empty());
assert_eq!(history.len(), 1);
assert_eq!(history[0].active_surface().unwrap().id, "first");
assert_eq!(
history[0].active_surface().unwrap().components["root"]["text"],
"First final"
);
assert_eq!(active.active_surface().unwrap().id, "second");
}
#[test]
fn catalog_validation_and_actions_use_current_local_data() {
let mut store = Store::default();

View File

@@ -79,6 +79,8 @@ pub(crate) struct App {
pub(super) queued_inputs: VecDeque<String>,
pub(super) conversation: Vec<ChatMessage>,
pub(super) a2ui: crate::a2ui::Store,
pub(super) a2ui_history: Vec<crate::a2ui::Store>,
pub(super) a2ui_history_index: Option<usize>,
pub(super) a2ui_tabs: HashMap<(String, String), usize>,
pub(super) a2ui_modals: HashSet<(String, String)>,
pub(super) a2ui_editors: HashMap<(String, String, String), text_editor::Content>,
@@ -87,6 +89,7 @@ pub(crate) struct App {
pub(super) a2ui_images: HashMap<String, iced::widget::image::Handle>,
pub(super) a2ui_image_requests: HashSet<String>,
pub(super) a2ui_image_loading: bool,
pub(super) pending_a2ui_dismissal: Option<String>,
pub(super) generating: bool,
pub(super) context_used: u32,
pub(super) context_limit: u32,
@@ -217,6 +220,10 @@ pub(crate) enum Message {
A2uiToggleModal(String, String),
A2uiImageLoaded(String, Result<Vec<u8>, String>),
A2uiPlayMedia(String, String, bool),
RequestA2uiDismiss(String),
ConfirmA2uiDismiss,
A2uiPreviousSurface,
A2uiNextSurface,
ResetPreferences,
SavePreferences,
DownloadArtifact(ManagedArtifactId),
@@ -328,6 +335,8 @@ impl App {
queued_inputs: VecDeque::new(),
conversation: Vec::new(),
a2ui: crate::a2ui::Store::default(),
a2ui_history: Vec::new(),
a2ui_history_index: None,
a2ui_tabs: HashMap::new(),
a2ui_modals: HashSet::new(),
a2ui_editors: HashMap::new(),
@@ -336,6 +345,7 @@ impl App {
a2ui_images: HashMap::new(),
a2ui_image_requests: HashSet::new(),
a2ui_image_loading: false,
pending_a2ui_dismissal: None,
generating: false,
context_used: 0,
context_limit,
@@ -443,6 +453,8 @@ impl App {
queued_inputs: VecDeque::new(),
conversation: Vec::new(),
a2ui: crate::a2ui::Store::default(),
a2ui_history: Vec::new(),
a2ui_history_index: None,
a2ui_tabs: HashMap::new(),
a2ui_modals: HashSet::new(),
a2ui_editors: HashMap::new(),
@@ -451,6 +463,7 @@ impl App {
a2ui_images: HashMap::new(),
a2ui_image_requests: HashSet::new(),
a2ui_image_loading: false,
pending_a2ui_dismissal: None,
generating: false,
context_used: 0,
context_limit,
@@ -538,7 +551,9 @@ impl App {
}
}
Message::DismissPanel => {
if self.session_rename.is_some() || self.session_menu.is_some() {
if self.pending_a2ui_dismissal.is_some() {
self.pending_a2ui_dismissal = None;
} else if self.session_rename.is_some() || self.session_menu.is_some() {
self.session_rename = None;
self.session_menu = None;
} else if self.preferences_open {
@@ -843,9 +858,15 @@ impl App {
Message::DownloadProgressTick => self.update_download_progress(),
Message::ComposerChanged(value) => self.composer = value,
Message::A2uiDataChanged(surface_id, path, value) => {
if self.a2ui_history_index.is_some() {
return Task::none();
}
return self.change_a2ui_data(surface_id, path, value);
}
Message::A2uiEditorAction(surface_id, component_id, path, action) => {
if self.a2ui_history_index.is_some() {
return Task::none();
}
let key = (surface_id.clone(), component_id, path.clone());
let Some(editor) = self.a2ui_editors.get_mut(&key) else {
return Task::none();
@@ -859,6 +880,9 @@ impl App {
.insert((surface_id, component_id, context_path), value);
}
Message::A2uiAction(surface_id, component_id, context_path) => {
if self.a2ui_history_index.is_some() {
return Task::none();
}
match self
.a2ui
.action(&surface_id, &component_id, context_path.as_deref())
@@ -907,6 +931,68 @@ impl App {
self.error = Some(format!("Could not open media: {error}"));
}
}
Message::RequestA2uiDismiss(surface_id) => {
if self.generating {
self.error = Some(
"Stop the active generation before dismissing its A2UI surface.".into(),
);
} else if self.a2ui_history_index.is_none()
&& self.a2ui.surface(&surface_id).is_some()
{
self.pending_a2ui_dismissal = Some(surface_id);
}
}
Message::ConfirmA2uiDismiss => {
let Some(surface_id) = self.pending_a2ui_dismissal.clone() else {
return Task::none();
};
let Some(session_id) = self.selected_session else {
self.error = Some("The A2UI surface is not attached to a saved chat.".into());
return Task::none();
};
let Some(database) = &mut self.database else {
self.error = Some("The project database is unavailable.".into());
return Task::none();
};
match database.dismiss_a2ui_surface(session_id, &surface_id) {
Ok(message) => {
self.conversation.push(ChatMessage::from(message));
self.a2ui_history.push(self.a2ui.clone());
self.a2ui.clear();
self.a2ui_history_index = None;
self.pending_a2ui_dismissal = None;
self.a2ui_tabs.clear();
self.a2ui_modals.clear();
self.sync_a2ui_renderer_state();
self.error = None;
}
Err(error) => {
self.error = Some(format!("Could not dismiss A2UI surface: {error}"));
}
}
}
Message::A2uiPreviousSurface => {
if !self.a2ui_history.is_empty() {
self.a2ui_history_index = Some(
self.a2ui_history_index
.map_or(self.a2ui_history.len() - 1, |index| index.saturating_sub(1)),
);
self.a2ui_tabs.clear();
self.a2ui_modals.clear();
self.sync_a2ui_renderer_state();
return self.load_next_a2ui_image();
}
}
Message::A2uiNextSurface => {
if let Some(index) = self.a2ui_history_index {
self.a2ui_history_index =
(index + 1 < self.a2ui_history.len()).then_some(index + 1);
self.a2ui_tabs.clear();
self.a2ui_modals.clear();
self.sync_a2ui_renderer_state();
return self.load_next_a2ui_image();
}
}
Message::ToggleReasoning(index) => {
if let Some(message) = self.conversation.get_mut(index)
&& message.reasoning.is_some()
@@ -1220,16 +1306,20 @@ impl App {
Ok((messages, a2ui)) => {
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
self.clear_a2ui();
for message in a2ui {
if let Err(error) =
self.a2ui.apply_raw(&message.json, message.message_id)
{
self.error = Some(format!(
"Could not restore A2UI message {}: {error}",
message.id
));
}
}
let (history, active, errors) =
crate::a2ui::replay_epochs(a2ui.iter().map(|message| {
(
message.id,
message.message_id,
message.dismissed,
message.json.as_str(),
)
}));
self.a2ui_history = history;
self.a2ui = active;
let restore_error = (!errors.is_empty()).then(|| {
format!("Could not restore some A2UI history: {}", errors.join("; "))
});
self.sync_a2ui_renderer_state();
self.composer.clear();
self.remember_project(project_id);
@@ -1244,7 +1334,7 @@ impl App {
self.config.generation.context_tokens.max(0) as u32
};
self.tokens_per_second = tokens_per_second;
self.error = None;
self.error = restore_error;
return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]);
}
Err(error) => {

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)

View File

@@ -145,6 +145,7 @@ pub struct StoredA2uiMessage {
pub session_id: i32,
pub message_id: i32,
pub json: String,
pub dismissed: bool,
}
#[derive(Insertable)]
@@ -153,6 +154,7 @@ struct NewA2uiMessage<'a> {
session_id: i32,
message_id: i32,
json: &'a str,
dismissed: bool,
}
#[derive(Debug)]
@@ -344,12 +346,51 @@ impl Database {
session_id,
message_id,
json,
dismissed: false,
})
.returning(StoredA2uiMessage::as_returning())
.get_result(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn dismiss_a2ui_surface(
&mut self,
session_id: i32,
surface_id: &str,
) -> Result<StoredMessage, String> {
let content = format!(
"A2UI surface `{surface_id}` was dismissed by the user. There is no active A2UI surface. If the user asks for UI again, create a complete new surface with createSurface and a new surfaceId; do not update any earlier surface."
);
let json = serde_json::json!({"surfaceId": surface_id}).to_string();
self.connection
.transaction(|connection| {
let message = diesel::insert_into(messages::table)
.values(NewMessage {
session_id,
user: false,
tool: false,
reasoning: None,
reasoning_complete: true,
content: &content,
system: true,
compaction: false,
compaction_tail_start: None,
})
.returning(StoredMessage::as_returning())
.get_result(connection)?;
diesel::insert_into(a2ui_messages::table)
.values(NewA2uiMessage {
session_id,
message_id: message.id,
json: &json,
dismissed: true,
})
.execute(connection)?;
Ok(message)
})
.map_err(|error: diesel::result::Error| error.to_string())
}
pub fn update_session_context(
&mut self,
session_id: i32,
@@ -672,6 +713,86 @@ mod tests {
fs::remove_file(path).unwrap();
}
#[test]
fn a2ui_dismissal_persists_a_fresh_surface_boundary() {
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("ds4-a2ui-dismiss-{id}.sqlite3"));
let mut database = Database::open(&path).unwrap();
let project = database
.create_project("DS4", "/tmp/ds4-a2ui-dismiss")
.unwrap();
let session = database.create_session(project.id, "A2UI").unwrap();
let first = database
.start_chat_turn(session.id, "First", &[], false)
.unwrap()
.pop()
.unwrap();
database
.insert_a2ui_message(
session.id,
first.id,
r#"{"version":"v1.0","createSurface":{"surfaceId":"first","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}}"#,
)
.unwrap();
let dismissal = database.dismiss_a2ui_surface(session.id, "first").unwrap();
assert!(dismissal.system);
assert!(dismissal.content.contains("createSurface"));
let protocol = database.load_a2ui_messages(session.id).unwrap();
let (_, active, errors) = crate::a2ui::replay_epochs(protocol.iter().map(|message| {
(
message.id,
message.message_id,
message.dismissed,
message.json.as_str(),
)
}));
assert!(errors.is_empty());
assert!(active.active_surface().is_none());
let second = database
.start_chat_turn(session.id, "Second", &[], false)
.unwrap()
.pop()
.unwrap();
database
.insert_a2ui_message(
session.id,
second.id,
r#"{"version":"v1.0","createSurface":{"surfaceId":"second","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}}"#,
)
.unwrap();
drop(database);
let mut reopened = Database::open(&path).unwrap();
let protocol = reopened.load_a2ui_messages(session.id).unwrap();
assert_eq!(protocol.len(), 3);
assert!(protocol[1].dismissed);
assert_eq!(protocol[1].message_id, dismissal.id);
let (_, active, errors) = crate::a2ui::replay_epochs(protocol.iter().map(|message| {
(
message.id,
message.message_id,
message.dismissed,
message.json.as_str(),
)
}));
assert!(errors.is_empty());
assert_eq!(active.active_surface().unwrap().id, "second");
let messages = reopened.load_messages(session.id).unwrap();
assert!(
messages
.iter()
.position(|message| message.id == dismissal.id)
.is_some_and(|index| messages[index + 1].content == "Second")
);
reopened.delete_project(project.id).unwrap();
drop(reopened);
fs::remove_file(path).unwrap();
}
#[test]
fn chat_and_compaction_history_survive_reopen_and_session_deletion() {
let id = SystemTime::now()

View File

@@ -4,6 +4,7 @@ diesel::table! {
session_id -> Integer,
message_id -> Integer,
json -> Text,
dismissed -> Bool,
}
}