diff --git a/.gitignore b/.gitignore index 0d75af0f..923f7ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ /target apps/papillon/frontend/target apps/papillon/frontend/dist +bindings/java/.gradle/ registry.db apps/papillon/papillon.db .DS_STORE diff --git a/Cargo.lock b/Cargo.lock index cd43253e..4e614781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4724,7 +4724,7 @@ dependencies = [ [[package]] name = "pap-agents" -version = "0.8.2" +version = "0.8.3" dependencies = [ "candle-core", "candle-transformers", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "pap-bench" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "criterion", @@ -4772,7 +4772,7 @@ dependencies = [ [[package]] name = "pap-bluefield" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -4791,7 +4791,7 @@ dependencies = [ [[package]] name = "pap-bluefield-loopback" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-bluefield", @@ -4804,7 +4804,7 @@ dependencies = [ [[package]] name = "pap-c" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "cbindgen", @@ -4822,7 +4822,7 @@ dependencies = [ [[package]] name = "pap-core" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -4842,7 +4842,7 @@ dependencies = [ [[package]] name = "pap-credential" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -4861,7 +4861,7 @@ dependencies = [ [[package]] name = "pap-credential-lifecycle-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "pap-credential-store" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "argon2", @@ -4897,7 +4897,7 @@ dependencies = [ [[package]] name = "pap-delegation-chain-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -4907,7 +4907,7 @@ dependencies = [ [[package]] name = "pap-did" -version = "0.8.2" +version = "0.8.3" dependencies = [ "bs58", "ed25519-dalek", @@ -4920,7 +4920,7 @@ dependencies = [ [[package]] name = "pap-ecash" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "blind-rsa-signatures", @@ -4932,7 +4932,7 @@ dependencies = [ [[package]] name = "pap-federated-discovery-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -4948,7 +4948,7 @@ dependencies = [ [[package]] name = "pap-federation" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "axum-server", @@ -4985,7 +4985,7 @@ dependencies = [ [[package]] name = "pap-intent-routing" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-agents", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "pap-marketplace" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -5016,7 +5016,7 @@ dependencies = [ [[package]] name = "pap-networked-search-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "chrono", @@ -5032,7 +5032,7 @@ dependencies = [ [[package]] name = "pap-payment-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -5044,7 +5044,7 @@ dependencies = [ [[package]] name = "pap-proto" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -5066,7 +5066,7 @@ dependencies = [ [[package]] name = "pap-protocol-envelope-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5079,7 +5079,7 @@ dependencies = [ [[package]] name = "pap-python" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5097,7 +5097,7 @@ dependencies = [ [[package]] name = "pap-registry" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "axum", @@ -5146,7 +5146,7 @@ dependencies = [ [[package]] name = "pap-sandbox" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "async-trait", @@ -5173,7 +5173,7 @@ dependencies = [ [[package]] name = "pap-search-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -5185,7 +5185,7 @@ dependencies = [ [[package]] name = "pap-selective-disclosure-decay-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "ed25519-dalek", "pap-core", @@ -5197,7 +5197,7 @@ dependencies = [ [[package]] name = "pap-tee" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -5212,7 +5212,7 @@ dependencies = [ [[package]] name = "pap-test-utils" -version = "0.8.2" +version = "0.8.3" dependencies = [ "ed25519-dalek", "pap-did", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "pap-transport" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "axum", @@ -5250,7 +5250,7 @@ dependencies = [ [[package]] name = "pap-travel-booking-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "pap-core", @@ -5262,7 +5262,7 @@ dependencies = [ [[package]] name = "pap-wasm" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "pap-webauthn" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", @@ -5301,7 +5301,7 @@ dependencies = [ [[package]] name = "pap-webauthn-ceremony-example" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -5313,7 +5313,7 @@ dependencies = [ [[package]] name = "papillon" -version = "0.8.2" +version = "0.8.3" dependencies = [ "axum", "axum-server", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "papillon-shared" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", @@ -8209,7 +8209,7 @@ dependencies = [ [[package]] name = "tee-attestation" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "chrono", diff --git a/apps/papillon/frontend/Cargo.lock b/apps/papillon/frontend/Cargo.lock index 9ce0e26a..f5fcd177 100644 --- a/apps/papillon/frontend/Cargo.lock +++ b/apps/papillon/frontend/Cargo.lock @@ -1249,7 +1249,7 @@ checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" [[package]] name = "pap-core" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "pap-credential" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1285,7 +1285,7 @@ dependencies = [ [[package]] name = "pap-did" -version = "0.8.2" +version = "0.8.3" dependencies = [ "bs58", "ed25519-dalek", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "pap-federation" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1317,7 +1317,7 @@ dependencies = [ [[package]] name = "pap-marketplace" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64", "chrono", @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "pap-proto" -version = "0.8.2" +version = "0.8.3" dependencies = [ "aes-gcm", "base64", @@ -1356,7 +1356,7 @@ dependencies = [ [[package]] name = "papillon-shared" -version = "0.8.2" +version = "0.8.3" dependencies = [ "chrono", "ed25519-dalek", diff --git a/apps/papillon/frontend/src/components/block_renderer/mod.rs b/apps/papillon/frontend/src/components/block_renderer/mod.rs index 8aae5f12..6dee84f3 100644 --- a/apps/papillon/frontend/src/components/block_renderer/mod.rs +++ b/apps/papillon/frontend/src/components/block_renderer/mod.rs @@ -61,7 +61,8 @@ pub fn create_default_registry() -> Arc { // Reservations registry.register(Arc::new(templates::FlightTemplate)); registry.register(Arc::new(templates::HotelTemplate)); - // Q&A — SearchResultsPage/SearchAction handled by generic composite renderer + // Search / Q&A + registry.register(Arc::new(templates::SearchResultsTemplate)); registry.register(Arc::new(templates::AnswerTemplate)); // Entertainment registry.register(Arc::new(templates::MovieTemplate)); diff --git a/apps/papillon/frontend/src/components/block_renderer/templates.rs b/apps/papillon/frontend/src/components/block_renderer/templates.rs index 32be4ac1..6b0e2185 100644 --- a/apps/papillon/frontend/src/components/block_renderer/templates.rs +++ b/apps/papillon/frontend/src/components/block_renderer/templates.rs @@ -1,4 +1,6 @@ use super::renderer::BlockRenderer; +use super::BlockContext; +use crate::state::canvas::CanvasState; use leptos::prelude::*; use serde_json::Value; @@ -63,11 +65,303 @@ impl BlockRenderer for HotelTemplate { } } -// SearchTemplate removed — SearchResultsPage and SearchAction are handled by -// the generic composite renderer via flatten_to_entries(). The generic renderer -// classifies each schema.org property by kind (URL, text, number, etc.) and -// renders them without type-specific hardcoding. Custom rendering for specific -// agents or types should use DeclarativeRenderer (dynamic templates) instead. +/// SearchResultsPage template — turns Schema.org search JSON-LD into a browsable +/// result list. Agents send data only; Papillon chooses this UI from `@type`. +pub struct SearchResultsTemplate; + +#[derive(Clone, Debug)] +struct SearchResultAction { + label: String, + target: String, +} + +#[derive(Clone, Debug)] +struct SearchResultItem { + name: String, + url: String, + description: String, + source: String, + actions: Vec, +} + +impl BlockRenderer for SearchResultsTemplate { + fn render(&self, content: &Value) -> AnyView { + let results = extract_search_results(content); + let result_count = results.len(); + let canvas_state = use_context::(); + let source_block_id = use_context::().map(|c| c.id.get_value()); + let result_views = results + .into_iter() + .map(|item| { + let has_url = !item.url.is_empty(); + let pap_url = to_pap_url(&item.url); + let pap_for_click = pap_url.clone(); + let source_for_click = source_block_id.clone(); + let canvas_for_click = canvas_state; + let title = if item.name.is_empty() { + item.url.clone() + } else { + item.name + }; + + let title_view = if has_url { + view! { + + {title} + + } + .into_any() + } else { + view! {
{title}
}.into_any() + }; + + let source_view = if item.source.is_empty() { + view! { <> }.into_any() + } else { + view! {
{item.source}
}.into_any() + }; + + let description_view = if item.description.is_empty() { + view! { <> }.into_any() + } else { + view! {

{item.description}

}.into_any() + }; + + let action_views = item + .actions + .into_iter() + .map(|action| { + let target_title = action.target; + let target_for_click = to_pap_url(&target_title); + let source_for_action = source_block_id.clone(); + let canvas_for_action = canvas_state; + + view! { + + } + }) + .collect::>(); + + let actions_view = if action_views.is_empty() { + view! { <> }.into_any() + } else { + view! { +
+ {action_views} +
+ } + .into_any() + }; + + view! { +
+
+ {title_view} + {source_view} + {description_view} +
+ {actions_view} +
+ } + }) + .collect::>(); + + let result_body = if result_count == 0 { + view! {
"No structured results returned."
} + .into_any() + } else { + view! { <>{result_views} }.into_any() + }; + + view! { +
+
+ "Schema.org SearchResultsPage" + + {if result_count == 1 { + "1 result".to_string() + } else { + format!("{result_count} results") + }} + +
+ {result_body} +
+ } + .into_any() + } + + fn schema_types(&self) -> Vec<&'static str> { + vec!["SearchResultsPage", "SearchResult"] + } +} + +fn extract_search_results(content: &Value) -> Vec { + let mut raw_items: Vec = Vec::new(); + + if has_type(content, "SearchResult") { + raw_items.push(content.clone()); + } + + if let Some(items) = content + .get("mainEntity") + .and_then(|v| v.get("itemListElement")) + .and_then(|v| v.as_array()) + { + raw_items.extend(items.iter().cloned()); + } + + if let Some(items) = content.get("itemListElement").and_then(|v| v.as_array()) { + raw_items.extend(items.iter().cloned()); + } + + if raw_items.is_empty() { + if let Some(items) = content.get("result").and_then(|v| v.as_array()) { + raw_items.extend(items.iter().cloned()); + } else if let Some(items) = content + .get("result") + .and_then(|v| v.get("itemListElement")) + .and_then(|v| v.as_array()) + { + raw_items.extend(items.iter().cloned()); + } + } + + raw_items + .iter() + .filter_map(search_item_from_value) + .take(20) + .collect() +} + +fn search_item_from_value(value: &Value) -> Option { + let item = value.get("item").unwrap_or(value); + let payload = if has_type(item, "ListItem") { + item.get("item").unwrap_or(item) + } else { + item + }; + + let name = textish(payload, "name") + .or_else(|| textish(payload, "headline")) + .or_else(|| textish(value, "name")) + .unwrap_or_else(|| "Untitled result".to_string()); + let url = textish(payload, "url") + .or_else(|| textish(payload, "sameAs")) + .or_else(|| textish(value, "url")) + .unwrap_or_default(); + let description = textish(payload, "description") + .or_else(|| textish(payload, "text")) + .or_else(|| textish(value, "description")) + .unwrap_or_default(); + let source = if url.is_empty() { + textish(payload, "provider") + .or_else(|| textish(payload, "publisher")) + .or_else(|| textish(payload, "source")) + .unwrap_or_default() + } else { + url.clone() + }; + let actions = extract_actions(payload); + + if name.is_empty() && url.is_empty() && description.is_empty() { + None + } else { + Some(SearchResultItem { + name, + url, + description, + source, + actions, + }) + } +} + +fn extract_actions(value: &Value) -> Vec { + let Some(actions) = value.get("potentialAction") else { + return vec![]; + }; + + match actions { + Value::Array(items) => items.iter().filter_map(action_from_value).collect(), + other => action_from_value(other).into_iter().collect(), + } +} + +fn action_from_value(value: &Value) -> Option { + let label = textish(value, "name") + .or_else(|| textish(value, "@type").map(|t| t.trim_start_matches("schema:").to_string())) + .unwrap_or_else(|| "Open".to_string()); + let target = textish(value, "target") + .or_else(|| value.get("target").and_then(|v| textish(v, "urlTemplate"))) + .or_else(|| value.get("target").and_then(|v| textish(v, "url"))) + .or_else(|| textish(value, "url")) + .unwrap_or_default(); + + if target.is_empty() { + None + } else { + Some(SearchResultAction { label, target }) + } +} + +fn has_type(value: &Value, expected: &str) -> bool { + match value.get("@type") { + Some(Value::String(t)) => t == expected || t.trim_start_matches("schema:") == expected, + Some(Value::Array(types)) => types.iter().any(|t| { + t.as_str() + .map(|s| s == expected || s.trim_start_matches("schema:") == expected) + .unwrap_or(false) + }), + _ => false, + } +} + +fn textish(value: &Value, key: &str) -> Option { + let value = value.get(key)?; + match value { + Value::String(s) if !s.trim().is_empty() => Some(s.trim().to_string()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + Value::Object(_) => textish(value, "name").or_else(|| textish(value, "url")), + Value::Array(items) => items.iter().find_map(|item| match item { + Value::String(s) if !s.trim().is_empty() => Some(s.trim().to_string()), + Value::Object(_) => textish(item, "name").or_else(|| textish(item, "url")), + _ => None, + }), + _ => None, + } +} + +fn to_pap_url(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix("https://") { + format!("pap://{rest}") + } else if let Some(rest) = raw.strip_prefix("http://") { + format!("pap://{rest}") + } else { + raw.to_string() + } +} /// Answer template — on-device AI response rendered as a paragraph. pub struct AnswerTemplate; @@ -455,7 +749,12 @@ impl BlockRenderer for OrganizationTemplate { } fn schema_types(&self) -> Vec<&'static str> { - vec!["Organization", "LocalBusiness", "FoodEstablishment", "LodgingBusiness"] + vec![ + "Organization", + "LocalBusiness", + "FoodEstablishment", + "LodgingBusiness", + ] } } @@ -903,11 +1202,7 @@ impl BlockRenderer for WebPageTemplate { let date = if date_raw == "-" { String::new() } else { - date_raw - .split('T') - .next() - .unwrap_or(&date_raw) - .to_string() + date_raw.split('T').next().unwrap_or(&date_raw).to_string() }; // Rewrite the URL to a pap:// link so it routes through submit_agent_link(). diff --git a/apps/papillon/frontend/src/components/canvas_ghost_run_panel.rs b/apps/papillon/frontend/src/components/canvas_ghost_run_panel.rs index 60d8e146..50af303f 100644 --- a/apps/papillon/frontend/src/components/canvas_ghost_run_panel.rs +++ b/apps/papillon/frontend/src/components/canvas_ghost_run_panel.rs @@ -1,10 +1,41 @@ +use std::collections::{BTreeMap, HashMap}; + use leptos::prelude::*; -use papillon_shared::{BlockState, OrchestratorStatus}; +use papillon_shared::{AgentCandidate, BlockState, CanvasBlock, IntentPlan, OrchestratorStatus}; +use wasm_bindgen_futures::spawn_local; use crate::components::canvas_chat_thread::CanvasChatThread; -use crate::components::canvas_workflow_pipeline::{derive_block_trace, ApprovalPlanInline}; -use crate::state::canvas::{filter_messages_by_canvas, CanvasState}; +use crate::components::canvas_workflow_pipeline::derive_block_trace; +use crate::state::canvas::{filter_messages_by_canvas, CanvasSide, CanvasState}; use crate::state::orchestrator::OrchestratorState; +use crate::workflow_labels::{humanize_schema_term, render_label_for_schema, workflow_port_label}; + +#[derive(Clone, PartialEq)] +struct ApprovalGroup { + id: String, + action: String, + disclosure_props: Vec, + return_types: Vec, + candidates: Vec, + items: Vec, + ttl_hours: u32, +} + +#[derive(Clone, PartialEq)] +struct GroupCandidate { + name: String, + did: String, + disclosure_props: Vec, + return_types: Vec, +} + +#[derive(Clone, PartialEq)] +struct ApprovalGroupItem { + block_id: String, + approval_request_id: String, + prompt: String, + candidate_names: Vec, +} #[component] pub fn CanvasGhostRunPanel(#[prop(optional)] compact: bool) -> impl IntoView { @@ -27,6 +58,7 @@ pub fn CanvasGhostRunPanel(#[prop(optional)] compact: bool) -> impl IntoView { }) .collect::>() }; + let approval_groups = move || grouped_approvals(active_blocks()); let trace_blocks = move || { active_blocks() @@ -40,7 +72,12 @@ pub fn CanvasGhostRunPanel(#[prop(optional)] compact: bool) -> impl IntoView { let total = blocks.len(); let resolving = blocks .iter() - .filter(|b| matches!(b.state, BlockState::Resolving { .. } | BlockState::Ghost { .. })) + .filter(|b| { + matches!( + b.state, + BlockState::Resolving { .. } | BlockState::Ghost { .. } + ) + }) .count(); let approvals = blocks .iter() @@ -55,10 +92,9 @@ pub fn CanvasGhostRunPanel(#[prop(optional)] compact: bool) -> impl IntoView { let orchestrator_badge = move || match orchestrator.status.get() { OrchestratorStatus::Ready => ("status-ready".to_string(), "ready".to_string()), - OrchestratorStatus::Disconnected => ( - "status-disconnected".to_string(), - "offline".to_string(), - ), + OrchestratorStatus::Disconnected => { + ("status-disconnected".to_string(), "offline".to_string()) + } OrchestratorStatus::Unconfigured => ( "status-unconfigured".to_string(), "setup needed".to_string(), @@ -101,23 +137,14 @@ pub fn CanvasGhostRunPanel(#[prop(optional)] compact: bool) -> impl IntoView {
- - {move || approvals().len()} + + {move || approval_groups().len()}
-
- {block.prompt_text.unwrap_or_else(|| "Pending approval".into())} -
- - - } - } + each=approval_groups + key=|group| group.id.clone() + children=move |group| view! { } />
@@ -184,3 +211,371 @@ where } } + +#[component] +fn GroupedApprovalCard(group: ApprovalGroup) -> impl IntoView { + let canvas_state = expect_context::(); + let selected_agents = RwSignal::new( + group + .candidates + .iter() + .map(|candidate| candidate.name.clone()) + .collect::>(), + ); + let filled_values: RwSignal> = RwSignal::new(HashMap::new()); + + if !group.disclosure_props.is_empty() && crate::bridge::tauri_available() { + let values = filled_values; + spawn_local(async move { + if let Ok(attrs) = crate::bridge::invoke::<_, HashMap>( + "get_principal_attributes", + &serde_json::json!({}), + ) + .await + { + values.set(attrs); + } + }); + } + + let action_label = humanize_schema_term(&group.action); + let return_label = readable_returns(&group.return_types); + let candidate_count = group.candidates.len(); + let item_count = group.items.len(); + let ttl_hours = group.ttl_hours; + let has_disclosures = !group.disclosure_props.is_empty(); + let group_for_render = group.clone(); + let group_for_deny = group.clone(); + let canvas_for_render = canvas_state; + let canvas_for_deny = canvas_state; + let candidate_views = group + .candidates + .clone() + .into_iter() + .map(|candidate| { + let candidate_name = candidate.name.clone(); + let detail_text = candidate_detail(&candidate); + let candidate_name_for_checked = candidate_name.clone(); + let candidate_name_for_change = candidate_name.clone(); + view! { + + } + }) + .collect::>(); + let candidate_list_view = view! { +
{candidate_views}
+ } + .into_any(); + + let disclosure_view = if has_disclosures { + let disclosure_field_views = group + .disclosure_props + .clone() + .into_iter() + .map(|prop| { + let field_key = prop.clone(); + let field_key_for_input = prop.clone(); + let label = workflow_port_label(&prop); + view! { + + } + }) + .collect::>(); + view! { +
{disclosure_field_views}
+ } + .into_any() + } else { + view! { +
"No personal fields requested."
+ } + .into_any() + }; + + let prompts_view = if item_count > 0 { + let prompt_views = group + .items + .clone() + .into_iter() + .map(|item| view! {
{item.prompt}
}) + .collect::>(); + view! { +
{prompt_views}
+ } + .into_any() + } else { + view! { <> }.into_any() + }; + + view! { +
+
+
+
"Dry-run group"
+
{action_label}
+
+
+ {format!( + "{} · {}", + pluralize(item_count, "intent"), + pluralize(candidate_count, "agent") + )} +
+
+ +
+
+ "Will render" + {return_label} +
+
+ "Permission" + {if has_disclosures { "Disclosure required" } else { "Zero disclosure" }} +
+
+ "Valid for" + {format!("{ttl_hours}h")} +
+
+ +
+
"Agents to run"
+ {candidate_list_view} +
+ +
+
"Will need from you"
+ {disclosure_view} +
+ + {prompts_view} + +
+ + +
+
+ } +} + +fn grouped_approvals(blocks: Vec) -> Vec { + let mut groups = BTreeMap::::new(); + + for block in blocks { + let BlockState::AwaitingApproval { plan } = block.state.clone() else { + continue; + }; + let key = approval_group_key(&plan); + let candidates = plan_candidates(&plan); + let group = groups.entry(key.clone()).or_insert_with(|| ApprovalGroup { + id: key, + action: plan.action.clone(), + disclosure_props: normalized_unique(plan.requires_disclosure.clone()), + return_types: Vec::new(), + candidates: Vec::new(), + items: Vec::new(), + ttl_hours: plan.ttl_hours, + }); + + merge_unique(&mut group.return_types, plan.returns.clone()); + group.ttl_hours = group.ttl_hours.min(plan.ttl_hours); + + for candidate in &candidates { + if !group + .candidates + .iter() + .any(|existing| existing.name == candidate.name) + { + group.candidates.push(GroupCandidate { + name: candidate.name.clone(), + did: candidate.did.clone(), + disclosure_props: normalized_unique(candidate.requires_disclosure.clone()), + return_types: normalized_unique(candidate.returns.clone()), + }); + } + } + + group.items.push(ApprovalGroupItem { + block_id: block.id, + approval_request_id: plan.approval_request_id, + prompt: block + .prompt_text + .unwrap_or_else(|| "Pending approval".to_string()), + candidate_names: candidates + .into_iter() + .map(|candidate| candidate.name) + .collect(), + }); + } + + groups.into_values().collect() +} + +fn approval_group_key(plan: &IntentPlan) -> String { + let disclosures = normalized_unique(plan.requires_disclosure.clone()).join(","); + format!("{}|{}", plan.action, disclosures) +} + +fn plan_candidates(plan: &IntentPlan) -> Vec { + if plan.candidates.is_empty() { + vec![AgentCandidate { + name: plan.selected_agent_name.clone(), + did: plan.selected_agent_did.clone().unwrap_or_default(), + requires_disclosure: plan.requires_disclosure.clone(), + returns: plan.returns.clone(), + }] + } else { + plan.candidates.clone() + } +} + +fn normalized_unique(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + values +} + +fn merge_unique(target: &mut Vec, values: Vec) { + for value in values { + if !target.contains(&value) { + target.push(value); + } + } +} + +fn readable_returns(types: &[String]) -> String { + if types.is_empty() { + return "Structured result".into(); + } + let mut labels = types + .iter() + .map(|schema| render_label_for_schema(schema)) + .collect::>(); + labels.sort(); + labels.dedup(); + if labels.len() > 2 { + format!("{} +{}", labels[..2].join(", "), labels.len() - 2) + } else { + labels.join(", ") + } +} + +fn candidate_detail(candidate: &GroupCandidate) -> String { + let returns = readable_returns(&candidate.return_types); + if candidate.disclosure_props.is_empty() { + format!("{returns} · zero disclosure") + } else { + format!( + "{} · needs {}", + returns, + candidate + .disclosure_props + .iter() + .map(|prop| workflow_port_label(prop)) + .collect::>() + .join(", ") + ) + } +} + +fn pluralize(count: usize, singular: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{count} {singular}s") + } +} diff --git a/apps/papillon/frontend/src/pages/canvas.rs b/apps/papillon/frontend/src/pages/canvas.rs index d8d36045..3a63f2a1 100644 --- a/apps/papillon/frontend/src/pages/canvas.rs +++ b/apps/papillon/frontend/src/pages/canvas.rs @@ -4,6 +4,7 @@ use papillon_shared::{BlockState, CanvasBlock}; use crate::components::block_renderer::BlockRenderer; use crate::components::canvas_aside::{AsideOpen, CanvasAside, CanvasAsideDockToggle}; use crate::components::canvas_back_face::CanvasBackFace; +use crate::components::canvas_empty_state::CanvasEmptyState; use crate::components::canvas_surface_title::CanvasSurfaceTitle; use crate::components::hitl_gate::HitlGate; use crate::state::canvas::{CanvasSide, CanvasState}; @@ -23,6 +24,7 @@ pub fn CanvasPage() -> impl IntoView { .filter(|block| !matches!(block.state, BlockState::Guide { .. })) .collect::>() }; + let has_any_blocks = move || !blocks.get().is_empty(); let has_rendered_blocks = move || !rendered_blocks().is_empty(); // Group blocks by semantic links for rendering let grouped_blocks = move || { @@ -72,7 +74,14 @@ pub fn CanvasPage() -> impl IntoView {
-
"Approve workflow to render"
+ "Approve workflow to render"
+ } + > + +