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

@@ -55,6 +55,13 @@ Video posters render in the surface; video and audio playback uses native
macOS AVKit player windows with standard transport, volume, and fullscreen macOS AVKit player windows with standard transport, volume, and fullscreen
controls. controls.
The newest A2UI surface is pinned above the transcript in a default 50/50
workspace split. Dismissing it requires confirmation and records a durable
boundary in the chat database, so reopening the session restores only the
newest surface created after the last dismissal. Left and right arrow buttons
reconstruct earlier and later read-only surface epochs from the recorded A2UI
protocol calls; model updates remain routed only to the current surface.
### Live A2UI validation ### Live A2UI validation
With DS4Server running and its local endpoint enabled, run the opt-in model With DS4Server running and its local endpoint enabled, run the opt-in model

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M11.75 4.5 6.25 10l5.5 5.5"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="m8.25 4.5 5.5 5.5-5.5 5.5"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -0,0 +1 @@
ALTER TABLE a2ui_messages DROP COLUMN dismissed;

View File

@@ -0,0 +1,2 @@
ALTER TABLE a2ui_messages
ADD COLUMN dismissed BOOLEAN NOT NULL DEFAULT 0;

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`. 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: 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. - 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)] #[derive(Clone, Default)]
pub(crate) struct Store { pub(crate) struct Store {
surfaces: BTreeMap<String, Surface>, surfaces: BTreeMap<String, Surface>,
surface_order: Vec<String>,
pending_actions: BTreeMap<String, (String, Option<String>)>, pending_actions: BTreeMap<String, (String, Option<String>)>,
next_action_id: u64, next_action_id: u64,
} }
@@ -115,6 +116,25 @@ pub(crate) struct Applied {
pub(crate) open_url: Option<String>, 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> { pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet<String>, String> {
fn visit( fn visit(
surface: &Surface, surface: &Surface,
@@ -188,16 +208,11 @@ pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet
impl Store { impl Store {
pub(crate) fn clear(&mut self) { pub(crate) fn clear(&mut self) {
self.surfaces.clear(); self.surfaces.clear();
self.surface_order.clear();
self.pending_actions.clear(); self.pending_actions.clear();
self.next_action_id = 0; 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> { pub(crate) fn surfaces(&self) -> impl Iterator<Item = &Surface> {
self.surfaces.values() self.surfaces.values()
} }
@@ -206,8 +221,14 @@ impl Store {
self.surfaces.get(id) 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> + '_ { 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| { surface.components.values().filter_map(|component| {
let component = component.as_object()?; let component = component.as_object()?;
let field = match component.get("component").and_then(Value::as_str) { let field = match component.get("component").and_then(Value::as_str) {
@@ -342,6 +363,7 @@ impl Store {
owner_message_id, owner_message_id,
}, },
); );
self.surface_order.push(id.to_owned());
let surface = self.surfaces.get_mut(id).unwrap(); let surface = self.surfaces.get_mut(id).unwrap();
if let Some(data) = payload.get("dataModel") { if let Some(data) = payload.get("dataModel") {
if !data.is_object() { if !data.is_object() {
@@ -419,6 +441,7 @@ impl Store {
if self.surfaces.remove(id).is_none() { if self.surfaces.remove(id).is_none() {
return Err(format!("surface `{id}` has not been created")); return Err(format!("surface `{id}` has not been created"));
} }
self.surface_order.retain(|surface_id| surface_id != id);
} }
"callFunction" => { "callFunction" => {
let call_id = required_string(envelope, "functionCallId")?; let call_id = required_string(envelope, "functionCallId")?;
@@ -595,8 +618,8 @@ impl Store {
pub(crate) fn client_metadata(&self) -> Value { pub(crate) fn client_metadata(&self) -> Value {
let surfaces = self let surfaces = self
.surfaces .active_surface()
.values() .into_iter()
.filter(|surface| surface.send_data_model) .filter(|surface| surface.send_data_model)
.map(|surface| (surface.id.clone(), surface.data.clone())) .map(|surface| (surface.id.clone(), surface.data.clone()))
.collect::<Map<_, _>>(); .collect::<Map<_, _>>();
@@ -608,12 +631,10 @@ impl Store {
} }
} }
}); });
if !surfaces.is_empty() {
metadata["a2uiClientDataModel"] = json!({ metadata["a2uiClientDataModel"] = json!({
"version": VERSION, "version": VERSION,
"surfaces": surfaces, "surfaces": surfaces,
}); });
}
metadata metadata
} }
} }
@@ -1981,6 +2002,57 @@ mod tests {
assert!(store.surface("s").is_none()); 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] #[test]
fn catalog_validation_and_actions_use_current_local_data() { fn catalog_validation_and_actions_use_current_local_data() {
let mut store = Store::default(); let mut store = Store::default();

View File

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

View File

@@ -307,10 +307,16 @@ impl App {
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let model_prompt = if self.config.a2ui_enabled { let model_prompt = if self.config.a2ui_enabled {
format!( let mut prompt = format!(
"{prompt}\n\nA2UI client metadata:\n{}", "{prompt}\n\nA2UI client metadata:\n{}",
self.a2ui.client_metadata() 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 { } else {
prompt.clone() prompt.clone()
}; };
@@ -652,8 +658,7 @@ impl App {
self.generating = false; self.generating = false;
self.activity = Some("Correcting A2UI…".into()); self.activity = Some("Correcting A2UI…".into());
self.tool_cards.clear(); self.tool_cards.clear();
a2ui_feedback = Some( let mut feedback = serde_json::json!({
serde_json::json!({
"version": crate::a2ui::VERSION, "version": crate::a2ui::VERSION,
"error": { "error": {
"code": "VALIDATION_FAILED", "code": "VALIDATION_FAILED",
@@ -662,8 +667,13 @@ impl App {
"message": validation_errors.join("; ") "message": validation_errors.join("; ")
} }
}) })
.to_string(), .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; self.active_generation = None;
break; break;
} }

View File

@@ -3,6 +3,8 @@ use super::*;
impl App { impl App {
pub(super) fn clear_a2ui(&mut self) { pub(super) fn clear_a2ui(&mut self) {
self.a2ui.clear(); self.a2ui.clear();
self.a2ui_history.clear();
self.a2ui_history_index = None;
self.a2ui_tabs.clear(); self.a2ui_tabs.clear();
self.a2ui_modals.clear(); self.a2ui_modals.clear();
self.a2ui_editors.clear(); self.a2ui_editors.clear();
@@ -11,15 +13,16 @@ impl App {
self.a2ui_images.clear(); self.a2ui_images.clear();
self.a2ui_image_requests.clear(); self.a2ui_image_requests.clear();
self.a2ui_image_loading = false; self.a2ui_image_loading = false;
self.pending_a2ui_dismissal = None;
} }
pub(super) fn load_next_a2ui_image(&mut self) -> Task<Message> { pub(super) fn load_next_a2ui_image(&mut self) -> Task<Message> {
if self.a2ui_image_loading { if self.a2ui_image_loading {
return Task::none(); return Task::none();
} }
let Some(url) = self let urls = self.displayed_a2ui_store().image_urls().collect::<Vec<_>>();
.a2ui let Some(url) = urls
.image_urls() .into_iter()
.find(|url| !self.a2ui_image_requests.contains(url)) .find(|url| !self.a2ui_image_requests.contains(url))
else { else {
return Task::none(); 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( pub(super) fn change_a2ui_data(
&mut self, &mut self,
surface_id: String, surface_id: String,
path: String, path: String,
value: serde_json::Value, value: serde_json::Value,
) -> Task<Message> { ) -> Task<Message> {
if self.a2ui_history_index.is_some() {
return Task::none();
}
let owner = self let owner = self
.a2ui .a2ui
.surface(&surface_id) .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_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg");
const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg"); const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg");
const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.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. /// 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 /// 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.pending_project_path.is_some()
|| self.session_rename.is_some() || self.session_rename.is_some()
|| self.menu_session().is_some() || self.menu_session().is_some()
|| self.pending_a2ui_dismissal.is_some()
|| !self.a2ui_modals.is_empty() || !self.a2ui_modals.is_empty()
|| { || {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -135,6 +138,8 @@ impl App {
layers.push(self.rename_dialog(title)); layers.push(self.rename_dialog(title));
} else if let Some(session) = self.menu_session() { } else if let Some(session) = self.menu_session() {
layers.push(self.session_menu_panel(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() { } else if let Some(panel) = self.a2ui_modal_panel() {
layers.push(panel); layers.push(panel);
} }
@@ -147,6 +152,8 @@ impl App {
layers.push(self.rename_dialog(title)); layers.push(self.rename_dialog(title));
} else if let Some(session) = self.menu_session() { } else if let Some(session) = self.menu_session() {
layers.push(self.session_menu_panel(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() { } else if let Some(panel) = self.a2ui_modal_panel() {
layers.push(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> { pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
self.projects self.projects
.iter() .iter()

View File

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

View File

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

View File

@@ -145,6 +145,7 @@ pub struct StoredA2uiMessage {
pub session_id: i32, pub session_id: i32,
pub message_id: i32, pub message_id: i32,
pub json: String, pub json: String,
pub dismissed: bool,
} }
#[derive(Insertable)] #[derive(Insertable)]
@@ -153,6 +154,7 @@ struct NewA2uiMessage<'a> {
session_id: i32, session_id: i32,
message_id: i32, message_id: i32,
json: &'a str, json: &'a str,
dismissed: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -344,12 +346,51 @@ impl Database {
session_id, session_id,
message_id, message_id,
json, json,
dismissed: false,
}) })
.returning(StoredA2uiMessage::as_returning()) .returning(StoredA2uiMessage::as_returning())
.get_result(&mut self.connection) .get_result(&mut self.connection)
.map_err(|error| error.to_string()) .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( pub fn update_session_context(
&mut self, &mut self,
session_id: i32, session_id: i32,
@@ -672,6 +713,86 @@ mod tests {
fs::remove_file(path).unwrap(); 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] #[test]
fn chat_and_compaction_history_survive_reopen_and_session_deletion() { fn chat_and_compaction_history_survive_reopen_and_session_deletion() {
let id = SystemTime::now() let id = SystemTime::now()

View File

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