From 4e959d0335a7c09b1139f2ee23e5a73c22850aa5 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 18:20:19 -0700 Subject: [PATCH 1/2] fix(web-ui): close floating mini chat backdrop on click, not mousedown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing on mousedown unmounts the fullscreen backdrop in the middle of the press gesture. Pulling the hit target of an in-flight gesture out from over a cross-document iframe (a MiniApp) leaves the webview routing subsequent mousemoves against the stale layer: the MiniApp loses hover feedback and swallows an extra click until the next mousedown forces a fresh hit test — closing the bubble by clicking the MiniApp then took two more clicks before the app reacted again. Close on click instead, so the gesture completes on the backdrop before it unmounts. A ref armed on mousedown preserves the existing opening-phase protection: a press that began while the panel was still scaling up never closes it. --- .../src/app/layout/FloatingMiniChat.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/app/layout/FloatingMiniChat.tsx b/src/web-ui/src/app/layout/FloatingMiniChat.tsx index be4cdac149..44ff2c039b 100644 --- a/src/web-ui/src/app/layout/FloatingMiniChat.tsx +++ b/src/web-ui/src/app/layout/FloatingMiniChat.tsx @@ -61,6 +61,8 @@ export const FloatingMiniChat: React.FC = () => { text: string; } | null>(null); const composerPrefillIdRef = useRef(0); + /** True while a press that may legitimately close the panel is in flight. */ + const backdropArmedRef = useRef(false); const isOpen = phase !== 'closed'; const panelRef = useRef(null); const previousHostSessionRef = useRef<{ @@ -366,11 +368,24 @@ export const FloatingMiniChat: React.FC = () => { still smaller than its final rect, so a click aimed at the panel would land here and close what the user just opened. Rendering it (without the close handler) throughout keeps those clicks from reaching the - scene underneath. */} + scene underneath. + + Closing happens on click, NOT on mousedown: closing on mousedown + unmounts the backdrop in the middle of the press gesture, and pulling + the hit target of an in-flight gesture out from over a cross-document + iframe (a MiniApp) leaves the webview routing subsequent mousemoves + against the stale layer — the iframe loses hover and eats an extra + click until the next mousedown re-hit-tests. Arming on mousedown + preserves the opening-phase protection above: a press that began + while the panel was still scaling up never closes it. */} {isOpen && (
{ backdropArmedRef.current = phase === 'open'; }} + onClick={() => { + if (backdropArmedRef.current) handleClose(); + backdropArmedRef.current = false; + }} /> )} From f31c087c83e26cfe3c71187df2d24b601dfd295c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 30 Jul 2026 18:42:00 -0700 Subject: [PATCH 2/2] feat(agentic): resolve PublishMiniApp target by display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Publish 循天问命' used to send the agent digging through the filesystem: the tool required app_id and its description pointed at 'the directory name under the miniapps data root', while the installed roster sat in the running MiniAppManager all along. - Add app_name: matched case-insensitively against manifest names in every locale (exact first, substring fallback); app_id becomes optional. - Every failure path (unknown name, ambiguous name, bad id, neither given) returns the installed 'id — name' roster so the model self-corrects on the next call instead of searching disk. - Tool description now says to resolve through the manager and never search the filesystem; screenshot validation moves after app resolution so name-only calls confirm the app before asking the user for screenshots. - Permission identity falls back to app_name; unit tests cover locale matching, substring fallback, ambiguity and the schema contract. --- .../implementations/miniapp_publish_tool.rs | 212 ++++++++++++++++-- 1 file changed, 196 insertions(+), 16 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index 0d703dffdd..a8ff936233 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -9,6 +9,7 @@ use async_trait::async_trait; use bitfun_product_domains::miniapp::market::{ MarketLicense, MarketSubmissionDraftRequest, MARKET_CATEGORIES, MARKET_MAX_SCREENSHOTS, }; +use bitfun_product_domains::miniapp::types::MiniAppMeta; use bitfun_services_integrations::miniapp_market::{ map_local_category_to_market, resolve_release_target, submit_installed_app, suggest_market_slug, DesktopAuthPollRequest, MarketClient, ReleaseTarget, @@ -42,6 +43,8 @@ impl Tool for PublishMiniAppTool { async fn description(&self) -> BitFunResult { Ok(r#"Submit an installed MiniApp to the BitFun MiniApp market for human review. +Identify the app by `app_name` — the display name the user used (e.g. "循天问命"), matched against installed apps' manifest names in every locale — or by `app_id` if you already have one. Installed apps are resolved through the running MiniApp manager: do NOT search the filesystem for the app; if the name does not resolve, the error lists every installed app to pick from. + Listing metadata (name, description, icon, category, tags) is derived from the app's manifest; the marketplace slug and release number are derived automatically from the user's submission history. Provide 1-5 screenshot file paths (PNG/JPEG/WebP, each <= 5 MiB) — ask the user for screenshots, or have them use 市场 → 我的投稿 → 截取当前画面 to capture one. If the user is not signed in to the market, the tool returns a GitHub authorization link. Show the link to the user, wait for them to authorize in the browser, then call this tool again with the same arguments. @@ -58,11 +61,15 @@ Publishing is an outward-facing action: only call this when the user explicitly json!({ "type": "object", "additionalProperties": false, - "required": ["app_id", "screenshot_paths"], + "required": ["screenshot_paths"], "properties": { + "app_name": { + "type": "string", + "description": "Display name of the installed MiniApp as the user refers to it (matched case-insensitively against manifest names in every locale). Preferred when the user named the app. Provide this or app_id." + }, "app_id": { "type": "string", - "description": "Installed MiniApp id (returned by InitMiniApp, or the directory name under the miniapps data root)" + "description": "Installed MiniApp id (e.g. returned by InitMiniApp or an earlier PublishMiniApp error). Provide this or app_name." }, "screenshot_paths": { "type": "array", @@ -100,14 +107,22 @@ Publishing is an outward-facing action: only call this when the user explicitly input: &Value, _context: &ToolUseContext, ) -> BitFunResult> { - let app_id = input + let identity = input .get("app_id") .and_then(Value::as_str) - .unwrap_or("unknown") - .trim(); + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + input + .get("app_name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .unwrap_or("unknown"); Ok(vec![PermissionIntent::new( "custom_tool", - vec![format!("miniapp:PublishMiniApp:{app_id}")], + vec![format!("miniapp:PublishMiniApp:{identity}")], )]) } @@ -123,8 +138,60 @@ Publishing is an outward-facing action: only call this when the user explicitly .get("app_id") .and_then(Value::as_str) .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| BitFunError::validation("Missing required field: app_id"))?; + .filter(|value| !value.is_empty()); + let app_name = input + .get("app_name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + // Resolve the app through the running MiniApp manager — never via the + // filesystem. Every failure path carries the installed-app roster so + // the model can correct itself in the next call instead of going + // hunting for ids elsewhere. + let app = if let Some(app_id) = app_id { + match manager.get(app_id).await { + Ok(app) => app, + Err(error) => { + let apps = manager.list().await.unwrap_or_default(); + return Err(BitFunError::validation(format!( + "Installed MiniApp '{app_id}' not found: {error}\nInstalled apps (id — name):\n{}", + installed_roster(apps.iter()) + ))); + } + } + } else if let Some(app_name) = app_name { + let apps = manager + .list() + .await + .map_err(|e| BitFunError::tool(format!("Could not list installed MiniApps: {e}")))?; + let matches = find_apps_by_name(&apps, app_name); + match matches.as_slice() { + [only] => manager + .get(&only.id) + .await + .map_err(|e| BitFunError::tool(format!("Installed MiniApp not found: {e}")))?, + [] => { + return Err(BitFunError::validation(format!( + "No installed MiniApp is named '{app_name}'. Installed apps (id — name):\n{}", + installed_roster(apps.iter()) + ))); + } + many => { + return Err(BitFunError::validation(format!( + "'{app_name}' matches several installed MiniApps — call again with the app_id:\n{}", + installed_roster(many.iter().copied()) + ))); + } + } + } else { + let apps = manager.list().await.unwrap_or_default(); + return Err(BitFunError::validation(format!( + "Provide app_name (the display name the user used) or app_id. Installed apps (id — name):\n{}", + installed_roster(apps.iter()) + ))); + }; + let screenshot_paths: Vec = input .get("screenshot_paths") .and_then(Value::as_array) @@ -149,11 +216,6 @@ Publishing is an outward-facing action: only call this when the user explicitly } } - let app = manager - .get(app_id) - .await - .map_err(|e| BitFunError::tool(format!("Installed MiniApp not found: {e}")))?; - let mut client = MarketClient::from_environment() .await .map_err(|e| BitFunError::tool(format!("MiniApp market unavailable: {e}")))?; @@ -371,6 +433,52 @@ Publishing is an outward-facing action: only call this when the user explicitly } } +/// Every display name an installed app answers to, lower-cased: the manifest +/// name plus each locale's i18n name. +fn display_names(meta: &MiniAppMeta) -> Vec { + let mut names = vec![meta.name.trim().to_lowercase()]; + if let Some(i18n) = &meta.i18n { + for strings in i18n.locales.values() { + if let Some(name) = &strings.name { + names.push(name.trim().to_lowercase()); + } + } + } + names +} + +/// Match a user-supplied display name against installed apps: exact +/// (case-insensitive, any locale) first, substring as a fallback. +fn find_apps_by_name<'a>(apps: &'a [MiniAppMeta], needle: &str) -> Vec<&'a MiniAppMeta> { + let needle = needle.trim().to_lowercase(); + if needle.is_empty() { + return Vec::new(); + } + let exact: Vec<&MiniAppMeta> = apps + .iter() + .filter(|meta| display_names(meta).iter().any(|name| *name == needle)) + .collect(); + if !exact.is_empty() { + return exact; + } + apps.iter() + .filter(|meta| display_names(meta).iter().any(|name| name.contains(&needle))) + .collect() +} + +/// "id — name" lines for error messages, capped to keep the result readable. +fn installed_roster<'a>(apps: impl Iterator) -> String { + let lines: Vec = apps + .take(40) + .map(|meta| format!("{} — {}", meta.id, meta.name)) + .collect(); + if lines.is_empty() { + "(no MiniApps installed)".to_string() + } else { + lines.join("\n") + } +} + fn unix_now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -380,8 +488,9 @@ fn unix_now() -> i64 { #[cfg(test)] mod tests { - use super::PublishMiniAppTool; + use super::{find_apps_by_name, PublishMiniAppTool}; use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; + use bitfun_product_domains::miniapp::types::MiniAppMeta; use serde_json::json; #[test] @@ -407,10 +516,81 @@ mod tests { } #[test] - fn publish_miniapp_schema_requires_app_and_screenshots() { + fn publish_miniapp_schema_requires_only_screenshots() { let tool = PublishMiniAppTool::new(); let schema = tool.input_schema(); - assert_eq!(schema["required"], json!(["app_id", "screenshot_paths"])); + assert_eq!(schema["required"], json!(["screenshot_paths"])); + assert!(schema["properties"]["app_name"].is_object()); + assert!(schema["properties"]["app_id"].is_object()); assert_eq!(schema["additionalProperties"], json!(false)); } + + #[test] + fn publish_miniapp_permission_identity_falls_back_to_app_name() { + let tool = PublishMiniAppTool::new(); + let context = ToolUseContext::for_tool_listing(None, None); + let intents = tool + .permission_intents(&json!({ "app_name": "循天问命" }), &context) + .expect("permission intent"); + assert_eq!( + intents[0].resources, + ["miniapp:PublishMiniApp:循天问命".to_string()] + ); + } + + fn meta(id: &str, name: &str, locale_name: Option<&str>) -> MiniAppMeta { + use bitfun_product_domains::miniapp::types::{MiniAppI18n, MiniAppLocaleStrings}; + use std::collections::HashMap; + MiniAppMeta { + id: id.to_string(), + name: name.to_string(), + description: String::new(), + icon: String::new(), + category: String::new(), + tags: Vec::new(), + version: 1, + created_at: 0, + updated_at: 0, + permissions: Default::default(), + ai_context: None, + runtime: Default::default(), + runtime_profile: Default::default(), + i18n: locale_name.map(|value| MiniAppI18n { + locales: HashMap::from([( + "en-US".to_string(), + MiniAppLocaleStrings { + name: Some(value.to_string()), + description: None, + tags: None, + }, + )]), + }), + } + } + + #[test] + fn find_apps_by_name_matches_any_locale_exactly() { + let apps = vec![ + meta("a1", "循天问命", Some("BaZi Chart")), + meta("a2", "五子棋", None), + ]; + let by_zh = find_apps_by_name(&apps, "循天问命"); + assert_eq!(by_zh.len(), 1); + assert_eq!(by_zh[0].id, "a1"); + let by_en = find_apps_by_name(&apps, "bazi chart"); + assert_eq!(by_en.len(), 1); + assert_eq!(by_en[0].id, "a1"); + } + + #[test] + fn find_apps_by_name_falls_back_to_substring_and_reports_ambiguity() { + let apps = vec![ + meta("a1", "循天问命", None), + meta("a2", "问命笺", None), + ]; + let partial = find_apps_by_name(&apps, "问命"); + assert_eq!(partial.len(), 2); + assert!(find_apps_by_name(&apps, "不存在").is_empty()); + assert!(find_apps_by_name(&apps, " ").is_empty()); + } }