prev/next A2UI surface and better dismissal
This commit is contained in:
104
src/a2ui.rs
104
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<String, Surface>,
|
||||
surface_order: Vec<String>,
|
||||
pending_actions: BTreeMap<String, (String, Option<String>)>,
|
||||
next_action_id: u64,
|
||||
}
|
||||
@@ -115,6 +116,25 @@ pub(crate) struct Applied {
|
||||
pub(crate) open_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn replay_epochs<'a>(
|
||||
records: impl IntoIterator<Item = (i32, i32, bool, &'a str)>,
|
||||
) -> (Vec<Store>, Store, Vec<String>) {
|
||||
let mut history = Vec::new();
|
||||
let mut active = Store::default();
|
||||
let mut errors = Vec::new();
|
||||
for (record_id, message_id, dismissed, raw) in records {
|
||||
if dismissed {
|
||||
if active.active_surface().is_some() {
|
||||
history.push(active.clone());
|
||||
}
|
||||
active.clear();
|
||||
} else if let Err(error) = active.apply_raw(raw, message_id) {
|
||||
errors.push(format!("A2UI message {record_id}: {error}"));
|
||||
}
|
||||
}
|
||||
(history, active, errors)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet<String>, String> {
|
||||
fn visit(
|
||||
surface: &Surface,
|
||||
@@ -188,16 +208,11 @@ pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet
|
||||
impl Store {
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.surfaces.clear();
|
||||
self.surface_order.clear();
|
||||
self.pending_actions.clear();
|
||||
self.next_action_id = 0;
|
||||
}
|
||||
|
||||
pub(crate) fn surfaces_for_message(&self, message_id: i32) -> impl Iterator<Item = &Surface> {
|
||||
self.surfaces
|
||||
.values()
|
||||
.filter(move |surface| surface.owner_message_id == message_id)
|
||||
}
|
||||
|
||||
pub(crate) fn surfaces(&self) -> impl Iterator<Item = &Surface> {
|
||||
self.surfaces.values()
|
||||
}
|
||||
@@ -206,8 +221,14 @@ impl Store {
|
||||
self.surfaces.get(id)
|
||||
}
|
||||
|
||||
pub(crate) fn active_surface(&self) -> Option<&Surface> {
|
||||
self.surface_order
|
||||
.last()
|
||||
.and_then(|id| self.surfaces.get(id))
|
||||
}
|
||||
|
||||
pub(crate) fn image_urls(&self) -> impl Iterator<Item = String> + '_ {
|
||||
self.surfaces.values().flat_map(|surface| {
|
||||
self.active_surface().into_iter().flat_map(|surface| {
|
||||
surface.components.values().filter_map(|component| {
|
||||
let component = component.as_object()?;
|
||||
let field = match component.get("component").and_then(Value::as_str) {
|
||||
@@ -342,6 +363,7 @@ impl Store {
|
||||
owner_message_id,
|
||||
},
|
||||
);
|
||||
self.surface_order.push(id.to_owned());
|
||||
let surface = self.surfaces.get_mut(id).unwrap();
|
||||
if let Some(data) = payload.get("dataModel") {
|
||||
if !data.is_object() {
|
||||
@@ -419,6 +441,7 @@ impl Store {
|
||||
if self.surfaces.remove(id).is_none() {
|
||||
return Err(format!("surface `{id}` has not been created"));
|
||||
}
|
||||
self.surface_order.retain(|surface_id| surface_id != id);
|
||||
}
|
||||
"callFunction" => {
|
||||
let call_id = required_string(envelope, "functionCallId")?;
|
||||
@@ -595,8 +618,8 @@ impl Store {
|
||||
|
||||
pub(crate) fn client_metadata(&self) -> Value {
|
||||
let surfaces = self
|
||||
.surfaces
|
||||
.values()
|
||||
.active_surface()
|
||||
.into_iter()
|
||||
.filter(|surface| surface.send_data_model)
|
||||
.map(|surface| (surface.id.clone(), surface.data.clone()))
|
||||
.collect::<Map<_, _>>();
|
||||
@@ -608,12 +631,10 @@ impl Store {
|
||||
}
|
||||
}
|
||||
});
|
||||
if !surfaces.is_empty() {
|
||||
metadata["a2uiClientDataModel"] = json!({
|
||||
"version": VERSION,
|
||||
"surfaces": surfaces,
|
||||
});
|
||||
}
|
||||
metadata["a2uiClientDataModel"] = json!({
|
||||
"version": VERSION,
|
||||
"surfaces": surfaces,
|
||||
});
|
||||
metadata
|
||||
}
|
||||
}
|
||||
@@ -1981,6 +2002,57 @@ mod tests {
|
||||
assert!(store.surface("s").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_created_surface_is_the_active_surface() {
|
||||
let mut store = Store::default();
|
||||
for id in ["first", "second"] {
|
||||
apply(
|
||||
&mut store,
|
||||
json!({"version":VERSION,"createSurface":{"surfaceId":id,"catalogId":CATALOG_ID,"sendDataModel":true,"dataModel":{"id":id}}}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(store.active_surface().unwrap().id, "second");
|
||||
let metadata = store.client_metadata();
|
||||
assert_eq!(
|
||||
metadata.pointer("/a2uiClientDataModel/surfaces/second/id"),
|
||||
Some(&json!("second"))
|
||||
);
|
||||
assert!(
|
||||
metadata
|
||||
.pointer("/a2uiClientDataModel/surfaces/first")
|
||||
.is_none()
|
||||
);
|
||||
apply(
|
||||
&mut store,
|
||||
json!({"version":VERSION,"deleteSurface":{"surfaceId":"second"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.active_surface().unwrap().id, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_boundaries_replay_history_and_a_fresh_active_epoch() {
|
||||
let first = json!({"version":VERSION,"createSurface":{"surfaceId":"first","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"First"}]}}).to_string();
|
||||
let update = json!({"version":VERSION,"updateComponents":{"surfaceId":"first","components":[{"id":"root","component":"Text","text":"First final"}]}}).to_string();
|
||||
let second = json!({"version":VERSION,"createSurface":{"surfaceId":"second","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"Second"}]}}).to_string();
|
||||
let records = [
|
||||
(1, 10, false, first.as_str()),
|
||||
(2, 11, false, update.as_str()),
|
||||
(3, 12, true, "{}"),
|
||||
(4, 13, false, second.as_str()),
|
||||
];
|
||||
let (history, active, errors) = replay_epochs(records);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].active_surface().unwrap().id, "first");
|
||||
assert_eq!(
|
||||
history[0].active_surface().unwrap().components["root"]["text"],
|
||||
"First final"
|
||||
);
|
||||
assert_eq!(active.active_surface().unwrap().id, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_validation_and_actions_use_current_local_data() {
|
||||
let mut store = Store::default();
|
||||
|
||||
114
src/app.rs
114
src/app.rs
@@ -79,6 +79,8 @@ pub(crate) struct App {
|
||||
pub(super) queued_inputs: VecDeque<String>,
|
||||
pub(super) conversation: Vec<ChatMessage>,
|
||||
pub(super) a2ui: crate::a2ui::Store,
|
||||
pub(super) a2ui_history: Vec<crate::a2ui::Store>,
|
||||
pub(super) a2ui_history_index: Option<usize>,
|
||||
pub(super) a2ui_tabs: HashMap<(String, String), usize>,
|
||||
pub(super) a2ui_modals: HashSet<(String, String)>,
|
||||
pub(super) a2ui_editors: HashMap<(String, String, String), text_editor::Content>,
|
||||
@@ -87,6 +89,7 @@ pub(crate) struct App {
|
||||
pub(super) a2ui_images: HashMap<String, iced::widget::image::Handle>,
|
||||
pub(super) a2ui_image_requests: HashSet<String>,
|
||||
pub(super) a2ui_image_loading: bool,
|
||||
pub(super) pending_a2ui_dismissal: Option<String>,
|
||||
pub(super) generating: bool,
|
||||
pub(super) context_used: u32,
|
||||
pub(super) context_limit: u32,
|
||||
@@ -217,6 +220,10 @@ pub(crate) enum Message {
|
||||
A2uiToggleModal(String, String),
|
||||
A2uiImageLoaded(String, Result<Vec<u8>, String>),
|
||||
A2uiPlayMedia(String, String, bool),
|
||||
RequestA2uiDismiss(String),
|
||||
ConfirmA2uiDismiss,
|
||||
A2uiPreviousSurface,
|
||||
A2uiNextSurface,
|
||||
ResetPreferences,
|
||||
SavePreferences,
|
||||
DownloadArtifact(ManagedArtifactId),
|
||||
@@ -328,6 +335,8 @@ impl App {
|
||||
queued_inputs: VecDeque::new(),
|
||||
conversation: Vec::new(),
|
||||
a2ui: crate::a2ui::Store::default(),
|
||||
a2ui_history: Vec::new(),
|
||||
a2ui_history_index: None,
|
||||
a2ui_tabs: HashMap::new(),
|
||||
a2ui_modals: HashSet::new(),
|
||||
a2ui_editors: HashMap::new(),
|
||||
@@ -336,6 +345,7 @@ impl App {
|
||||
a2ui_images: HashMap::new(),
|
||||
a2ui_image_requests: HashSet::new(),
|
||||
a2ui_image_loading: false,
|
||||
pending_a2ui_dismissal: None,
|
||||
generating: false,
|
||||
context_used: 0,
|
||||
context_limit,
|
||||
@@ -443,6 +453,8 @@ impl App {
|
||||
queued_inputs: VecDeque::new(),
|
||||
conversation: Vec::new(),
|
||||
a2ui: crate::a2ui::Store::default(),
|
||||
a2ui_history: Vec::new(),
|
||||
a2ui_history_index: None,
|
||||
a2ui_tabs: HashMap::new(),
|
||||
a2ui_modals: HashSet::new(),
|
||||
a2ui_editors: HashMap::new(),
|
||||
@@ -451,6 +463,7 @@ impl App {
|
||||
a2ui_images: HashMap::new(),
|
||||
a2ui_image_requests: HashSet::new(),
|
||||
a2ui_image_loading: false,
|
||||
pending_a2ui_dismissal: None,
|
||||
generating: false,
|
||||
context_used: 0,
|
||||
context_limit,
|
||||
@@ -538,7 +551,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::DismissPanel => {
|
||||
if self.session_rename.is_some() || self.session_menu.is_some() {
|
||||
if self.pending_a2ui_dismissal.is_some() {
|
||||
self.pending_a2ui_dismissal = None;
|
||||
} else if self.session_rename.is_some() || self.session_menu.is_some() {
|
||||
self.session_rename = None;
|
||||
self.session_menu = None;
|
||||
} else if self.preferences_open {
|
||||
@@ -843,9 +858,15 @@ impl App {
|
||||
Message::DownloadProgressTick => self.update_download_progress(),
|
||||
Message::ComposerChanged(value) => self.composer = value,
|
||||
Message::A2uiDataChanged(surface_id, path, value) => {
|
||||
if self.a2ui_history_index.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.change_a2ui_data(surface_id, path, value);
|
||||
}
|
||||
Message::A2uiEditorAction(surface_id, component_id, path, action) => {
|
||||
if self.a2ui_history_index.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let key = (surface_id.clone(), component_id, path.clone());
|
||||
let Some(editor) = self.a2ui_editors.get_mut(&key) else {
|
||||
return Task::none();
|
||||
@@ -859,6 +880,9 @@ impl App {
|
||||
.insert((surface_id, component_id, context_path), value);
|
||||
}
|
||||
Message::A2uiAction(surface_id, component_id, context_path) => {
|
||||
if self.a2ui_history_index.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
match self
|
||||
.a2ui
|
||||
.action(&surface_id, &component_id, context_path.as_deref())
|
||||
@@ -907,6 +931,68 @@ impl App {
|
||||
self.error = Some(format!("Could not open media: {error}"));
|
||||
}
|
||||
}
|
||||
Message::RequestA2uiDismiss(surface_id) => {
|
||||
if self.generating {
|
||||
self.error = Some(
|
||||
"Stop the active generation before dismissing its A2UI surface.".into(),
|
||||
);
|
||||
} else if self.a2ui_history_index.is_none()
|
||||
&& self.a2ui.surface(&surface_id).is_some()
|
||||
{
|
||||
self.pending_a2ui_dismissal = Some(surface_id);
|
||||
}
|
||||
}
|
||||
Message::ConfirmA2uiDismiss => {
|
||||
let Some(surface_id) = self.pending_a2ui_dismissal.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(session_id) = self.selected_session else {
|
||||
self.error = Some("The A2UI surface is not attached to a saved chat.".into());
|
||||
return Task::none();
|
||||
};
|
||||
let Some(database) = &mut self.database else {
|
||||
self.error = Some("The project database is unavailable.".into());
|
||||
return Task::none();
|
||||
};
|
||||
match database.dismiss_a2ui_surface(session_id, &surface_id) {
|
||||
Ok(message) => {
|
||||
self.conversation.push(ChatMessage::from(message));
|
||||
self.a2ui_history.push(self.a2ui.clone());
|
||||
self.a2ui.clear();
|
||||
self.a2ui_history_index = None;
|
||||
self.pending_a2ui_dismissal = None;
|
||||
self.a2ui_tabs.clear();
|
||||
self.a2ui_modals.clear();
|
||||
self.sync_a2ui_renderer_state();
|
||||
self.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(format!("Could not dismiss A2UI surface: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::A2uiPreviousSurface => {
|
||||
if !self.a2ui_history.is_empty() {
|
||||
self.a2ui_history_index = Some(
|
||||
self.a2ui_history_index
|
||||
.map_or(self.a2ui_history.len() - 1, |index| index.saturating_sub(1)),
|
||||
);
|
||||
self.a2ui_tabs.clear();
|
||||
self.a2ui_modals.clear();
|
||||
self.sync_a2ui_renderer_state();
|
||||
return self.load_next_a2ui_image();
|
||||
}
|
||||
}
|
||||
Message::A2uiNextSurface => {
|
||||
if let Some(index) = self.a2ui_history_index {
|
||||
self.a2ui_history_index =
|
||||
(index + 1 < self.a2ui_history.len()).then_some(index + 1);
|
||||
self.a2ui_tabs.clear();
|
||||
self.a2ui_modals.clear();
|
||||
self.sync_a2ui_renderer_state();
|
||||
return self.load_next_a2ui_image();
|
||||
}
|
||||
}
|
||||
Message::ToggleReasoning(index) => {
|
||||
if let Some(message) = self.conversation.get_mut(index)
|
||||
&& message.reasoning.is_some()
|
||||
@@ -1220,16 +1306,20 @@ impl App {
|
||||
Ok((messages, a2ui)) => {
|
||||
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
|
||||
self.clear_a2ui();
|
||||
for message in a2ui {
|
||||
if let Err(error) =
|
||||
self.a2ui.apply_raw(&message.json, message.message_id)
|
||||
{
|
||||
self.error = Some(format!(
|
||||
"Could not restore A2UI message {}: {error}",
|
||||
message.id
|
||||
));
|
||||
}
|
||||
}
|
||||
let (history, active, errors) =
|
||||
crate::a2ui::replay_epochs(a2ui.iter().map(|message| {
|
||||
(
|
||||
message.id,
|
||||
message.message_id,
|
||||
message.dismissed,
|
||||
message.json.as_str(),
|
||||
)
|
||||
}));
|
||||
self.a2ui_history = history;
|
||||
self.a2ui = active;
|
||||
let restore_error = (!errors.is_empty()).then(|| {
|
||||
format!("Could not restore some A2UI history: {}", errors.join("; "))
|
||||
});
|
||||
self.sync_a2ui_renderer_state();
|
||||
self.composer.clear();
|
||||
self.remember_project(project_id);
|
||||
@@ -1244,7 +1334,7 @@ impl App {
|
||||
self.config.generation.context_tokens.max(0) as u32
|
||||
};
|
||||
self.tokens_per_second = tokens_per_second;
|
||||
self.error = None;
|
||||
self.error = restore_error;
|
||||
return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]);
|
||||
}
|
||||
Err(error) => {
|
||||
|
||||
@@ -307,10 +307,16 @@ impl App {
|
||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||
#[cfg(target_os = "macos")]
|
||||
let model_prompt = if self.config.a2ui_enabled {
|
||||
format!(
|
||||
let mut prompt = format!(
|
||||
"{prompt}\n\nA2UI client metadata:\n{}",
|
||||
self.a2ui.client_metadata()
|
||||
)
|
||||
);
|
||||
if self.a2ui.active_surface().is_none() {
|
||||
prompt.push_str(
|
||||
"\n\nThere is no active A2UI surface. If this response presents UI, its first A2UI message must be createSurface with a new surfaceId and a complete root component tree. Do not update any surfaceId found only in earlier chat history.",
|
||||
);
|
||||
}
|
||||
prompt
|
||||
} else {
|
||||
prompt.clone()
|
||||
};
|
||||
@@ -652,18 +658,22 @@ impl App {
|
||||
self.generating = false;
|
||||
self.activity = Some("Correcting A2UI…".into());
|
||||
self.tool_cards.clear();
|
||||
a2ui_feedback = Some(
|
||||
serde_json::json!({
|
||||
"version": crate::a2ui::VERSION,
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"surfaceId": error_surface_id,
|
||||
"path": "/",
|
||||
"message": validation_errors.join("; ")
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let mut feedback = serde_json::json!({
|
||||
"version": crate::a2ui::VERSION,
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"surfaceId": error_surface_id,
|
||||
"path": "/",
|
||||
"message": validation_errors.join("; ")
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
if self.a2ui.active_surface().is_none() {
|
||||
feedback.push_str(
|
||||
"\nThere is no active A2UI surface. Correct this by emitting createSurface with a new surfaceId and a complete root component tree; do not retry updateComponents for an earlier surface.",
|
||||
);
|
||||
}
|
||||
a2ui_feedback = Some(feedback);
|
||||
self.active_generation = None;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ use super::*;
|
||||
impl App {
|
||||
pub(super) fn clear_a2ui(&mut self) {
|
||||
self.a2ui.clear();
|
||||
self.a2ui_history.clear();
|
||||
self.a2ui_history_index = None;
|
||||
self.a2ui_tabs.clear();
|
||||
self.a2ui_modals.clear();
|
||||
self.a2ui_editors.clear();
|
||||
@@ -11,15 +13,16 @@ impl App {
|
||||
self.a2ui_images.clear();
|
||||
self.a2ui_image_requests.clear();
|
||||
self.a2ui_image_loading = false;
|
||||
self.pending_a2ui_dismissal = None;
|
||||
}
|
||||
|
||||
pub(super) fn load_next_a2ui_image(&mut self) -> Task<Message> {
|
||||
if self.a2ui_image_loading {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(url) = self
|
||||
.a2ui
|
||||
.image_urls()
|
||||
let urls = self.displayed_a2ui_store().image_urls().collect::<Vec<_>>();
|
||||
let Some(url) = urls
|
||||
.into_iter()
|
||||
.find(|url| !self.a2ui_image_requests.contains(url))
|
||||
else {
|
||||
return Task::none();
|
||||
@@ -49,12 +52,32 @@ impl App {
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn displayed_a2ui_store(&self) -> &crate::a2ui::Store {
|
||||
self.a2ui_history_index
|
||||
.and_then(|index| self.a2ui_history.get(index))
|
||||
.unwrap_or(&self.a2ui)
|
||||
}
|
||||
|
||||
pub(super) fn has_previous_a2ui_surface(&self) -> bool {
|
||||
self.a2ui_history_index
|
||||
.map_or(!self.a2ui_history.is_empty(), |index| index > 0)
|
||||
}
|
||||
|
||||
pub(super) fn has_next_a2ui_surface(&self) -> bool {
|
||||
self.a2ui_history_index.is_some_and(|index| {
|
||||
index + 1 < self.a2ui_history.len() || self.a2ui.active_surface().is_some()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn change_a2ui_data(
|
||||
&mut self,
|
||||
surface_id: String,
|
||||
path: String,
|
||||
value: serde_json::Value,
|
||||
) -> Task<Message> {
|
||||
if self.a2ui_history_index.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let owner = self
|
||||
.a2ui
|
||||
.surface(&surface_id)
|
||||
|
||||
@@ -39,6 +39,8 @@ const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
||||
const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg");
|
||||
const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg");
|
||||
const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.svg");
|
||||
const ICON_ARROW_LEFT: &[u8] = include_bytes!("../../assets/icons/arrow-left.svg");
|
||||
const ICON_ARROW_RIGHT: &[u8] = include_bytes!("../../assets/icons/arrow-right.svg");
|
||||
|
||||
/// Height of the strip the window content shares with the native title bar.
|
||||
/// Keep it close to the 28pt macOS title bar so our controls line up with the
|
||||
@@ -70,6 +72,7 @@ impl App {
|
||||
|| self.pending_project_path.is_some()
|
||||
|| self.session_rename.is_some()
|
||||
|| self.menu_session().is_some()
|
||||
|| self.pending_a2ui_dismissal.is_some()
|
||||
|| !self.a2ui_modals.is_empty()
|
||||
|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -135,6 +138,8 @@ impl App {
|
||||
layers.push(self.rename_dialog(title));
|
||||
} else if let Some(session) = self.menu_session() {
|
||||
layers.push(self.session_menu_panel(session));
|
||||
} else if let Some(surface_id) = &self.pending_a2ui_dismissal {
|
||||
layers.push(self.a2ui_dismiss_panel(surface_id));
|
||||
} else if let Some(panel) = self.a2ui_modal_panel() {
|
||||
layers.push(panel);
|
||||
}
|
||||
@@ -147,6 +152,8 @@ impl App {
|
||||
layers.push(self.rename_dialog(title));
|
||||
} else if let Some(session) = self.menu_session() {
|
||||
layers.push(self.session_menu_panel(session));
|
||||
} else if let Some(surface_id) = &self.pending_a2ui_dismissal {
|
||||
layers.push(self.a2ui_dismiss_panel(surface_id));
|
||||
} else if let Some(panel) = self.a2ui_modal_panel() {
|
||||
layers.push(panel);
|
||||
}
|
||||
@@ -604,6 +611,37 @@ impl App {
|
||||
)
|
||||
}
|
||||
|
||||
fn a2ui_dismiss_panel<'a>(&self, surface_id: &'a str) -> Element<'a, Message> {
|
||||
let dialog = container(
|
||||
column![
|
||||
text("Dismiss A2UI surface?").size(24),
|
||||
text(format!(
|
||||
"The `{surface_id}` surface will be hidden permanently. The next generated UI starts as a new surface."
|
||||
))
|
||||
.size(14),
|
||||
row![
|
||||
Space::new().width(Length::Fill),
|
||||
action_button("Cancel").on_press(Message::DismissPanel),
|
||||
action_button("Dismiss surface").on_press(Message::ConfirmA2uiDismiss),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(22)
|
||||
.width(460)
|
||||
.style(overview_style);
|
||||
|
||||
opaque(
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
|
||||
self.projects
|
||||
.iter()
|
||||
|
||||
@@ -10,7 +10,7 @@ impl App {
|
||||
let mut markdown = HashMap::new();
|
||||
let mut editors = HashMap::new();
|
||||
let mut filters = HashSet::new();
|
||||
for surface in self.a2ui.surfaces() {
|
||||
if let Some(surface) = self.displayed_a2ui_store().active_surface() {
|
||||
collect_renderer_state(
|
||||
surface,
|
||||
"root",
|
||||
@@ -44,12 +44,10 @@ impl App {
|
||||
.retain(|key, _| filters.contains(key));
|
||||
}
|
||||
|
||||
pub(super) fn a2ui_surfaces(&self, message_id: i32) -> Element<'_, Message> {
|
||||
let mut surfaces = column![].spacing(10);
|
||||
for surface in self.a2ui.surfaces_for_message(message_id) {
|
||||
surfaces = surfaces.push(self.a2ui_surface(surface));
|
||||
}
|
||||
surfaces.into()
|
||||
pub(super) fn displayed_a2ui_surface(&self) -> Option<Element<'_, Message>> {
|
||||
self.displayed_a2ui_store()
|
||||
.active_surface()
|
||||
.map(|surface| self.a2ui_surface(surface))
|
||||
}
|
||||
|
||||
fn a2ui_surface<'a>(&'a self, surface: &'a Surface) -> Element<'a, Message> {
|
||||
@@ -66,19 +64,44 @@ impl App {
|
||||
.color(muted_text())
|
||||
.into()
|
||||
};
|
||||
let dismiss = action_button(text("Dismiss").size(11)).padding([5, 9]);
|
||||
let dismiss = if self.generating || self.a2ui_history_index.is_some() {
|
||||
dismiss
|
||||
} else {
|
||||
dismiss.on_press(Message::RequestA2uiDismiss(surface.id.clone()))
|
||||
};
|
||||
let previous = action_button(icon(ICON_ARROW_LEFT, 15)).padding(5);
|
||||
let previous = if self.has_previous_a2ui_surface() {
|
||||
previous.on_press(Message::A2uiPreviousSurface)
|
||||
} else {
|
||||
previous
|
||||
};
|
||||
let next = action_button(icon(ICON_ARROW_RIGHT, 15)).padding(5);
|
||||
let next = if self.has_next_a2ui_surface() {
|
||||
next.on_press(Message::A2uiNextSurface)
|
||||
} else {
|
||||
next
|
||||
};
|
||||
container(
|
||||
column![
|
||||
row![
|
||||
previous,
|
||||
next,
|
||||
text(agent).size(10).color(muted_text()),
|
||||
Space::new().width(Length::Fill),
|
||||
text(&surface.id).size(10).color(muted_text()),
|
||||
],
|
||||
content,
|
||||
dismiss,
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
scrollable(content).height(Length::Fill),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(10),
|
||||
)
|
||||
.padding(14)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(preference_group_style)
|
||||
.into()
|
||||
}
|
||||
@@ -856,7 +879,7 @@ impl App {
|
||||
|
||||
pub(super) fn a2ui_modal_panel(&self) -> Option<Element<'_, Message>> {
|
||||
let (surface_id, component_id) = self.a2ui_modals.iter().next()?;
|
||||
let surface = self.a2ui.surface(surface_id)?;
|
||||
let surface = self.displayed_a2ui_store().surface(surface_id)?;
|
||||
let component = surface.components.get(component_id)?.as_object()?;
|
||||
let content_id = component.get("content")?.as_str()?;
|
||||
let content =
|
||||
|
||||
@@ -179,9 +179,6 @@ impl App {
|
||||
if !cards.is_empty() {
|
||||
body = body.push(tool_cards(cards));
|
||||
}
|
||||
if self.a2ui.surfaces_for_message(message.id).next().is_some() {
|
||||
body = body.push(self.a2ui_surfaces(message.id));
|
||||
}
|
||||
}
|
||||
let user = message.user;
|
||||
messages = messages.push(
|
||||
@@ -293,10 +290,24 @@ impl App {
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
let transcript = scrollable(messages)
|
||||
.id(chat_scroll_id())
|
||||
.height(Length::Fill);
|
||||
let workspace: Element<'_, Message> =
|
||||
if let Some(surface) = self.displayed_a2ui_surface() {
|
||||
column![
|
||||
container(surface).height(Length::FillPortion(1)),
|
||||
rule::horizontal(1),
|
||||
container(transcript).height(Length::FillPortion(1)),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(8)
|
||||
.into()
|
||||
} else {
|
||||
transcript.into()
|
||||
};
|
||||
let conversation = column![
|
||||
scrollable(messages)
|
||||
.id(chat_scroll_id())
|
||||
.height(Length::Fill),
|
||||
workspace,
|
||||
container(composer_content,)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
|
||||
121
src/database.rs
121
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<StoredMessage, String> {
|
||||
let content = format!(
|
||||
"A2UI surface `{surface_id}` was dismissed by the user. There is no active A2UI surface. If the user asks for UI again, create a complete new surface with createSurface and a new surfaceId; do not update any earlier surface."
|
||||
);
|
||||
let json = serde_json::json!({"surfaceId": surface_id}).to_string();
|
||||
self.connection
|
||||
.transaction(|connection| {
|
||||
let message = diesel::insert_into(messages::table)
|
||||
.values(NewMessage {
|
||||
session_id,
|
||||
user: false,
|
||||
tool: false,
|
||||
reasoning: None,
|
||||
reasoning_complete: true,
|
||||
content: &content,
|
||||
system: true,
|
||||
compaction: false,
|
||||
compaction_tail_start: None,
|
||||
})
|
||||
.returning(StoredMessage::as_returning())
|
||||
.get_result(connection)?;
|
||||
diesel::insert_into(a2ui_messages::table)
|
||||
.values(NewA2uiMessage {
|
||||
session_id,
|
||||
message_id: message.id,
|
||||
json: &json,
|
||||
dismissed: true,
|
||||
})
|
||||
.execute(connection)?;
|
||||
Ok(message)
|
||||
})
|
||||
.map_err(|error: diesel::result::Error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn update_session_context(
|
||||
&mut self,
|
||||
session_id: i32,
|
||||
@@ -672,6 +713,86 @@ mod tests {
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a2ui_dismissal_persists_a_fresh_surface_boundary() {
|
||||
let id = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("ds4-a2ui-dismiss-{id}.sqlite3"));
|
||||
let mut database = Database::open(&path).unwrap();
|
||||
let project = database
|
||||
.create_project("DS4", "/tmp/ds4-a2ui-dismiss")
|
||||
.unwrap();
|
||||
let session = database.create_session(project.id, "A2UI").unwrap();
|
||||
let first = database
|
||||
.start_chat_turn(session.id, "First", &[], false)
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
database
|
||||
.insert_a2ui_message(
|
||||
session.id,
|
||||
first.id,
|
||||
r#"{"version":"v1.0","createSurface":{"surfaceId":"first","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let dismissal = database.dismiss_a2ui_surface(session.id, "first").unwrap();
|
||||
assert!(dismissal.system);
|
||||
assert!(dismissal.content.contains("createSurface"));
|
||||
let protocol = database.load_a2ui_messages(session.id).unwrap();
|
||||
let (_, active, errors) = crate::a2ui::replay_epochs(protocol.iter().map(|message| {
|
||||
(
|
||||
message.id,
|
||||
message.message_id,
|
||||
message.dismissed,
|
||||
message.json.as_str(),
|
||||
)
|
||||
}));
|
||||
assert!(errors.is_empty());
|
||||
assert!(active.active_surface().is_none());
|
||||
|
||||
let second = database
|
||||
.start_chat_turn(session.id, "Second", &[], false)
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
database
|
||||
.insert_a2ui_message(
|
||||
session.id,
|
||||
second.id,
|
||||
r#"{"version":"v1.0","createSurface":{"surfaceId":"second","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
drop(database);
|
||||
|
||||
let mut reopened = Database::open(&path).unwrap();
|
||||
let protocol = reopened.load_a2ui_messages(session.id).unwrap();
|
||||
assert_eq!(protocol.len(), 3);
|
||||
assert!(protocol[1].dismissed);
|
||||
assert_eq!(protocol[1].message_id, dismissal.id);
|
||||
let (_, active, errors) = crate::a2ui::replay_epochs(protocol.iter().map(|message| {
|
||||
(
|
||||
message.id,
|
||||
message.message_id,
|
||||
message.dismissed,
|
||||
message.json.as_str(),
|
||||
)
|
||||
}));
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(active.active_surface().unwrap().id, "second");
|
||||
let messages = reopened.load_messages(session.id).unwrap();
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.position(|message| message.id == dismissal.id)
|
||||
.is_some_and(|index| messages[index + 1].content == "Second")
|
||||
);
|
||||
reopened.delete_project(project.id).unwrap();
|
||||
drop(reopened);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_and_compaction_history_survive_reopen_and_session_deletion() {
|
||||
let id = SystemTime::now()
|
||||
|
||||
@@ -4,6 +4,7 @@ diesel::table! {
|
||||
session_id -> Integer,
|
||||
message_id -> Integer,
|
||||
json -> Text,
|
||||
dismissed -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user