diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 8a3186646f..5bcea293cb 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**323 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**324 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **323** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **324** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -33,14 +33,14 @@ Docs, website, search, and agents see only features, settings, and documented su ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **48**、委托 **62**、需交互 **213**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **213**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 323 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 324 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **62 delegated**, **213 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **213 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 323 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 324 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index b1eeb79e94..2c3be0e77f 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "dbed6a03474e7ad0368377e1a1dfc8eb9ba43b88e57864ddbec2986f3fc3eb2c", - "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", + "digest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", + "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,9 +138,9 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 323, + "documentedItems": 324, "controlCoverage": { - "direct": 48, + "direct": 49, "delegated": 62, "interactive": 213, "unsupported": 0 @@ -11969,6 +11969,7 @@ "id": "setting.application.input:query", "capabilityId": "setting.application.input", "itemIds": [ + "auto-show-selection-toolbar", "voice-enabled", "language", "microphone-test", @@ -12014,6 +12015,50 @@ "pageId": "application.voice" } }, + { + "id": "setting.application.input:option:auto-show-selection-toolbar", + "capabilityId": "setting.application.input", + "itemIds": [ + "auto-show-selection-toolbar" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "boolean" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "application.voice" + } + }, { "id": "setting.application.input:option:voice-enabled", "capabilityId": "setting.application.input", @@ -25736,11 +25781,14 @@ "id": "setting.application.input", "kind": "setting", "categoryId": "application", - "titleZh": "语音", - "titleEn": "Voice", - "summaryZh": "配置语音输入、麦克风、识别语言、本地模型与实时语音通话。", - "summaryEn": "Configure voice input, microphones, recognition language, local models, and realtime voice calls.", + "titleZh": "输入与交互", + "titleEn": "Input & interaction", + "summaryZh": "配置对话文本选择操作、语音输入、麦克风、识别语言与本地模型。", + "summaryEn": "Configure conversation text selection actions, voice input, microphones, recognition language, and local models.", "keywordsZh": [ + "选中文字", + "批注", + "侧边提问", "语音设置", "语音", "麦克风", @@ -25748,6 +25796,9 @@ "语音识别" ], "keywordsEn": [ + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", @@ -25755,16 +25806,37 @@ "speech recognition" ], "highlightsZh": [ + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话" ], "highlightsEn": [ + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls" ], "items": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "开启或关闭选中对话文本后的自动操作栏", + "titleEn": "Enable or disable automatic actions after selecting conversation text", + "destination": { + "kind": "settings", + "pageId": "application.voice", + "sectionId": "text-selection" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "auto-show-selection-toolbar" + } + ] + } + }, { "id": "voice-enabled", "titleZh": "在聊天输入框启用或停用语音输入", @@ -25852,11 +25924,11 @@ ], "stepsZh": [ "打开设置", - "进入“应用与界面 > 语音”" + "进入“应用与界面 > 输入与交互”" ], "stepsEn": [ "Open Settings", - "Go to App & Interface > Voice" + "Go to App & Interface > Input & Interaction" ], "agentExamplesZh": [ "打开语音输入设置", @@ -25872,6 +25944,16 @@ }, "operations": [], "options": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "自动显示选中文字操作栏", + "titleEn": "Automatically show text selection actions", + "descriptionZh": "选中对话文本后自动显示批注和提问操作;关闭后仍可使用右键菜单和提问快捷键。", + "descriptionEn": "Automatically show annotation and question actions after selecting conversation text; right-click actions and the question shortcut remain available when disabled.", + "valueSchema": { + "type": "boolean" + } + }, { "id": "voice-enabled", "titleZh": "启用语音输入", @@ -25914,25 +25996,36 @@ ], "searchTerms": [ "setting.application.input", - "语音", - "Voice", + "输入与交互", + "Input & interaction", "应用设置", "Application settings", + "选中文字", + "批注", + "侧边提问", "语音设置", + "语音", "麦克风", "听写", "语音识别", + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", "dictation", "speech recognition", + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话", + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls", + "开启或关闭选中对话文本后的自动操作栏", + "Enable or disable automatic actions after selecting conversation text", "在聊天输入框启用或停用语音输入", "Enable or disable voice input in the chat composer", "选择自动检测或中文、粤语、英文、日文等识别语言", diff --git a/docs/interactive-capabilities/capabilities/setting.application.input.md b/docs/interactive-capabilities/capabilities/setting.application.input.md index 5a5b633798..a077fdea51 100644 --- a/docs/interactive-capabilities/capabilities/setting.application.input.md +++ b/docs/interactive-capabilities/capabilities/setting.application.input.md @@ -3,20 +3,22 @@ id: setting.application.input kind: setting category: application -title_zh: "语音" -title_en: "Voice" +title_zh: "输入与交互" +title_en: "Input & interaction" --- -# 语音 / Voice +# 输入与交互 / Input & interaction > 设置 / Setting -配置语音输入、麦克风、识别语言、本地模型与实时语音通话。 +配置对话文本选择操作、语音输入、麦克风、识别语言与本地模型。 -Configure voice input, microphones, recognition language, local models, and realtime voice calls. +Configure conversation text selection actions, voice input, microphones, recognition language, and local models. ## 完整功能清单 / Everything included +- **智能体可直接控制 / Direct Agent control** · 开启或关闭选中对话文本后的自动操作栏 + - Enable or disable automatic actions after selecting conversation text - **智能体可直接控制 / Direct Agent control** · 在聊天输入框启用或停用语音输入 - Enable or disable voice input in the chat composer - **智能体可直接控制 / Direct Agent control** · 选择自动检测或中文、粤语、英文、日文等识别语言 @@ -32,8 +34,8 @@ Configure voice input, microphones, recognition language, local models, and real 1. 打开设置 Open Settings -2. 进入“应用与界面 > 语音” - Go to App & Interface > Voice +2. 进入“应用与界面 > 输入与交互” + Go to App & Interface > Input & Interaction 入口 / Entry: OpenBitFun 设置 @@ -47,6 +49,7 @@ Configure voice input, microphones, recognition language, local models, and real | 选项 / Option | 可用值 / Values | 中文说明 | English description | | --- | --- | --- | --- | +| 自动显示选中文字操作栏 / Automatically show text selection actions | `boolean` | 选中对话文本后自动显示批注和提问操作;关闭后仍可使用右键菜单和提问快捷键。 | Automatically show annotation and question actions after selecting conversation text; right-click actions and the question shortcut remain available when disabled. | | 启用语音输入 / Enable voice input | `boolean` | 在聊天输入框显示并启用录音入口。 | Show and enable recording in the chat composer. | | 默认识别语言 / Default recognition language | `auto` / `zh` / `en` / `ja` / `yue` | 选择自动检测、中文、英文、日文或粤语。 | Choose automatic detection, Chinese, English, Japanese, or Cantonese. | | 最长录音时间 / Maximum recording duration | `integer` (10–600) | 单次语音输入允许的最长秒数。 | Maximum seconds allowed for one voice input. | diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index f841590937..7a668e4f4e 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "dbed6a03474e7ad0368377e1a1dfc8eb9ba43b88e57864ddbec2986f3fc3eb2c", + "catalogDigest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", "count": 213, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index bb74169bf8..a380cf6484 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "dbed6a03474e7ad0368377e1a1dfc8eb9ba43b88e57864ddbec2986f3fc3eb2c", + "catalogDigest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", "commandCount": 669, "coverage": { "commandCount": 669, diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 7484ff61f9..668a212de4 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -190,6 +190,12 @@ Narrower local guides already exist for some subtrees: ## Verification +For config-backed product-control option defaults, typed binding, and persisted readback: + +```bash +cargo test -p openbitfun-core --no-default-features --features agent-runtime,git --lib every_catalog_config_option +``` + AI client construction and subscription credential compatibility: ```bash diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 265005f753..cadcf3a25e 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -437,6 +437,9 @@ pub struct AppFlowChatConfig { skip_serializing_if = "is_permission_mode_control_visible" )] pub show_permission_mode_control: bool, + /// Automatically show excerpt actions after selecting transcript text. + #[serde(default = "default_auto_show_selection_toolbar")] + pub auto_show_selection_toolbar: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -454,6 +457,10 @@ fn is_permission_mode_control_visible(value: &bool) -> bool { *value } +fn default_auto_show_selection_toolbar() -> bool { + true +} + impl Default for AppFlowChatConfig { fn default() -> Self { Self { @@ -461,6 +468,7 @@ impl Default for AppFlowChatConfig { default_mode_id: None, last_mode_id: None, show_permission_mode_control: default_show_permission_mode_control(), + auto_show_selection_toolbar: default_auto_show_selection_toolbar(), } } } @@ -3036,6 +3044,42 @@ mod tests { ); } + #[test] + fn app_flow_chat_selection_toolbar_defaults_and_round_trips() { + // Old payloads retain automatic display and remain readable after saving. + let legacy = serde_json::json!({ "default_mode_id": "Standard" }); + let config: super::AppFlowChatConfig = serde_json::from_value(legacy.clone()).unwrap(); + assert!(config.auto_show_selection_toolbar); + let written = serde_json::to_value(&config).unwrap(); + assert_eq!(written["default_mode_id"], legacy["default_mode_id"]); + let reloaded: super::AppFlowChatConfig = serde_json::from_value(written).unwrap(); + assert!(reloaded.auto_show_selection_toolbar); + + let disabled = serde_json::json!({ + "auto_show_selection_toolbar": false, + "show_permission_mode_control": false, + "default_mode_id": "Standard" + }); + let config: super::AppFlowChatConfig = serde_json::from_value(disabled.clone()).unwrap(); + assert!(!config.auto_show_selection_toolbar); + let written = serde_json::to_value(&config).unwrap(); + assert_eq!(written, disabled); + let reloaded: super::AppFlowChatConfig = serde_json::from_value(written).unwrap(); + assert!(!reloaded.auto_show_selection_toolbar); + + let enabled: super::AppFlowChatConfig = serde_json::from_value(serde_json::json!({ + "auto_show_selection_toolbar": true, + "future_field": "tolerated" + })) + .unwrap(); + assert!(enabled.auto_show_selection_toolbar); + assert_eq!( + serde_json::to_value(enabled).unwrap(), + serde_json::json!({ "auto_show_selection_toolbar": true }) + ); + assert!(super::AppFlowChatConfig::default().auto_show_selection_toolbar); + } + #[test] fn default_model_serialization_contains_no_retired_reasoning_fields() { let config = AIModelConfig::default(); diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 8d3712af8f..53c968faba 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "dbed6a03474e7ad0368377e1a1dfc8eb9ba43b88e57864ddbec2986f3fc3eb2c", - "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", + "digest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", + "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,9 +138,9 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 323, + "documentedItems": 324, "controlCoverage": { - "direct": 48, + "direct": 49, "delegated": 62, "interactive": 213, "unsupported": 0 @@ -11969,6 +11969,7 @@ "id": "setting.application.input:query", "capabilityId": "setting.application.input", "itemIds": [ + "auto-show-selection-toolbar", "voice-enabled", "language", "microphone-test", @@ -12014,6 +12015,50 @@ "pageId": "application.voice" } }, + { + "id": "setting.application.input:option:auto-show-selection-toolbar", + "capabilityId": "setting.application.input", + "itemIds": [ + "auto-show-selection-toolbar" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "boolean" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "application.voice" + } + }, { "id": "setting.application.input:option:voice-enabled", "capabilityId": "setting.application.input", @@ -25835,11 +25880,14 @@ "id": "setting.application.input", "kind": "setting", "categoryId": "application", - "titleZh": "语音", - "titleEn": "Voice", - "summaryZh": "配置语音输入、麦克风、识别语言、本地模型与实时语音通话。", - "summaryEn": "Configure voice input, microphones, recognition language, local models, and realtime voice calls.", + "titleZh": "输入与交互", + "titleEn": "Input & interaction", + "summaryZh": "配置对话文本选择操作、语音输入、麦克风、识别语言与本地模型。", + "summaryEn": "Configure conversation text selection actions, voice input, microphones, recognition language, and local models.", "keywordsZh": [ + "选中文字", + "批注", + "侧边提问", "语音设置", "语音", "麦克风", @@ -25847,6 +25895,9 @@ "语音识别" ], "keywordsEn": [ + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", @@ -25854,16 +25905,37 @@ "speech recognition" ], "highlightsZh": [ + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话" ], "highlightsEn": [ + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls" ], "items": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "开启或关闭选中对话文本后的自动操作栏", + "titleEn": "Enable or disable automatic actions after selecting conversation text", + "destination": { + "kind": "settings", + "pageId": "application.voice", + "sectionId": "text-selection" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "auto-show-selection-toolbar" + } + ] + } + }, { "id": "voice-enabled", "titleZh": "在聊天输入框启用或停用语音输入", @@ -25951,11 +26023,11 @@ ], "stepsZh": [ "打开设置", - "进入“应用与界面 > 语音”" + "进入“应用与界面 > 输入与交互”" ], "stepsEn": [ "Open Settings", - "Go to App & Interface > Voice" + "Go to App & Interface > Input & Interaction" ], "agentExamplesZh": [ "打开语音输入设置", @@ -25971,6 +26043,20 @@ }, "operations": [], "options": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "自动显示选中文字操作栏", + "titleEn": "Automatically show text selection actions", + "descriptionZh": "选中对话文本后自动显示批注和提问操作;关闭后仍可使用右键菜单和提问快捷键。", + "descriptionEn": "Automatically show annotation and question actions after selecting conversation text; right-click actions and the question shortcut remain available when disabled.", + "valueSchema": { + "type": "boolean" + }, + "handler": { + "kind": "config", + "path": "app.flow_chat.auto_show_selection_toolbar" + } + }, { "id": "voice-enabled", "titleZh": "启用语音输入", @@ -26034,25 +26120,36 @@ ], "searchTerms": [ "setting.application.input", - "语音", - "Voice", + "输入与交互", + "Input & interaction", "应用设置", "Application settings", + "选中文字", + "批注", + "侧边提问", "语音设置", + "语音", "麦克风", "听写", "语音识别", + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", "dictation", "speech recognition", + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话", + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls", + "开启或关闭选中对话文本后的自动操作栏", + "Enable or disable automatic actions after selecting conversation text", "在聊天输入框启用或停用语音输入", "Enable or disable voice input in the chat composer", "选择自动检测或中文、粤语、英文、日文等识别语言", diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index cf1dbf7954..8b9da366b7 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -423,6 +423,12 @@ pub fn owner_definitions() -> Vec { boolean(), merge_config("app.ai_experience", &["voice_input.enabled"]), ), + option( + "setting.application.input", + "auto-show-selection-toolbar", + boolean(), + config("app.flow_chat.auto_show_selection_toolbar"), + ), option( "setting.application.input", "voice-language", diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index ee01bc3815..aef7ab5965 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -5832,11 +5832,14 @@ "id": "setting.application.input", "kind": "setting", "categoryId": "application", - "titleZh": "语音", - "titleEn": "Voice", - "summaryZh": "配置语音输入、麦克风、识别语言、本地模型与实时语音通话。", - "summaryEn": "Configure voice input, microphones, recognition language, local models, and realtime voice calls.", + "titleZh": "输入与交互", + "titleEn": "Input & interaction", + "summaryZh": "配置对话文本选择操作、语音输入、麦克风、识别语言与本地模型。", + "summaryEn": "Configure conversation text selection actions, voice input, microphones, recognition language, and local models.", "keywordsZh": [ + "选中文字", + "批注", + "侧边提问", "语音设置", "语音", "麦克风", @@ -5844,6 +5847,9 @@ "语音识别" ], "keywordsEn": [ + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", @@ -5851,16 +5857,37 @@ "speech recognition" ], "highlightsZh": [ + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话" ], "highlightsEn": [ + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls" ], "items": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "开启或关闭选中对话文本后的自动操作栏", + "titleEn": "Enable or disable automatic actions after selecting conversation text", + "destination": { + "kind": "settings", + "pageId": "application.voice", + "sectionId": "text-selection" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [{ "id": "auto-show-selection-toolbar" }] + }, + "evidence": [ + "source:src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.tsx#AUTO_SHOW_SELECTION_TOOLBAR_CONFIG_PATH", + "source:src/web-ui/src/locales/zh-CN/settings/application.json#textSelection.autoShowToolbar" + ] + }, { "id": "voice-enabled", "titleZh": "在聊天输入框启用或停用语音输入", @@ -5967,11 +5994,11 @@ ], "stepsZh": [ "打开设置", - "进入“应用与界面 > 语音”" + "进入“应用与界面 > 输入与交互”" ], "stepsEn": [ "Open Settings", - "Go to App & Interface > Voice" + "Go to App & Interface > Input & Interaction" ], "agentExamplesZh": [ "打开语音输入设置", @@ -5987,6 +6014,13 @@ }, "operations": [], "options": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "自动显示选中文字操作栏", + "titleEn": "Automatically show text selection actions", + "descriptionZh": "选中对话文本后自动显示批注和提问操作;关闭后仍可使用右键菜单和提问快捷键。", + "descriptionEn": "Automatically show annotation and question actions after selecting conversation text; right-click actions and the question shortcut remain available when disabled." + }, { "id": "voice-enabled", "titleZh": "启用语音输入", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index b1eeb79e94..2c3be0e77f 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "dbed6a03474e7ad0368377e1a1dfc8eb9ba43b88e57864ddbec2986f3fc3eb2c", - "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", + "digest": "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a", + "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,9 +138,9 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 323, + "documentedItems": 324, "controlCoverage": { - "direct": 48, + "direct": 49, "delegated": 62, "interactive": 213, "unsupported": 0 @@ -11969,6 +11969,7 @@ "id": "setting.application.input:query", "capabilityId": "setting.application.input", "itemIds": [ + "auto-show-selection-toolbar", "voice-enabled", "language", "microphone-test", @@ -12014,6 +12015,50 @@ "pageId": "application.voice" } }, + { + "id": "setting.application.input:option:auto-show-selection-toolbar", + "capabilityId": "setting.application.input", + "itemIds": [ + "auto-show-selection-toolbar" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "boolean" + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "application.voice" + } + }, { "id": "setting.application.input:option:voice-enabled", "capabilityId": "setting.application.input", @@ -25736,11 +25781,14 @@ "id": "setting.application.input", "kind": "setting", "categoryId": "application", - "titleZh": "语音", - "titleEn": "Voice", - "summaryZh": "配置语音输入、麦克风、识别语言、本地模型与实时语音通话。", - "summaryEn": "Configure voice input, microphones, recognition language, local models, and realtime voice calls.", + "titleZh": "输入与交互", + "titleEn": "Input & interaction", + "summaryZh": "配置对话文本选择操作、语音输入、麦克风、识别语言与本地模型。", + "summaryEn": "Configure conversation text selection actions, voice input, microphones, recognition language, and local models.", "keywordsZh": [ + "选中文字", + "批注", + "侧边提问", "语音设置", "语音", "麦克风", @@ -25748,6 +25796,9 @@ "语音识别" ], "keywordsEn": [ + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", @@ -25755,16 +25806,37 @@ "speech recognition" ], "highlightsZh": [ + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话" ], "highlightsEn": [ + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls" ], "items": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "开启或关闭选中对话文本后的自动操作栏", + "titleEn": "Enable or disable automatic actions after selecting conversation text", + "destination": { + "kind": "settings", + "pageId": "application.voice", + "sectionId": "text-selection" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "auto-show-selection-toolbar" + } + ] + } + }, { "id": "voice-enabled", "titleZh": "在聊天输入框启用或停用语音输入", @@ -25852,11 +25924,11 @@ ], "stepsZh": [ "打开设置", - "进入“应用与界面 > 语音”" + "进入“应用与界面 > 输入与交互”" ], "stepsEn": [ "Open Settings", - "Go to App & Interface > Voice" + "Go to App & Interface > Input & Interaction" ], "agentExamplesZh": [ "打开语音输入设置", @@ -25872,6 +25944,16 @@ }, "operations": [], "options": [ + { + "id": "auto-show-selection-toolbar", + "titleZh": "自动显示选中文字操作栏", + "titleEn": "Automatically show text selection actions", + "descriptionZh": "选中对话文本后自动显示批注和提问操作;关闭后仍可使用右键菜单和提问快捷键。", + "descriptionEn": "Automatically show annotation and question actions after selecting conversation text; right-click actions and the question shortcut remain available when disabled.", + "valueSchema": { + "type": "boolean" + } + }, { "id": "voice-enabled", "titleZh": "启用语音输入", @@ -25914,25 +25996,36 @@ ], "searchTerms": [ "setting.application.input", - "语音", - "Voice", + "输入与交互", + "Input & interaction", "应用设置", "Application settings", + "选中文字", + "批注", + "侧边提问", "语音设置", + "语音", "麦克风", "听写", "语音识别", + "text selection", + "annotation", + "selection toolbar", "voice settings", "voice", "microphone", "dictation", "speech recognition", + "选中文字操作栏的自动显示开关", "语音输入与本地模型", "识别语言和最长录音时间", "实时语音通话", + "Automatic text selection toolbar preference", "Voice input and local models", "Recognition language and maximum recording time", "Realtime voice calls", + "开启或关闭选中对话文本后的自动操作栏", + "Enable or disable automatic actions after selecting conversation text", "在聊天输入框启用或停用语音输入", "Enable or disable voice input in the chat composer", "选择自动检测或中文、粤语、英文、日文等识别语言", diff --git a/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts b/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts index fc062ba671..ff3db7b970 100644 --- a/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts +++ b/src/web-ui/src/app/global-search/interactiveCapabilityActivator.test.ts @@ -61,6 +61,7 @@ describe('activateInteractiveCapability', () => { it.each([ ['feature.computer-use', undefined, { pageId: 'tools.desktop-control' }], ['setting.application.input', 'voice-enabled', { pageId: 'application.input', sectionId: 'voice' }], + ['setting.application.input', 'auto-show-selection-toolbar', { pageId: 'application.input', sectionId: 'text-selection' }], ['setting.application.appearance', 'language', { pageId: 'application.general' }], ['setting.workspace.session', 'default-agent-harness', { pageId: 'ai.session-memory', sectionId: 'session' }], ['setting.workspace.session', 'accelerated-search', { pageId: 'development.workspace', sectionId: 'workspace-search' }], diff --git a/src/web-ui/src/app/scenes/settings/pages/application/InputSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/application/InputSettingsPage.tsx index 92048eb38b..2ff25eccd4 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/InputSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/InputSettingsPage.tsx @@ -1,10 +1,12 @@ import { SettingsPage } from '../shared/SettingsPage'; import KeyboardShortcutsSection from './KeyboardShortcutsSection'; import VoiceSettingsSection from './VoiceSettingsSection'; +import TextSelectionSettingsSection from './TextSelectionSettingsSection'; export default function InputSettingsPage() { return ( + diff --git a/src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.test.tsx new file mode 100644 index 0000000000..2ab423bf14 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import TextSelectionSettingsSection from './TextSelectionSettingsSection'; + +const mocks = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn(), error: vi.fn(), listeners: new Set<() => void>() })); +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { + getOptionalConfig: mocks.get, + setConfig: mocks.set, + watch: (_path: string, listener: () => void) => { + mocks.listeners.add(listener); + return () => mocks.listeners.delete(listener); + }, +} })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@/shared/notification-system', () => ({ notificationService: { error: mocks.error } })); + +describe('text selection settings persistence', () => { + let root: Root; + let container: HTMLDivElement; + const toggle = () => container.querySelector('[role="switch"]')!; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + activateSurface('local'); + mocks.get.mockReset().mockResolvedValue(undefined); + mocks.set.mockReset().mockImplementation(async (_path: string, value: boolean) => { + mocks.get.mockResolvedValue(value); + mocks.listeners.forEach(listener => listener()); + }); + mocks.error.mockReset(); + mocks.listeners.clear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { act(() => root.unmount()); container.remove(); }); + + it('persists only the preference leaf and presents the saved value', async () => { + await act(async () => root.render()); + expect(toggle().checked).toBe(true); + await act(async () => toggle().click()); + expect(mocks.set).toHaveBeenCalledWith('app.flow_chat.auto_show_selection_toolbar', false); + expect(toggle().checked).toBe(false); + expect(toggle().disabled).toBe(false); + }); + + it('retains the persisted value and reports a failed save', async () => { + mocks.set.mockRejectedValue(new Error('Write failed')); + await act(async () => root.render()); + await act(async () => toggle().click()); + expect(toggle().checked).toBe(true); + expect(mocks.error).toHaveBeenCalledWith('textSelection.saveFailed'); + expect(toggle().disabled).toBe(false); + }); + + it('shows a retry state on load failure and keeps the switch unavailable until recovery', async () => { + mocks.get.mockRejectedValue(new Error('Host unavailable')); + await act(async () => root.render()); + expect(toggle()).toBeNull(); + expect(container.textContent).toContain('textSelection.loadFailed'); + mocks.get.mockResolvedValue(false); + await act(async () => container.querySelector('button')!.click()); + expect(toggle().checked).toBe(false); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.tsx new file mode 100644 index 0000000000..cbdde23629 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/application/TextSelectionSettingsSection.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { Switch } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { + ConfigLoadingState, ConfigPageRow, ConfigPageSection, ConfigRetryState, +} from '@/infrastructure/config/components/common'; +import { + AUTO_SHOW_SELECTION_TOOLBAR_CONFIG_PATH, useSelectionToolbarPreference, +} from '@/infrastructure/config/hooks/useSelectionToolbarPreference'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { useSettingsSectionAnchor } from '../shared/useSettingsSectionAnchor'; + +const log = createLogger('TextSelectionSettingsSection'); + +export default function TextSelectionSettingsSection() { + const { t } = useI18n('settings/application'); + const { enabled, error, reload } = useSelectionToolbarPreference(); + const [savingEpoch, setSavingEpoch] = useState(null); + const scope = getActiveSurfaceScope(); + const saving = savingEpoch === scope.epoch; + const sectionAnchor = useSettingsSectionAnchor('text-selection', enabled !== null || !!error); + + const save = async (value: boolean) => { + if (saving || enabled === null) return; + setSavingEpoch(scope.epoch); + try { + await configManager.setConfig(AUTO_SHOW_SELECTION_TOOLBAR_CONFIG_PATH, value); + } catch (reason) { + if (scope.isCurrent()) { + log.error('Failed to save selection toolbar preference', reason); + notificationService.error(t('textSelection.saveFailed')); + } + } finally { + if (scope.isCurrent()) setSavingEpoch(null); + } + }; + + return ( +
+ + {error ? void reload()} /> + : enabled === null ? + : + void save(value)} + aria-label={t('textSelection.autoShowToolbar')} /> + } + +
+ ); +} diff --git a/src/web-ui/src/app/scenes/settings/settingsDestinationMigrations.json b/src/web-ui/src/app/scenes/settings/settingsDestinationMigrations.json index beec60b9e1..cd4139e599 100644 --- a/src/web-ui/src/app/scenes/settings/settingsDestinationMigrations.json +++ b/src/web-ui/src/app/scenes/settings/settingsDestinationMigrations.json @@ -163,6 +163,14 @@ } }, "capabilities": { + "setting.application.input": { + "items": { + "auto-show-selection-toolbar": { + "pageId": "application.input", + "sectionId": "text-selection" + } + } + }, "feature.computer-use": { "destination": { "pageId": "tools.desktop-control" diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts index a93929adca..0bf83b5f84 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts @@ -27,7 +27,7 @@ describe('settings information architecture', () => { expect(new Set(SETTINGS_PAGE_MANIFESTS.map(page => page.id)).size).toBe(19); expect(SETTINGS_PAGE_MANIFESTS.filter(page => page.views?.length).map(page => page.id)).toEqual(['tools.external-agents']); expect(getSettingsPageManifest('tools.external-agents').views?.map(view => view.id)).toEqual(['local', 'ssh', 'json']); - expect(getSettingsPageManifest('application.input').sections?.map(section => section.id)).toEqual(['voice', 'shortcuts']); + expect(getSettingsPageManifest('application.input').sections?.map(section => section.id)).toEqual(['text-selection', 'voice', 'shortcuts']); expect(getSettingsPageManifest('application.pet').sections?.map(section => section.id)).toEqual(['voice-call', 'pet']); expect(getSettingsPageManifest('tools.automation').sections?.map(section => section.id)).toEqual(['quick-actions', 'hooks']); }); diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index 34b39fd952..357ab248bc 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -117,10 +117,16 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ categoryId: 'application', labelKey: 'navigation.pages.input.label', descriptionKey: 'navigation.pages.input.description', - keywords: ['input', 'voice', 'speech', 'keyboard', 'shortcut', 'keybinding', 'hotkey'], - namespaces: ['settings', 'settings/voice-input'], + keywords: ['input', 'voice', 'speech', 'keyboard', 'shortcut', 'keybinding', 'hotkey', 'selection', 'annotation', 'excerpt'], + namespaces: ['settings', 'settings/application', 'settings/voice-input'], searchPhrases: [], sections: [ + { + id: 'text-selection', + labelKey: 'navigation.sections.text-selection', + keywords: ['selection', 'annotation', 'excerpt', 'toolbar', 'popup', 'flowchat'], + searchPhrases: [phrase('settings/application', 'textSelection.title'), phrase('settings/application', 'textSelection.autoShowToolbar'), phrase('settings/application', 'textSelection.autoShowToolbarDescription')], + }, { id: 'voice', labelKey: 'navigation.sections.voice', diff --git a/src/web-ui/src/app/scenes/settings/settingsTypes.ts b/src/web-ui/src/app/scenes/settings/settingsTypes.ts index a5298c083d..15227ae32d 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTypes.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTypes.ts @@ -33,6 +33,7 @@ export type SettingsViewId = /** Anchors in a single settings page, not nested pages or tab panels. */ export type SettingsSectionId = + | 'text-selection' | 'pet' | 'voice-call' | 'voice' diff --git a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx index 09230141c7..2f372b5cd4 100644 --- a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx +++ b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx @@ -10,20 +10,35 @@ import { FLOWCHAT_EXCERPT_ACTION, type ExcerptActionRequest } from './excerptAct import { highlightExcerptRange } from './locateConversationExcerpt'; const state = vi.hoisted(() => ({ - sessions: new Map([['main', { sessionId: 'main', title: 'Source session', workspacePath: '/workspace' }]]), + sessions: new Map([ + ['main', { sessionId: 'main', title: 'Source session', workspacePath: '/workspace' }], + ['btw', { sessionId: 'btw', title: 'Side session', workspacePath: '/workspace', sessionKind: 'btw' }], + ]), t: (key: string) => key, + getPreference: vi.fn(), + preferenceListeners: new Set<() => void>(), + selectionIntent: vi.fn(), })); +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { + getOptionalConfig: state.getPreference, + watch: (_path: string, callback: () => void) => { + state.preferenceListeners.add(callback); + return () => state.preferenceListeners.delete(callback); + }, +} })); +vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: state.sessions }) } })); vi.mock('../session-drivers/resolve', () => ({ resolveSessionDriverId: () => 'local' })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: state.t }) })); -function Transcript() { +function Transcript({ sessionId = 'main' }: { sessionId?: string }) { const rootRef = useRef(null); return <> -
+
Selected source text
- + ; } @@ -45,7 +60,7 @@ describe('selection toolbar and annotation dialog lifecycle', () => { const resizeCallbacks = new Set<() => void>(); let textTop = 200; - beforeEach(() => { + beforeEach(async () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; vi.useFakeTimers(); activateSurface('local'); @@ -53,6 +68,9 @@ describe('selection toolbar and annotation dialog lifecycle', () => { resizeCallbacks.clear(); textTop = 200; requests.length = 0; + state.getPreference.mockReset().mockResolvedValue(undefined); + state.selectionIntent.mockClear(); + state.preferenceListeners.clear(); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; @@ -61,7 +79,7 @@ describe('selection toolbar and annotation dialog lifecycle', () => { container = document.createElement('div'); document.body.append(container); root = createRoot(container); - act(() => root.render()); + await act(async () => root.render()); window.addEventListener(FLOWCHAT_EXCERPT_ACTION, receive); }); @@ -224,6 +242,75 @@ describe('selection toolbar and annotation dialog lifecycle', () => { return textarea; } + async function setAutoShowToolbar(enabled: boolean) { + state.getPreference.mockResolvedValue(enabled); + await act(async () => state.preferenceListeners.forEach(callback => callback())); + } + + it.each(['main', 'btw'])('hides automatic actions in %s while retaining selection, reading intent, and the ask shortcut', async (sessionId) => { + await setAutoShowToolbar(false); + await act(async () => root.render()); + selectForToolbar(); + expect(toolbar()).toBeNull(); + expect(state.selectionIntent).toHaveBeenCalledOnce(); + expect(window.getSelection()?.toString()).toBe('Selected source text'); + act(() => document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'b', ctrlKey: true, altKey: true, bubbles: true, cancelable: true, + }))); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ action: 'ask', parentSessionId: 'main', excerpt: { source: { sessionId } } }); + }); + + it('retains right-click annotation and side questions when automatic actions are disabled', async () => { + await setAutoShowToolbar(false); + expect(await openAnnotation()).not.toBeNull(); + act(() => dialog().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + act(() => vi.advanceTimersByTime(180)); + const target = selectForToolbar(); + const context: SelectionContext = { + type: ContextType.SELECTION, event: new MouseEvent('contextmenu'), targetElement: target, + position: { x: 0, y: 0 }, timestamp: Date.now(), selectedText: window.getSelection()!.toString(), + selection: window.getSelection()!, isEditable: false, + }; + const provider = contextMenuRegistry.findMatchingProviders(context)[0]; + await act(async () => { + const items = await provider.getMenuItems(context); + expect(items[1].disabled).toBe(false); + await items[1].onClick?.(context); + }); + expect(requests).toHaveLength(1); + expect(requests[0].action).toBe('ask'); + expect(toolbar()).toBeNull(); + }); + + it('closes an existing toolbar immediately and allows a fresh selection after re-enabling', async () => { + const target = selectForToolbar(); + expect(toolbar()).not.toBeNull(); + await setAutoShowToolbar(false); + expect(toolbar()).toBeNull(); + expect(window.getSelection()?.toString()).toBe('Selected source text'); + await setAutoShowToolbar(true); + expect(toolbar()).toBeNull(); + act(() => { + window.getSelection()!.setBaseAndExtent(target.firstChild!, 1, target.firstChild!, 8); + document.dispatchEvent(new Event('selectionchange')); + }); + flushFrames(); + expect(toolbar()).not.toBeNull(); + }); + + it('does not discard an open annotation or its comment when automatic actions are disabled', async () => { + const surface = await openAnnotation(); + const textarea = enterComment('Keep this draft'); + await setAutoShowToolbar(false); + expect(dialog()).toBe(surface); + expect(textarea.value).toBe('Keep this draft'); + act(() => textarea.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true, + }))); + expect(requests[0]).toMatchObject({ action: 'annotate', excerpt: { comment: 'Keep this draft' } }); + }); + it('releases temporary paint without allowing an old locate timer to clear the next excerpt', () => { const highlights = new Map>(); vi.stubGlobal('CSS', { highlights }); diff --git a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx index 75666f4e5d..12a3d4edbf 100644 --- a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx +++ b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx @@ -23,6 +23,7 @@ import { highlightExcerptRange } from './locateConversationExcerpt'; import { contextMenuRegistry } from '@/shared/context-menu-system/core/ContextMenuRegistry'; import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; import { notificationService } from '@/shared/notification-system'; +import { useSelectionToolbarPreference } from '@/infrastructure/config/hooks/useSelectionToolbarPreference'; export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, active = true, onSelectionIntent }: { rootRef: RefObject; @@ -33,6 +34,9 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti }) { const { t } = useI18n('flow-chat'); const peer = usePeerDeviceModeOptional(); + const { enabled: autoShowToolbar } = useSelectionToolbarPreference(); + const autoShowToolbarRef = useRef(autoShowToolbar); + autoShowToolbarRef.current = autoShowToolbar; const menuId = useId(); const popupRef = useRef(null); const editorRef = useRef(null); @@ -67,7 +71,11 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti && (peer?.peerMode.active ? peer.currentPeerCapabilities?.hostKind === 'desktop' : isTauriRuntime()) && (!parentSessionId || child)); - useDismissibleLayer({ enabled: !!selection && active && !editing, layerRef: popupRef, onDismiss: clear }); + useDismissibleLayer({ enabled: !!selection && active && !editing && autoShowToolbar === true, layerRef: popupRef, onDismiss: clear }); + + useEffect(() => { + if (autoShowToolbar !== true && !editingRef.current) clear(); + }, [autoShowToolbar, clear]); useEffect(() => { clear(); @@ -91,7 +99,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti }; const capture = () => { frame = 0; - if (pressed || editingRef.current || useContextMenuStore.getState().visible + if (autoShowToolbarRef.current !== true || pressed || editingRef.current || useContextMenuStore.getState().visible || popupRef.current?.contains(owner.activeElement)) return; if (sameFlowChatSelection(lastCapturedRef.current, owner.getSelection())) return; const next = readSelection(); @@ -222,7 +230,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti useLayoutEffect(() => { const root = rootRef.current; - if (!selection || editing || !popupRef.current || !root) return; + if (autoShowToolbar !== true || !selection || editing || !popupRef.current || !root) return; const popup = popupRef.current; const initial = measureSelectionBarGeometry(root, selection); if (!initial) { clear(); return; } @@ -252,7 +260,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti mutation.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-openbitfun-viewport-inset-bottom'] }); return () => { cancelAnimationFrame(frame); observer.disconnect(); mutation.disconnect(); }; - }, [selection, editing, clear, rootRef]); + }, [selection, editing, autoShowToolbar, clear, rootRef]); useEffect(() => { if (!editing || !selection) return; @@ -277,7 +285,7 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti return ( <> - {selection && active && !editing && createOverlayPortal( + {selection && active && !editing && autoShowToolbar === true && createOverlayPortal( diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 7efdf7ee07..686d01d030 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "dbed6a03474e7ad0368377e1a1dfc8eb9ba43b88e57864ddbec2986f3fc3eb2c" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "f965bcc92d234b79484b5ec6a2fbd817c40e4c78119dd41b804fbbce3876fa5a" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; @@ -29,7 +29,7 @@ export interface ProductControlOptionIdsByCapability { "setting.application.general": "launch-at-login" | "prevent-sleep" | "auto-update" | "close-button-behavior" | "completion-notification" | "permission-notification" | "startup-tips"; "setting.application.appearance": "theme" | "language"; "setting.application.pet": "enabled"; - "setting.application.input": "voice-enabled" | "voice-language" | "max-recording-seconds"; + "setting.application.input": "auto-show-selection-toolbar" | "voice-enabled" | "voice-language" | "max-recording-seconds"; "setting.application.shortcuts": never; "setting.application.development": "editor-font-family" | "editor-font-weight" | "editor-font-size" | "editor-line-height" | "editor-cursor-style" | "editor-cursor-blinking" | "editor-tab-size" | "editor-insert-spaces" | "editor-detect-indentation" | "editor-word-wrap" | "editor-line-numbers" | "editor-smooth-scrolling" | "editor-minimap-enabled" | "editor-minimap-side" | "editor-minimap-size" | "editor-render-whitespace" | "editor-render-line-highlight" | "editor-scroll-beyond-last-line" | "editor-auto-save" | "editor-auto-save-delay" | "editor-semantic-highlighting" | "editor-bracket-pair-colorization" | "editor-format-on-save" | "editor-format-on-paste" | "editor-trim-auto-whitespace"; "setting.ai.models": "stream-ttft-timeout-seconds" | "stream-idle-timeout-seconds"; diff --git a/src/web-ui/src/infrastructure/config/hooks/useSelectionToolbarPreference.test.tsx b/src/web-ui/src/infrastructure/config/hooks/useSelectionToolbarPreference.test.tsx new file mode 100644 index 0000000000..7e451fdacd --- /dev/null +++ b/src/web-ui/src/infrastructure/config/hooks/useSelectionToolbarPreference.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { useSelectionToolbarPreference } from './useSelectionToolbarPreference'; + +const mocks = vi.hoisted(() => ({ get: vi.fn(), listeners: new Set<() => void>() })); +vi.mock('../services/ConfigManager', () => ({ configManager: { + getOptionalConfig: mocks.get, + watch: (_path: string, listener: () => void) => { + mocks.listeners.add(listener); + return () => mocks.listeners.delete(listener); + }, +} })); + +function Preference() { + const { enabled, error, reload } = useSelectionToolbarPreference(); + return