diff --git a/README.md b/README.md
index b716832..8c287d4 100644
--- a/README.md
+++ b/README.md
@@ -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
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
With DS4Server running and its local endpoint enabled, run the opt-in model
diff --git a/assets/icons/arrow-left.svg b/assets/icons/arrow-left.svg
new file mode 100644
index 0000000..2bdaec4
--- /dev/null
+++ b/assets/icons/arrow-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/icons/arrow-right.svg b/assets/icons/arrow-right.svg
new file mode 100644
index 0000000..3872a50
--- /dev/null
+++ b/assets/icons/arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/migrations/20260727130000_add_a2ui_dismissal/down.sql b/migrations/20260727130000_add_a2ui_dismissal/down.sql
new file mode 100644
index 0000000..7a69309
--- /dev/null
+++ b/migrations/20260727130000_add_a2ui_dismissal/down.sql
@@ -0,0 +1 @@
+ALTER TABLE a2ui_messages DROP COLUMN dismissed;
diff --git a/migrations/20260727130000_add_a2ui_dismissal/up.sql b/migrations/20260727130000_add_a2ui_dismissal/up.sql
new file mode 100644
index 0000000..00c498e
--- /dev/null
+++ b/migrations/20260727130000_add_a2ui_dismissal/up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE a2ui_messages
+ADD COLUMN dismissed BOOLEAN NOT NULL DEFAULT 0;
diff --git a/src/a2ui.rs b/src/a2ui.rs
index 6add606..ed1e682 100644
--- a/src/a2ui.rs
+++ b/src/a2ui.rs
@@ -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,
+ surface_order: Vec,
pending_actions: BTreeMap)>,
next_action_id: u64,
}
@@ -115,6 +116,25 @@ pub(crate) struct Applied {
pub(crate) open_url: Option,
}
+pub(crate) fn replay_epochs<'a>(
+ records: impl IntoIterator- ,
+) -> (Vec
, Store, Vec) {
+ 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, String> {
fn visit(
surface: &Surface,
@@ -188,16 +208,11 @@ pub(crate) fn validate_surface_composition(surface: &Surface) -> Result impl Iterator- {
- self.surfaces
- .values()
- .filter(move |surface| surface.owner_message_id == message_id)
- }
-
pub(crate) fn surfaces(&self) -> impl Iterator
- {
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
- + '_ {
- 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::
>();
@@ -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();
diff --git a/src/app.rs b/src/app.rs
index d029de9..58b79b8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -79,6 +79,8 @@ pub(crate) struct App {
pub(super) queued_inputs: VecDeque,
pub(super) conversation: Vec,
pub(super) a2ui: crate::a2ui::Store,
+ pub(super) a2ui_history: Vec,
+ pub(super) a2ui_history_index: Option,
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,
pub(super) a2ui_image_requests: HashSet,
pub(super) a2ui_image_loading: bool,
+ pub(super) pending_a2ui_dismissal: Option,
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, 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) => {
diff --git a/src/app/generation.rs b/src/app/generation.rs
index 00849df..9757c36 100644
--- a/src/app/generation.rs
+++ b/src/app/generation.rs
@@ -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;
}
diff --git a/src/app/projects.rs b/src/app/projects.rs
index 13dbe0f..f3f236f 100644
--- a/src/app/projects.rs
+++ b/src/app/projects.rs
@@ -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 {
if self.a2ui_image_loading {
return Task::none();
}
- let Some(url) = self
- .a2ui
- .image_urls()
+ let urls = self.displayed_a2ui_store().image_urls().collect::>();
+ 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 {
+ if self.a2ui_history_index.is_some() {
+ return Task::none();
+ }
let owner = self
.a2ui
.surface(&surface_id)
diff --git a/src/app/view.rs b/src/app/view.rs
index 8006e2a..eef46ee 100644
--- a/src/app/view.rs
+++ b/src/app/view.rs
@@ -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()
diff --git a/src/app/view/a2ui.rs b/src/app/view/a2ui.rs
index d92b0bf..0450baa 100644
--- a/src/app/view/a2ui.rs
+++ b/src/app/view/a2ui.rs
@@ -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> {
+ 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> {
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 =
diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs
index ea63428..e95332c 100644
--- a/src/app/view/chat.rs
+++ b/src/app/view/chat.rs
@@ -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)
diff --git a/src/database.rs b/src/database.rs
index 7c4427b..5489767 100644
--- a/src/database.rs
+++ b/src/database.rs
@@ -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 {
+ 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()
diff --git a/src/schema.rs b/src/schema.rs
index bfaf338..28623d5 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -4,6 +4,7 @@ diesel::table! {
session_id -> Integer,
message_id -> Integer,
json -> Text,
+ dismissed -> Bool,
}
}