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, } }