From 0d986a72d1646ac8ad4f8e2681444788056b1c95 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 7 Aug 2026 11:32:47 +0800 Subject: [PATCH 1/3] feat(external-sources): improve application connection experience --- ...nal-ai-app-connection-experience-design.md | 2 +- ...ernal-ai-app-connection-experience-plan.md | 2 +- .../rules/source/public-api-rules.mjs | 17 + .../desktop/src/api/external_sources_api.rs | 72 ++++- .../src/api/remote_workspace_policy.rs | 8 + src/apps/desktop/src/lib.rs | 2 + .../assembly/core/src/external_sources.rs | 159 ++++++++++ .../scenes/settings/SettingsNav.appearance.ts | 1 + .../src/app/scenes/settings/SettingsNav.scss | 10 + .../src/app/scenes/settings/SettingsNav.tsx | 14 + .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsStore.ts | 19 ++ .../components/ChatInput.appearance.ts | 1 + .../src/flow_chat/components/ChatInput.scss | 24 ++ .../src/flow_chat/components/ChatInput.tsx | 23 +- .../utils/externalPromptCommands.test.ts | 94 ++++++ .../flow_chat/utils/externalPromptCommands.ts | 42 ++- .../service-api/ExternalSourcesAPI.test.ts | 23 ++ .../api/service-api/ExternalSourcesAPI.ts | 25 ++ .../ExternalSourcesConfig.appearance.ts | 4 + .../components/ExternalSourcesConfig.scss | 147 +++++++-- .../components/ExternalSourcesConfig.tsx | 261 ++++++---------- .../external-sources/ExternalAppDetail.tsx | 95 ++++++ .../external-sources/ExternalAppsOverview.tsx | 91 ++++++ .../ExternalCommandConflicts.tsx | 114 +++++++ .../ExternalSourceSection.tsx | 112 +++++++ .../external-sources/applicationModel.test.ts | 282 +++++++++++++++++ .../external-sources/applicationModel.ts | 290 ++++++++++++++++++ .../components/external-sources/index.ts | 23 ++ .../external-sources/presentation.ts | 69 +++++ .../components/external-sources/types.ts | 27 ++ .../useExternalAppAwareness.ts | 54 ++++ src/web-ui/src/locales/en-US/flow-chat.json | 4 + src/web-ui/src/locales/en-US/settings.json | 1 + .../en-US/settings/external-sources.json | 60 ++++ src/web-ui/src/locales/zh-CN/flow-chat.json | 4 + src/web-ui/src/locales/zh-CN/settings.json | 1 + .../zh-CN/settings/external-sources.json | 60 ++++ src/web-ui/src/locales/zh-TW/flow-chat.json | 4 + src/web-ui/src/locales/zh-TW/settings.json | 1 + .../zh-TW/settings/external-sources.json | 60 ++++ 41 files changed, 2094 insertions(+), 210 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/index.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/types.ts create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts diff --git a/docs/architecture/extensions/external-ai-app-connection-experience-design.md b/docs/architecture/extensions/external-ai-app-connection-experience-design.md index 30da8581d4..9a9fe20abd 100644 --- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md +++ b/docs/architecture/extensions/external-ai-app-connection-experience-design.md @@ -167,7 +167,7 @@ Instruction、Skill、Hook 和显式复制成 BitFun 原生配置的内容继续 ### 5.1 首页 -首页沿用现有约 600px 正文最大宽度,按以下顺序纵向排列: +首页沿用现有 `ConfigPageLayout` 的 760px 正文最大宽度,按以下顺序纵向排列: 1. 标题和一句说明; 2. “需要处理”摘要,仅在有真实待办时显示; diff --git a/docs/plans/external-ai-app-connection-experience-plan.md b/docs/plans/external-ai-app-connection-experience-plan.md index 48581faf84..0bfab7b5bf 100644 --- a/docs/plans/external-ai-app-connection-experience-plan.md +++ b/docs/plans/external-ai-app-connection-experience-plan.md @@ -398,7 +398,7 @@ pnpm run type-check:web - `ExternalAppReview`; - `ExternalAdvancedSettings`; - 无策略判断的 presentation helpers。 -3. 首页使用现有约 600px 单列阅读轴:标题、真实待办、应用列表、高级设置。 +3. 首页使用现有 `ConfigPageLayout` 的 760px 单列阅读轴:标题、应用列表、高级设置。真实的任务相关待办通过就地提示或状态变化处理,不把无法归属的系统诊断聚合成首页数量。 4. 每个应用行只显示应用名、一个状态、一句结果摘要和唯一主操作;有工作区时主操作明确标注“仅当前工作区”,没有工作区时先进入详情选择范围。来源路径、能力清单、冲突和诊断进入详情。 5. 详情按“结果优先、控制后置”排列;连接完成显示生效范围、已启用、待确认和受限摘要。`user_default` 只在详情/高级设置中提供,并在提交前再次展示会影响同一执行域的所有工作区。 6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;按类别展示数量、主要风险、共享推荐状态和安全上限,技术详情按需展开,高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。 diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 5df1dad4a0..f44871619e 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -1076,6 +1076,23 @@ export const externalSourceCorePublicApiEntries = [ 'Desktop external-source configuration host adapter', true, ), + ...[ + 'unacknowledged_external_ecosystems', + 'acknowledge_external_ecosystems', + ].map((symbol) => ({ + symbol, + owner: 'bitfun-core external source composition facade', + consumer: 'Desktop settings navigation and CLI/TUI external application entry points', + verification: + 'core acknowledgement persistence and execution-domain scoping tests, plus Desktop and TUI first-discovery hint tests', + p0: 'first-discovery hint for external applications shared by GUI and TUI', + contractSlice: contractSlices.externalSourceCommandContract, + wireImpact: true, + rationale: + 'both surfaces must derive "an external application the user has not seen" from one owner, otherwise GUI and TUI drift; awareness stays outside the preference-revision contract because it grants nothing and only suppresses a hint', + exit: + 'remove once the versioned application-level read model owns notice state, together with its cross-surface deduplication tests', + })), ...[ 'ExternalToolActivationState', 'ExternalToolApprovalRequest', diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index d1bf91b2c5..0890159bac 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,21 +1,23 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - apply_external_source_control_action, choose_external_mcp_conflict, - choose_external_subagent_conflict, expand_external_prompt_command, - external_source_location_for_host_action, external_source_snapshot, + acknowledge_external_ecosystems, apply_external_source_control_action, + choose_external_mcp_conflict, choose_external_subagent_conflict, + expand_external_prompt_command, external_source_location_for_host_action, + external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_subagent_model_binding, set_external_tool_conflict_choice, set_external_tool_target_decision, - set_native_prompt_command_conflict_choice, update_external_integration_policy, - workspace_reference_snapshot, ExternalIntegrationPolicyMutation, - ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, - ExternalSourceSurfaceSnapshotV1, ExternalSubagentModelBindingTarget, - NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, - PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, + set_native_prompt_command_conflict_choice, unacknowledged_external_ecosystems, + update_external_integration_policy, workspace_reference_snapshot, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1, + ExternalSubagentModelBindingTarget, NativePromptCommandConflictSnapshot, + NativePromptCommandDescriptor, PromptCommandInvocationOutcome, + PromptCommandShellReviewDecision, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::{ @@ -73,6 +75,25 @@ pub struct RevealExternalSourceLocationRequest { pub source_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalEcosystemAwarenessRequest { + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEcosystemAwarenessResponse { + pub unacknowledged_ecosystem_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AcknowledgeExternalEcosystemsRequest { + pub workspace_path: Option, + pub ecosystem_ids: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateExternalIntegrationPolicyRequest { @@ -407,6 +428,37 @@ pub async fn apply_external_source_control_action_command( apply_external_source_control_action(workspace, request.control).await } +/// External applications discovered on this host that the user has never been +/// told about. Surfaces use it to show a low-key "something new" affordance. +#[tauri::command] +pub async fn get_external_ecosystem_awareness_command( + request: ExternalEcosystemAwarenessRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + unacknowledged_external_ecosystems(workspace) + .await + .map( + |unacknowledged_ecosystem_ids| ExternalEcosystemAwarenessResponse { + unacknowledged_ecosystem_ids, + }, + ) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + +/// Records that the user has seen these external applications. +/// +/// This only clears the "new application" hint. It grants nothing, so it takes +/// no expected preference revision and leaves approvals and policy untouched. +#[tauri::command] +pub async fn acknowledge_external_ecosystems_command( + request: AcknowledgeExternalEcosystemsRequest, +) -> ExternalSourceOperationResult<()> { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + acknowledge_external_ecosystems(workspace, request.ecosystem_ids) + .await + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_source_enabled_command( request: SetExternalSourceEnabledRequest, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 119858ac6d..3d02f0b0d8 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -201,6 +201,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "apply_external_source_control_action_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "get_external_ecosystem_awareness_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "acknowledge_external_ecosystems_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("apply_patch", RemoteWorkspacePolicy::LegacyUnaudited), ( "archive_all_sessions", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index af8fc576fa..3d292941c2 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1245,6 +1245,8 @@ pub async fn run() { reveal_external_source_location, get_external_source_control_snapshot, apply_external_source_control_action_command, + get_external_ecosystem_awareness_command, + acknowledge_external_ecosystems_command, update_external_integration_policy_command, set_external_source_enabled_command, set_external_source_conflict_choice_command, diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 97b20519e0..7bea9318be 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -139,6 +139,10 @@ const MAX_PROMPT_COMMAND_SHELL_OUTPUT_CHARS: usize = 256 * 1024; const PROMPT_COMMAND_SHELL_TIMEOUT_MS: u64 = 30_000; const PROMPT_COMMAND_SHELL_KILL_YIELD_MS: u64 = 5_000; const MAX_APPROVED_PROMPT_COMMAND_SHELL_PLANS: usize = 512; +/// Awareness records are tiny and bounded by the number of registered +/// ecosystems, but the cap keeps a corrupted or hostile file from growing +/// without limit. +const MAX_ACKNOWLEDGED_ECOSYSTEMS: usize = 256; #[derive(Debug, Clone, PartialEq, Eq)] struct ResolvedPromptCommandShell { @@ -922,6 +926,17 @@ struct ExternalSourcesConfig { mcp_server_decisions: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] mcp_conflict_choices: BTreeMap, + /// Ecosystems the user has already been told about, as + /// `execution_domain_id` + unit separator + `ecosystem_id`. + /// + /// This records awareness, not a policy decision: it only suppresses the + /// "a new external application was found" hint. It deliberately carries no + /// content version, because discovering more commands inside an ecosystem + /// the user already knows about is not new information. Awareness is also + /// user-wide rather than per workspace, so opening another project does not + /// re-announce the same application. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + acknowledged_ecosystems: BTreeSet, /// Preserves fields written by a newer preferences schema. #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, @@ -971,6 +986,7 @@ impl std::fmt::Debug for ExternalSourcesConfig { .field("subagent_model_bindings", &self.subagent_model_bindings) .field("mcp_server_decisions", &self.mcp_server_decisions) .field("mcp_conflict_choices", &self.mcp_conflict_choices) + .field("acknowledged_ecosystems", &self.acknowledged_ecosystems) .field("extensions", &self.extensions) .finish() } @@ -4579,6 +4595,88 @@ async fn read_external_sources_config() -> Result ExternalSourcePreferenceStore::global()?.read().await } +fn acknowledged_ecosystem_key(execution_domain_id: &str, ecosystem_id: &str) -> String { + format!("{execution_domain_id}\u{1f}{ecosystem_id}") +} + +/// Ecosystems that have configuration on this host but have never been +/// announced to the user. +/// +/// Both the desktop settings navigation and the TUI read this same result, so +/// neither surface derives "is there something new" on its own and they cannot +/// drift apart. An ecosystem only qualifies once discovery actually found a +/// source for it: a registered adapter with nothing to offer is not news. +pub async fn unacknowledged_external_ecosystems( + workspace_root: Option<&Path>, +) -> Result, String> { + let service = read_only_service_for(workspace_root).await?; + let execution_domain_id = service.execution_domain_id.clone(); + let discovered = service + .snapshot() + .sources + .iter() + .map(|source| source.record.ecosystem_id.to_string()) + .collect::>(); + if discovered.is_empty() { + return Ok(Vec::new()); + } + let config = read_external_sources_config().await?; + Ok(discovered + .into_iter() + .filter(|ecosystem_id| { + !config + .acknowledged_ecosystems + .contains(&acknowledged_ecosystem_key( + execution_domain_id.as_str(), + ecosystem_id, + )) + }) + .collect()) +} + +/// Records that the user has seen the given ecosystems. +/// +/// Awareness is not part of the preference-revision contract. The set only +/// grows, insertion is idempotent, and no policy or approval decision reads it, +/// so concurrent writers cannot lose each other's decisions here. Taking an +/// expected revision would therefore add fencing failures without protecting +/// anything, and bumping the revision would invalidate unrelated in-flight +/// mutations every time a user opens the settings page. +/// +/// The execution domain is resolved from the workspace's own service so hosts +/// never pass an identity that disagrees with the one discovery recorded. +pub async fn acknowledge_external_ecosystems( + workspace_root: Option<&Path>, + ecosystem_ids: Vec, +) -> Result<(), String> { + if ecosystem_ids.is_empty() { + return Ok(()); + } + let execution_domain_id = read_only_service_for(workspace_root) + .await? + .execution_domain_id + .clone(); + let keys = ecosystem_ids + .iter() + .map(|ecosystem_id| acknowledged_ecosystem_key(execution_domain_id.as_str(), ecosystem_id)) + .collect::>(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + for key in &keys { + if config.acknowledged_ecosystems.contains(key) { + continue; + } + if config.acknowledged_ecosystems.len() >= MAX_ACKNOWLEDGED_ECOSYSTEMS { + break; + } + config.acknowledged_ecosystems.insert(key.clone()); + } + true + }) + .await + .map(|_| ()) +} + async fn persist_prompt_command_shell_plan_approval( fingerprint: &str, expected_preference_revision: u64, @@ -8723,6 +8821,67 @@ mod tests { ); } + #[tokio::test] + async fn acknowledging_an_ecosystem_survives_a_reload_and_stays_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let store = ExternalSourcePreferenceStore::new(path.clone()); + let key = acknowledged_ecosystem_key(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, "opencode"); + + store + .update(|config| { + config.acknowledged_ecosystems.insert(key.clone()); + }) + .await + .unwrap(); + store + .update(|config| { + config.acknowledged_ecosystems.insert(key.clone()); + }) + .await + .unwrap(); + + // A fresh store proves the record came back from disk, not from memory. + let reloaded = ExternalSourcePreferenceStore::new(path) + .read() + .await + .unwrap(); + assert_eq!(reloaded.acknowledged_ecosystems, BTreeSet::from([key])); + // Awareness is not a policy decision, so it must not consume a revision. + assert_eq!(reloaded.preference_revision, 0); + } + + #[tokio::test] + async fn acknowledgement_is_scoped_to_its_execution_domain() { + let temp = tempfile::tempdir().unwrap(); + let store = ExternalSourcePreferenceStore::new(temp.path().join("external-sources.json")); + let local = acknowledged_ecosystem_key(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, "opencode"); + let remote = acknowledged_ecosystem_key("remote-host", "opencode"); + + store + .update(|config| { + config.acknowledged_ecosystems.insert(local.clone()); + }) + .await + .unwrap(); + + let persisted = store.read().await.unwrap(); + assert!(persisted.acknowledged_ecosystems.contains(&local)); + assert!(!persisted.acknowledged_ecosystems.contains(&remote)); + } + + #[test] + fn acknowledgement_keys_never_collide_across_domains_or_ecosystems() { + assert_ne!( + acknowledged_ecosystem_key("local-user", "opencode"), + acknowledged_ecosystem_key("local-user", "codex") + ); + assert_ne!( + acknowledged_ecosystem_key("local-user", "opencode"), + acknowledged_ecosystem_key("remote-host", "opencode") + ); + } + #[test] fn opencode_registry_owns_low_friction_defaults_and_safety_ceilings() { let mut config = ExternalSourcesConfig::default(); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts b/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts index f7a615b167..687458bdef 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.appearance.ts @@ -14,6 +14,7 @@ export const settingsNavAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'categoryHeader', visualRole: 'toolbar' }, { id: 'items', visualRole: 'content' }, { id: 'item', propertyProfile: 'control', visualRole: 'control' }, + { id: 'itemUnseen', propertyProfile: 'paint', visualRole: 'decoration' }, { id: 'highlight', propertyProfile: 'paint', visualRole: 'decoration' }, ], states: [ diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.scss b/src/web-ui/src/app/scenes/settings/SettingsNav.scss index 39563c5559..4ea407ef1e 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.scss +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.scss @@ -226,6 +226,16 @@ text-transform: uppercase; } + /* Marks a tab holding something the user has not seen yet. */ + &__item-unseen { + flex-shrink: 0; + width: 6px; + height: 6px; + margin-left: $size-gap-2; + border-radius: 50%; + background: var(--bf-appearance-token-color-accent-500); + } + &__item { display: flex; align-items: center; diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index e9022ba8b7..e9ce3935ad 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -143,6 +143,7 @@ function useSettingsNav() { const setActiveTab = useSettingsStore((s) => s.setActiveTab); const searchQuery = useSettingsStore((s) => s.searchQuery); const setSearchQuery = useSettingsStore((s) => s.setSearchQuery); + const unseenTabs = useSettingsStore((s) => s.unseenTabs); const [draftQuery, setDraftQuery] = useState(''); const searchInputRef = useRef(null); @@ -276,6 +277,7 @@ function useSettingsNav() { return { t, activeTab, + unseenTabs, handleTabClick, preloadTab, draftQuery, @@ -298,6 +300,7 @@ const SettingsNav: React.FC = () => { const { t, activeTab, + unseenTabs, handleTabClick, preloadTab, draftQuery, @@ -436,6 +439,17 @@ const SettingsNav: React.FC = () => { {t(tabDef.labelKey, { defaultValue: tabDef.id })} + {unseenTabs.includes(tabDef.id) ? ( + + ) : null} {tabDef.beta ? ( {t('configCenter.beta')} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index f3dc9ad3fc..5ae668e623 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -12,6 +12,7 @@ import React, { useState, } from 'react'; import { useSettingsStore } from './settingsStore'; +import { useExternalAppAwareness } from '@/infrastructure/config/components/external-sources'; import type { ConfigTab } from './settingsConfig'; import { AcpAgentsConfig, @@ -77,6 +78,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { } const SettingsScene: React.FC = () => { + useExternalAppAwareness(); const activeTab = useSettingsStore(s => s.activeTab); const setActiveTab = useSettingsStore(s => s.setActiveTab); diff --git a/src/web-ui/src/app/scenes/settings/settingsStore.ts b/src/web-ui/src/app/scenes/settings/settingsStore.ts index 0a468f17b2..2bc1670d6b 100644 --- a/src/web-ui/src/app/scenes/settings/settingsStore.ts +++ b/src/web-ui/src/app/scenes/settings/settingsStore.ts @@ -15,14 +15,33 @@ interface SettingsState { /** Debounced from SettingsNav search input; used for filtering index. */ searchQuery: string; setSearchQuery: (query: string) => void; + /** + * Tabs with something the user has not seen yet, rendered as a small dot. + * + * The navigation stays feature-neutral: a tab owner decides when it has + * unseen content and writes the id here, rather than SettingsNav learning to + * query each feature. + */ + unseenTabs: ConfigTab[]; + markTabUnseen: (tab: ConfigTab, unseen: boolean) => void; } export const useSettingsStore = create((set) => ({ activeTab: DEFAULT_SETTINGS_TAB, searchQuery: '', + unseenTabs: [], setActiveTab: (tab) => set({ activeTab: tab }), setSearchQuery: (query) => set({ searchQuery: query }), + markTabUnseen: (tab, unseen) => set((state) => { + const has = state.unseenTabs.includes(tab); + if (has === unseen) return state; + return { + unseenTabs: unseen + ? [...state.unseenTabs, tab] + : state.unseenTabs.filter((candidate) => candidate !== tab), + }; + }), })); /** Resolve the category id for a given tab (for initial scroll / highlight) */ diff --git a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts index 271ed97270..205c6ff71a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts @@ -23,6 +23,7 @@ export const chatInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'commandName' }, { id: 'commandLabel' }, { id: 'commandCurrent' }, + { id: 'commandStatus' }, { id: 'commandSection' }, { id: 'commandEmpty' }, { id: 'actions' }, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 71da60a4d0..0283dffe10 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -1738,6 +1738,30 @@ flex: 0 0 auto; } + /* + * External commands that cannot run yet. The badge keeps the reason visible + * in the list instead of waiting for the click that reveals it, matching the + * hints the TUI command menu appends to its descriptions. + */ + &__slash-command-status { + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + padding: 0.12rem 0.32rem; + border-radius: 3px; + font-weight: 500; + flex: 0 0 auto; + white-space: nowrap; + + &--restricted { + background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 15%, transparent); + color: color-mix(in srgb, var(--bf-appearance-token-color-warning) 90%, var(--bf-appearance-token-color-text-primary)); + } + + &--choose { + background: var(--bf-appearance-token-color-bg-tertiary); + color: var(--bf-appearance-token-color-text-secondary); + } + } + &__slash-command-empty { padding: var(--bf-appearance-token-size-gap-4) var(--bf-appearance-token-flowchat-card-expanded-pad-x); text-align: center; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index ad471a2378..cc2ce0daf9 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -5425,15 +5425,32 @@ export const ChatInput: React.FC = ({ {labelText} {item.kind === 'mode' && item.id === modeState.current && {t('chatInput.current')}} + {item.kind === 'externalCommand' && item.status !== 'available' ? ( + + {t(item.status === 'restricted' + ? 'chatInput.commandStatus.restricted' + : 'chatInput.commandStatus.chooseSource')} + + ) : null} ); }) - ) : !externalPromptCommandsIssue ? ( + ) : (
- {t('chatInput.noMatchingCommand')} + {/* A catalog issue must not leave the list blank: say why nothing is listed. */} + {externalPromptCommandsIssue === 'host_unavailable' + ? t('chatInput.externalCommandsHostUnavailable') + : externalPromptCommandsIssue === 'load_failed' + ? t('chatInput.externalCommandsLoadFailed') + : t('chatInput.noMatchingCommand')}
- ) : null} + )} ); diff --git a/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts b/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts index a9e9dc0568..33163e2918 100644 --- a/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts +++ b/src/web-ui/src/flow_chat/utils/externalPromptCommands.test.ts @@ -128,6 +128,100 @@ describe('external prompt command projection', () => { expect(items.every(item => item.conflictKey === 'review-conflict')).toBe(true); expect(items.every(item => item.expectedPreferenceRevision === 9)).toBe(true); }); + + it('carries the owning ecosystem so the picker can name the application', () => { + expect(buildExternalPromptCommandItems(snapshot())[0]).toMatchObject({ + ecosystemId: 'claude-code', + status: 'available', + }); + }); + + it('asks the user to pick a source while a conflict is unresolved', () => { + const items = buildExternalPromptCommandItems(snapshot({ + commands: [], + commandConflicts: [{ + conflictKey: 'review-conflict', + commandName: 'review', + candidates: [ + { + candidateId: 'claude-review', + source: { providerId: 'claude-code.commands', sourceId: 'project' }, + sourceDisplayName: 'Claude Code project commands', + ecosystemId: 'claude-code', + contentVersion: 'claude-v1', + commandDescription: 'Review with Claude conventions', + sourceScope: 'project', + sourceLocation: '.claude/commands', + availability: { state: 'available' }, + }, + { + candidateId: 'opencode-review', + source: { providerId: 'opencode.commands', sourceId: 'project' }, + sourceDisplayName: 'OpenCode project commands', + ecosystemId: 'opencode', + contentVersion: 'opencode-v1', + commandDescription: 'Review with OpenCode conventions', + sourceScope: 'project', + sourceLocation: '.opencode/commands', + availability: { state: 'available' }, + }, + ], + }], + })); + + expect(items.map(item => item.status)).toEqual(['choose_source', 'choose_source']); + expect(items.map(item => item.ecosystemId)).toEqual(['claude-code', 'opencode']); + }); + + it('drops the pick-a-source hint once the conflict is resolved', () => { + const items = buildExternalPromptCommandItems(snapshot({ + commands: [], + commandConflicts: [{ + conflictKey: 'review-conflict', + commandName: 'review', + selectedCandidateId: 'claude-review', + candidates: [{ + candidateId: 'claude-review', + source: { providerId: 'claude-code.commands', sourceId: 'project' }, + sourceDisplayName: 'Claude Code project commands', + ecosystemId: 'claude-code', + contentVersion: 'claude-v1', + commandDescription: 'Review with Claude conventions', + sourceScope: 'project', + sourceLocation: '.claude/commands', + availability: { state: 'available' }, + }], + }], + })); + + expect(items.map(item => item.status)).toEqual(['available']); + }); + + it('reports a policy-blocked command as restricted instead of asking for a source', () => { + const items = buildExternalPromptCommandItems(snapshot({ + commands: [], + commandConflicts: [{ + conflictKey: 'review-conflict', + commandName: 'review', + candidates: [{ + candidateId: 'claude-review', + source: { providerId: 'claude-code.commands', sourceId: 'project' }, + sourceDisplayName: 'Claude Code project commands', + ecosystemId: 'claude-code', + contentVersion: 'claude-v1', + commandDescription: 'Review with Claude conventions', + sourceScope: 'project', + sourceLocation: '.claude/commands', + availability: { + state: 'restricted', + reason: 'External command execution is disabled by integration policy', + }, + }], + }], + })); + + expect(items[0]).toMatchObject({ status: 'restricted', available: false }); + }); }); describe('external prompt command invocation resolution', () => { diff --git a/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts b/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts index efa6cbe7f5..f63832868e 100644 --- a/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts +++ b/src/web-ui/src/flow_chat/utils/externalPromptCommands.ts @@ -3,6 +3,18 @@ import type { PromptCommandAvailability, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +/** + * Why a command needs a decision before it can run. + * + * Mirrors the states the TUI command menu already surfaces so both entry + * points describe the same catalog facts. Each surface renders them with its + * own copy and layout; only the derivation is shared, through the snapshot. + */ +export type ExternalPromptCommandStatus = + | 'available' + | 'restricted' + | 'choose_source'; + export interface ExternalPromptCommandItem { id: string; command: string; @@ -13,6 +25,9 @@ export interface ExternalPromptCommandItem { unavailableReason?: string; conflictKey?: string; expectedPreferenceRevision?: number; + /** Owning ecosystem, so the list can name the application behind a command. */ + ecosystemId?: string; + status: ExternalPromptCommandStatus; } export type ExternalPromptCommandInvocation = @@ -80,6 +95,16 @@ function availabilityFacts(availability: PromptCommandAvailability): { }; } +/** + * Restricted wins over conflict: a command the policy already blocks cannot be + * fixed by picking a source, so showing "choose this source" would be a dead + * end. This ordering matches the TUI command menu. + */ +function commandStatus(available: boolean, hasConflict: boolean): ExternalPromptCommandStatus { + if (!available) return 'restricted'; + return hasConflict ? 'choose_source' : 'available'; +} + export function buildExternalPromptCommandItems( snapshot: ExternalSourceCatalogSnapshot, ): ExternalPromptCommandItem[] { @@ -89,6 +114,12 @@ export function buildExternalPromptCommandItems( source.record.displayName, ]), ); + const sourceEcosystems = new Map( + snapshot.sources.map(source => [ + `${source.record.key.providerId}:${source.record.key.sourceId}`, + source.record.ecosystemId, + ]), + ); const items = new Map(); for (const entry of snapshot.commands) { @@ -101,13 +132,16 @@ export function buildExternalPromptCommandItems( } const sourceKey = `${definition.id.source.providerId}:${definition.id.source.sourceId}`; const sourceLabel = sourceLabels.get(sourceKey) ?? definition.id.source.providerId; + const facts = availabilityFacts(definition.availability); items.set(candidateId, { id: candidateId, command: `/${definition.name}`, label: `${definition.description || definition.name} · ${sourceLabel}`, candidateId, contentVersion: definition.contentVersion, - ...availabilityFacts(definition.availability), + ecosystemId: sourceEcosystems.get(sourceKey), + status: commandStatus(facts.available, false), + ...facts, }); } @@ -119,6 +153,7 @@ export function buildExternalPromptCommandItems( if (items.has(candidate.candidateId)) { continue; } + const facts = availabilityFacts(candidate.availability); items.set(candidate.candidateId, { id: candidate.candidateId, command: `/${conflict.commandName}`, @@ -127,7 +162,10 @@ export function buildExternalPromptCommandItems( contentVersion: candidate.contentVersion, conflictKey: conflict.conflictKey, expectedPreferenceRevision: snapshot.preferenceRevision ?? 0, - ...availabilityFacts(candidate.availability), + ecosystemId: candidate.ecosystemId, + // A resolved conflict no longer asks the user to pick a source. + status: commandStatus(facts.available, !conflict.selectedCandidateId), + ...facts, }); } } diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index 066b986d20..c80b1ea376 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -66,6 +66,29 @@ describe('ExternalSourcesAPI', () => { adapterMocks.isConnected.mockReturnValue(true); }); + it('reads and acknowledges backend-owned ecosystem awareness', async () => { + invokeMock + .mockResolvedValueOnce({ unacknowledgedEcosystemIds: ['opencode', 'codex'] }) + .mockResolvedValueOnce(undefined); + + await expect(externalSourcesAPI.getEcosystemAwareness('D:/workspace/project')) + .resolves.toEqual(['opencode', 'codex']); + await externalSourcesAPI.acknowledgeEcosystems( + 'D:/workspace/project', + ['opencode', 'codex'], + ); + + expect(invokeMock).toHaveBeenNthCalledWith(1, 'get_external_ecosystem_awareness_command', { + request: { workspacePath: 'D:/workspace/project' }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, 'acknowledge_external_ecosystems_command', { + request: { + workspacePath: 'D:/workspace/project', + ecosystemIds: ['opencode', 'codex'], + }, + }); + }); + it('keeps workspace ownership and refresh intent in the public snapshot request', async () => { await externalSourcesAPI.getSnapshot('D:/workspace/project', true); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 690966392f..05e0dd3218 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -1680,4 +1680,29 @@ export const externalSourcesAPI = { emitExternalAgentCatalogUpdated(workspacePath); return catalog; }, + + /** + * External applications found on this host that the user has never been told + * about. The host owns this derivation so the desktop and the TUI cannot + * disagree about what counts as new. + */ + async getEcosystemAwareness(workspacePath?: string): Promise { + const response = await invokeExternalSourceCommand<{ + unacknowledgedEcosystemIds?: unknown; + }>('get_external_ecosystem_awareness_command', { + request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath) }, + }); + return normalizeOptionalArray(response.unacknowledgedEcosystemIds) + .filter((ecosystemId): ecosystemId is string => typeof ecosystemId === 'string'); + }, + + /** Clears the "new external application" hint for these ecosystems. */ + acknowledgeEcosystems(workspacePath: string | undefined, ecosystemIds: string[]) { + return invokeExternalSourceCommand('acknowledge_external_ecosystems_command', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + ecosystemIds, + }, + }); + }, }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts index 51472729d1..59aab1704a 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -33,5 +33,9 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { id: 'ecosystemHeading' }, { id: 'ecosystemName' }, { id: 'ecosystemState' }, + { id: 'application' }, + { id: 'appDetail' }, + { id: 'appAttention' }, + { id: 'appCapability' }, ], }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index dbf964ebce..81c07748ee 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -2,6 +2,28 @@ container-name: external-sources; container-type: inline-size; + &__advanced { + margin-top: var(--bf-appearance-token-size-gap-5); + border-top: 1px solid var(--bf-appearance-token-border-subtle); + } + + &__advanced-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-3); + padding: 14px 2px; + color: var(--bf-appearance-token-color-text-primary); + font-size: 13px; + font-weight: 600; + + span:last-child { + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + font-weight: 400; + } + } + details > summary { cursor: pointer; user-select: none; @@ -52,6 +74,92 @@ padding-left: var(--bf-appearance-token-size-gap-4); } + &__app-detail { + display: grid; + gap: var(--bf-appearance-token-size-gap-4); + } + + &__app-detail-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-4); + + h2 { margin: 0; color: var(--bf-appearance-token-color-text-primary); font-size: 20px; } + p { margin: 5px 0 0; color: var(--bf-appearance-token-color-text-secondary); font-size: 12px; } + small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-muted); font-size: 11px; } + } + + &__app-attention { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-3); + width: 100%; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); + border-radius: var(--bf-appearance-token-size-radius-sm); + color: var(--bf-appearance-token-color-warning); + background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); + text-align: left; + cursor: pointer; + + small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-secondary); } + } + + &__app-capabilities { overflow: hidden; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: var(--bf-appearance-token-size-radius-md); } + &__app-capability { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 14px; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + + &:last-child { border-bottom: 0; } + strong, small { display: block; } + strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } + small { margin-top: 3px; } + } + + + display: grid; + overflow: hidden; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: var(--bf-appearance-token-size-radius-md); + } + + &__app-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-4); + padding: 14px var(--bf-appearance-token-size-gap-4); + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + + &:last-child { border-bottom: 0; } + } + + &__app-copy { min-width: 0; } + &__app-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--bf-appearance-token-size-gap-2); + } + &__app-name { color: var(--bf-appearance-token-color-text-primary); font-weight: 600; } + &__app-status, &__app-summary { + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + } + &__app-status { + &.is-connected, &.is-connected_custom { color: var(--bf-appearance-token-color-success); } + &.is-needs_attention { color: var(--bf-appearance-token-color-warning); } + } + &__app-summary { margin-top: 4px; overflow-wrap: anywhere; } + &__ecosystem-heading, &__policy-actions, &__ecosystem-name { @@ -103,7 +211,7 @@ display: inline-flex; align-items: center; width: fit-content; - font-size: 10px; + font-size: 12px; font-weight: 500; } @@ -209,7 +317,7 @@ align-items: center; gap: 4px; color: var(--bf-appearance-token-color-text-secondary); - font-size: 10px; + font-size: 12px; font-weight: 500; &.is-ready { @@ -398,7 +506,7 @@ } .bitfun-switch__description { - font-size: 10px; + font-size: 12px; line-height: 13px; } } @@ -434,7 +542,7 @@ border-radius: var(--bf-appearance-token-size-radius-sm); background: var(--bf-appearance-token-color-bg-tertiary); color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; font-variant-numeric: tabular-nums; line-height: 16px; white-space: nowrap; @@ -442,7 +550,7 @@ &__state { color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; &.is-using_last_valid_version, &.is-restricted, @@ -453,19 +561,22 @@ } } + &__conflict, + &__tool-card, + &__opencode-card { + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: var(--bf-appearance-token-size-radius-sm); + background: var(--bf-appearance-token-color-bg-tertiary); + } + &__conflict { - padding: 12px 0; - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + padding: 12px 14px; - &:last-child { - border-bottom: 0; - } + & + & { margin-top: var(--bf-appearance-token-size-gap-3); } } &__tool-card { - padding: 10px 14px; - border-left: 2px solid var(--bf-appearance-token-border-medium); - background: transparent; + padding: 12px 14px; overflow-wrap: anywhere; & + & { @@ -499,8 +610,6 @@ &__opencode-card { padding: 12px 14px; - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: 6px; margin-bottom: 12px; } @@ -570,7 +679,7 @@ &__tool-warning { margin-top: 8px; color: var(--bf-appearance-token-color-warning); - font-size: 11px; + font-size: 12px; } &__review-summary { @@ -604,7 +713,7 @@ &__diagnostic-code { color: var(--bf-appearance-token-color-text-muted); - font-size: 11px; + font-size: 12px; overflow-wrap: anywhere; } @@ -637,12 +746,12 @@ &__candidate-detail { color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; } &__candidate-state { color: var(--bf-appearance-token-color-text-secondary); - font-size: 11px; + font-size: 12px; } &__ecosystem { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index f913c8bb90..68e6ed323e 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -57,15 +57,17 @@ import { type ExternalSourcePresentationGroup, } from '../externalSourcePresentation'; import { externalSourceRequestScopeKey } from './externalSourceRequestScope'; +import { + ExternalAppDetail, + ExternalAppsOverview, + ExternalCommandConflicts, + ExternalSourceSection, + buildExternalApplicationsView, + type ExternalApplicationView, +} from './external-sources'; import './ExternalSourcesConfig.scss'; const DISCOVERY_POLL_DELAYS_MS = [750, 1_500, 3_000, 5_000] as const; -const SOURCE_COUNT_LABELS = [ - ['commands', 'sources.commandCount'], - ['tools', 'sources.toolCount'], - ['agents', 'sources.agentCount'], - ['mcps', 'sources.mcpCount'], -] as const; const AGENT_DIAGNOSTIC_SETTING_KEYS: Record = { opencode_unknown_agent_field: 'unknownField', @@ -388,6 +390,9 @@ const ExternalSourcesConfig: React.FC = () => { preferenceRevision: number; } | null>(null); const [agentChangeNotice, setAgentChangeNotice] = useState(null); + const [connectingApplication, setConnectingApplication] = useState(null); + const [selectedApplicationId, setSelectedApplicationId] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); const snapshotRef = useRef(null); const agentChangeNoticeRef = useRef(null); const requestSequence = useRef(0); @@ -625,6 +630,13 @@ const ExternalSourcesConfig: React.FC = () => { () => snapshot ? catalogDiagnosticsWithoutSourceDuplicates(snapshot, sourceGroups) : [], [snapshot, sourceGroups], ); + const applicationsView = useMemo( + () => buildExternalApplicationsView(snapshot, sourceGroups, catalogDiagnostics.length, policyScope), + [catalogDiagnostics.length, policyScope, snapshot, sourceGroups], + ); + const selectedApplication = applicationsView.applications.find( + (application) => application.ecosystemId === selectedApplicationId, + ) ?? null; const commandConflicts = useMemo( () => unresolvedFirst(snapshot?.commandConflicts ?? []), @@ -1079,6 +1091,18 @@ const ExternalSourcesConfig: React.FC = () => { ); }, [policyScope, runMutation, snapshot, t, workspacePath]); + const connectApplication = useCallback(async () => { + const application = connectingApplication; + if (!application || !snapshot) return; + setConnectingApplication(null); + const accepted = await updatePolicy({ + operation: 'set_ecosystem_mode', + ecosystemId: application.ecosystemId, + mode: 'recommended', + }); + if (accepted) setOperationStatus(t('applications.connectionComplete')); + }, [connectingApplication, snapshot, t, updatePolicy]); + const updateCapabilityAccess = useCallback(( ecosystemId: string, capabilityId: string, @@ -1424,6 +1448,33 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} ) : null} + {snapshot && selectedApplication ? ( + setSelectedApplicationId(null)} + onOpenAdvanced={() => { + setAdvancedOpen(true); + window.requestAnimationFrame(scrollToFirstAttentionItem); + }} + /> + ) : snapshot ? ( + setConnectingApplication(application)} + onManage={(application) => setSelectedApplicationId(application.ecosystemId)} + /> + ) : null} +
setAdvancedOpen(event.currentTarget.open)} + > + + {t('applications.advanced.title')} + {t('applications.advanced.description')} + {snapshot && policy ? ( { ) : null} - {nonOpencodeGroups.length > 0 ? ( - - {nonOpencodeGroups.map((group) => { - return ( - - - - - {group.location} - - - - {group.scopes.map((scope, index) => ( - - {index > 0 ? : null} - - {sourceScopeLabel(scope, t)} - - - ))} - - - {SOURCE_COUNT_LABELS.some( - ([capability]) => group.counts[capability] > 0, - ) ? ( - - {SOURCE_COUNT_LABELS.map(([capability, label]) => { - const count = group.counts[capability]; - return count > 0 ? ( - - {t(label, { count })} - - ) : null; - })} - - ) : null} - - )} - align="center" - > - {renderSourceMembers(group)} - - {group.diagnostics.length > 0 ? ( -
- - {t('diagnostics.sourceSummary', { - name: group.displayName, - count: group.diagnostics.length, - })} - -
    - {group.diagnostics.map((diagnostic) => ( -
  • - {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} -
    - {t('common.technicalDetails')} - {diagnostic.code} -
    -
  • - ))} -
-
- ) : null} -
- ); - })} -
- ) : null} + {(snapshot?.tools?.length ?? 0) > 0 ? ( @@ -3113,90 +3085,16 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} - {commandConflicts.length > 0 ? ( - - {commandConflicts.map((conflict) => { - const selectedChoiceUnavailable = conflict.candidates.some((candidate) => ( - candidate.candidateId === conflict.selectedCandidateId - && candidate.availability.state !== 'available' - )); - return ( -
-
- {t('conflicts.commandName', { name: conflict.commandName })} -
-
- {conflict.candidates.map((candidate) => { - const selected = conflict.selectedCandidateId === candidate.candidateId; - const available = candidate.availability.state === 'available'; - return ( -
- - - {t(selected - ? selectedChoiceUnavailable - ? 'common.selectedUnavailable' - : 'common.selected' - : !available - ? 'conflicts.restricted' - : conflict.selectedCandidateId - ? 'common.notSelected' - : 'common.availableChoice')} - -
- {candidate.commandDescription} - {' · '} - {sourceScopeLabel(candidate.sourceScope, t)} - {' · '} - - {abbreviatedLocation(candidate.sourceLocation)} - - {!available ? ` · ${t('conflicts.restricted')}` : ''} -
-
- ); - })} -
-
- {conflict.selectedCandidateId - ? t(selectedChoiceUnavailable - ? 'conflicts.currentSelectionUnavailable' - : 'conflicts.currentSelection') - : t('conflicts.pending')} -
-
- ); - })} -
- ) : null} + { + void chooseConflict(conflictKey, candidateId); + }} + /> {toolConflicts.length > 0 ? ( { })} ) : null} +
)} + setConnectingApplication(null)} + onConfirm={() => void connectApplication()} + title={connectingApplication ? t('applications.connectTitle', { name: connectingApplication.displayName }) : ''} + message={connectingApplication ? t('applications.connectMessage', { + commands: connectingApplication.counts.commands, + tools: connectingApplication.counts.tools, + agents: connectingApplication.counts.agents, + mcps: connectingApplication.counts.mcps, + }) : ''} + type="info" + confirmText={t('applications.actions.connect')} + /> setResetPolicyConfirmation(null)} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx new file mode 100644 index 0000000000..e805f980d8 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Button } from '@/component-library'; +import { ConfigPageSection } from '../common'; +import type { ExternalApplicationView } from './applicationModel'; +import type { TFunction } from 'i18next'; + +export interface ExternalAppDetailProps { + application: ExternalApplicationView; + t: TFunction; + onBack: () => void; + onOpenAdvanced: () => void; +} + +const CAPABILITIES = [ + ['commands', 'commands'], + ['tools', 'tools'], + ['agents', 'agents'], + ['mcps', 'mcps'], +] as const; + +/** + * Result-first application detail. V1 can summarize what was discovered and + * what needs review; the existing capability controls remain reachable through + * Advanced settings until the versioned review model can filter every owner by + * application without guessing. + */ +export const ExternalAppDetail: React.FC = ({ + application, + t, + onBack, + onOpenAdvanced, +}) => ( +
+ +
+
+

{application.displayName}

+

{t(`applications.status.${application.status}`)}

+ {application.sourceCount > 0 ? ( + + {t('applications.detail.sourceSummary', { count: application.sourceCount })} + + ) : null} +
+ +
+ + {application.attentionCount > 0 ? ( + + ) : null} + + +
+ {CAPABILITIES.map(([field, label]) => { + const count = application.counts[field]; + return ( +
+ + {t(`applications.detail.capabilities.${label}`)} + {t('applications.detail.foundCount', { count })} + + + {application.connectPlan.find((entry) => entry.capabilityId === ( + field === 'commands' ? 'command' + : field === 'tools' ? 'tool' + : field === 'agents' ? 'subagent' + : 'mcp' + ))?.recommendedAccess === 'auto' + ? t('applications.detail.autoAvailable') + : t('applications.detail.managed')} + +
+ ); + })} +
+
+
+); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx new file mode 100644 index 0000000000..23519d170c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Button } from '@/component-library'; +import { ConfigPageSection } from '../common'; +import type { ExternalApplicationView } from './applicationModel'; +import type { TFunction } from 'i18next'; + +export interface ExternalAppsOverviewProps { + applications: ExternalApplicationView[]; + t: TFunction; + onConnect: (application: ExternalApplicationView) => void; + onManage: (application: ExternalApplicationView) => void; +} + +function applicationSummary(application: ExternalApplicationView, t: TFunction): string { + const parts = [ + application.counts.commands > 0 + ? t('applications.counts.commands', { count: application.counts.commands }) + : null, + application.counts.tools > 0 + ? t('applications.counts.tools', { count: application.counts.tools }) + : null, + application.counts.agents > 0 + ? t('applications.counts.agents', { count: application.counts.agents }) + : null, + application.counts.mcps > 0 + ? t('applications.counts.mcps', { count: application.counts.mcps }) + : null, + ].filter((part): part is string => Boolean(part)); + if (parts.length > 0) return parts.join(' · '); + return application.status === 'checking' + ? t('applications.summary.checking') + : t('applications.summary.noContent'); +} + +/** The application-first entry point for external AI compatibility. */ +export const ExternalAppsOverview: React.FC = ({ + applications, + t, + onConnect, + onManage, +}) => ( + +
+ {applications.map((application) => { + const canConnect = application.primaryAction === 'connect'; + const canManage = application.primaryAction === 'manage' + || application.primaryAction === 'review'; + return ( +
+
+
+ + {application.displayName} + + + {t(`applications.status.${application.status}`)} + +
+
+ {application.attentionCount > 0 + ? t('applications.summary.attention', { count: application.attentionCount }) + : applicationSummary(application, t)} +
+
+ {canConnect ? ( + + ) : canManage ? ( + + ) : null} +
+ ); + })} +
+
+); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx new file mode 100644 index 0000000000..679887b859 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Button } from '@/component-library'; +import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { ConfigPageSection } from '../common'; +import { abbreviatedLocation, sourceScopeLabel } from './presentation'; +import type { ExternalSectionCommonProps } from './types'; + +type CommandConflict = NonNullable[number]; + +export interface ExternalCommandConflictsProps + extends Omit { + conflicts: CommandConflict[]; + onChooseConflict: (conflictKey: string, candidateId: string) => void; +} + +/** + * Prompt command name collisions across ecosystems. Selection stays a user + * decision: the controller never resolves these by registration order. + */ +export const ExternalCommandConflicts: React.FC = ({ + conflicts, + t, + busyKey, + hostCapabilities, + policyCompatible, + onChooseConflict, +}) => { + if (conflicts.length === 0) return null; + + return ( + + {conflicts.map((conflict) => { + const selectedChoiceUnavailable = conflict.candidates.some((candidate) => ( + candidate.candidateId === conflict.selectedCandidateId + && candidate.availability.state !== 'available' + )); + return ( +
+
+ {t('conflicts.commandName', { name: conflict.commandName })} +
+
+ {conflict.candidates.map((candidate) => { + const selected = conflict.selectedCandidateId === candidate.candidateId; + const available = candidate.availability.state === 'available'; + return ( +
+ + + {t(selected + ? selectedChoiceUnavailable + ? 'common.selectedUnavailable' + : 'common.selected' + : !available + ? 'conflicts.restricted' + : conflict.selectedCandidateId + ? 'common.notSelected' + : 'common.availableChoice')} + +
+ {candidate.commandDescription} + {' · '} + {sourceScopeLabel(candidate.sourceScope, t)} + {' · '} + + {abbreviatedLocation(candidate.sourceLocation)} + + {!available ? ` · ${t('conflicts.restricted')}` : ''} +
+
+ ); + })} +
+
+ {conflict.selectedCandidateId + ? t(selectedChoiceUnavailable + ? 'conflicts.currentSelectionUnavailable' + : 'conflicts.currentSelection') + : t('conflicts.pending')} +
+
+ ); + })} +
+ ); +}; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx new file mode 100644 index 0000000000..55de6cf347 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { ConfigPageRow, ConfigPageSection } from '../common'; +import { + externalSourceDiagnosticKey, + type ExternalSourcePresentationGroup, +} from '../../externalSourcePresentation'; +import { SOURCE_COUNT_LABELS, sourceDiagnosticCategory, sourceScopeLabel } from './presentation'; +import type { ExternalSectionCommonProps } from './types'; + +export interface ExternalSourceSectionProps + extends Pick { + groups: ExternalSourcePresentationGroup[]; + /** Renders the per-capability toggles owned by the controller. */ + renderSourceMembers: (group: ExternalSourcePresentationGroup) => React.ReactNode; +} + +/** + * Physical configuration sources grouped by presentation id. OpenCode is + * aggregated separately, so this section renders the remaining ecosystems. + */ +export const ExternalSourceSection: React.FC = ({ + groups, + t, + renderSourceMembers, +}) => { + if (groups.length === 0) return null; + + return ( + + {groups.map((group) => { + return ( + + + + + {group.location} + + + + {group.scopes.map((scope, index) => ( + + {index > 0 ? : null} + + {sourceScopeLabel(scope, t)} + + + ))} + + + {SOURCE_COUNT_LABELS.some( + ([capability]) => group.counts[capability] > 0, + ) ? ( + + {SOURCE_COUNT_LABELS.map(([capability, label]) => { + const count = group.counts[capability]; + return count > 0 ? ( + + {t(label, { count })} + + ) : null; + })} + + ) : null} + + )} + align="center" + > + {renderSourceMembers(group)} + + {group.diagnostics.length > 0 ? ( +
+ + {t('diagnostics.sourceSummary', { + name: group.displayName, + count: group.diagnostics.length, + })} + +
    + {group.diagnostics.map((diagnostic) => ( +
  • + {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} +
    + {t('common.technicalDetails')} + {diagnostic.code} +
    +
  • + ))} +
+
+ ) : null} +
+ ); + })} +
+ ); +}; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts new file mode 100644 index 0000000000..9268b3eb22 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import type { + ExternalSourceCatalogSnapshot, + ExternalSourceRecord, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { buildExternalSourcePresentationGroups } from '../../externalSourcePresentation'; +import { buildExternalApplicationsView } from './applicationModel'; + +const OPENCODE_CAPABILITIES = [ + { capabilityId: 'command', recommendedAccess: 'auto' as const, safetyCeiling: 'auto' as const }, + { capabilityId: 'tool', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, + { capabilityId: 'subagent', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, + { capabilityId: 'mcp', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, +]; + +function policy( + overrides: Partial = {}, +): ExternalSourceCatalogSnapshot['integrationPolicy'] { + return { + schemaMajor: 1, + status: 'compatible', + userDefaults: { enabled: true, ecosystems: {} }, + globalEffective: { enabled: true, ecosystems: {} }, + effective: { enabled: true, ecosystems: {} }, + registeredEcosystems: [ + { ecosystemId: 'opencode', displayName: 'OpenCode', adapterRevision: 'r1', capabilities: OPENCODE_CAPABILITIES }, + { ecosystemId: 'claude-code', displayName: 'Claude Code', adapterRevision: 'r1', capabilities: OPENCODE_CAPABILITIES }, + ], + ...overrides, + }; +} + +function withMode( + ecosystemId: string, + mode: 'recommended' | 'discover_only' | 'disabled' | 'custom', +): ExternalSourceCatalogSnapshot['integrationPolicy'] { + const ecosystems = { + [ecosystemId]: { ecosystemId, mode, capabilities: {} }, + }; + return policy({ + effective: { enabled: true, ecosystems }, + globalEffective: { enabled: true, ecosystems }, + }); +} + +function source( + stableKey: string, + ecosystemId: string, + overrides: Partial = {}, +): ExternalSourceCatalogSnapshot['sources'][number] { + return { + stableKey, + presentationGroupId: `${ecosystemId}-config`, + lifecycle: 'available', + record: { + key: { providerId: `${ecosystemId}.commands`, sourceId: 'user-configuration' }, + ecosystemId, + displayName: `${ecosystemId} configuration`, + sourceKind: 'configuration', + scope: 'user_global', + location: `~/.config/${ecosystemId}/config.json`, + executionDomainId: 'local', + health: 'available', + contentVersion: 'v1', + ...overrides, + }, + }; +} + +function snapshot( + overrides: Partial = {}, +): ExternalSourceCatalogSnapshot { + return { + hostCapabilities: { + canRefresh: true, + canMutatePolicy: true, + canManageSources: true, + canApproveRuntime: true, + canExecuteExternalAssets: true, + canSetSafeMode: true, + canRevealSourceLocation: true, + }, + generation: 1, + discoveryPending: false, + sources: [], + commands: [], + tools: [], + mcpServers: [], + subagents: [], + integrationPolicy: policy(), + ...overrides, + }; +} + +function view(input: ExternalSourceCatalogSnapshot, catalogAttention = 0) { + return buildExternalApplicationsView( + input, + buildExternalSourcePresentationGroups(input), + catalogAttention, + 'workspace', + ); +} + +describe('external application model', () => { + it('lists every registered ecosystem even when nothing was discovered', () => { + const result = view(snapshot()); + + expect(result.applications.map((application) => application.ecosystemId)) + .toEqual(['opencode', 'claude-code']); + expect(result.applications[0].status).toBe('no_configuration'); + expect(result.applications[0].primaryAction).toBe('none'); + }); + + it('reports checking while discovery is still running', () => { + const result = view(snapshot({ discoveryPending: true })); + + expect(result.applications[0].status).toBe('checking'); + }); + + it('treats a recommended ecosystem with sources as connected', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'recommended'), + })); + + const opencode = result.applications[0]; + expect(opencode.status).toBe('connected'); + expect(opencode.primaryAction).toBe('manage'); + }); + + it('keeps custom ecosystems on manage so a two-state toggle cannot flatten them', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'custom'), + })); + + expect(result.applications[0].status).toBe('connected_custom'); + expect(result.applications[0].primaryAction).toBe('manage'); + }); + + it('offers connect for a discovered but discover-only ecosystem', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'discover_only'), + })); + + expect(result.applications[0].status).toBe('discovered'); + expect(result.applications[0].primaryAction).toBe('connect'); + }); + + it('attributes tool approvals to the owning ecosystem', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'recommended'), + toolApprovalRequests: [{ + approvalKey: 'approval-1', + decisionKey: 'decision-1', + targetId: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'tool-a', + }, + sourceDisplayName: 'OpenCode', + sourceLocation: '~/.config/opencode', + sourceScope: 'user_global', + toolNames: ['tool-a'], + runtimeKind: 'node', + workingDirectory: '~/.config/opencode', + capabilities: ['file_system'], + contentVersion: 'v1', + }], + })); + + const opencode = result.applications[0]; + expect(opencode.attentionCount).toBe(1); + expect(opencode.status).toBe('needs_attention'); + expect(opencode.primaryAction).toBe('review'); + expect(result.unattributedAttentionCount).toBe(0); + }); + + it('keeps catalog diagnostics and policy incompatibility out of per-application counts', () => { + const result = view( + snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: policy({ status: 'incompatible_schema' }), + }), + 2, + ); + + expect(result.applications.every((application) => application.attentionCount === 0)) + .toBe(true); + expect(result.unattributedAttentionCount).toBe(0); + expect(result.totalAttentionCount).toBe(0); + }); + + it('does not attribute a conflict that spans two ecosystems', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode'), source('claude-user', 'claude-code')], + commandConflicts: [{ + conflictKey: 'conflict-1', + commandName: 'review', + candidates: [ + { + candidateId: 'candidate-opencode', + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + sourceDisplayName: 'OpenCode', + ecosystemId: 'opencode', + contentVersion: 'v1', + commandDescription: 'Review', + sourceScope: 'user_global', + sourceLocation: '~/.config/opencode', + availability: { state: 'available' }, + }, + { + candidateId: 'candidate-claude', + source: { providerId: 'claude-code.commands', sourceId: 'user-configuration' }, + sourceDisplayName: 'Claude Code', + ecosystemId: 'claude-code', + contentVersion: 'v1', + commandDescription: 'Review', + sourceScope: 'user_global', + sourceLocation: '~/.claude', + availability: { state: 'available' }, + }, + ], + }], + })); + + expect(result.applications.every((application) => application.attentionCount === 0)) + .toBe(true); + expect(result.unattributedAttentionCount).toBe(1); + }); + + it('ignores conflicts the user already resolved', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + commandConflicts: [{ + conflictKey: 'conflict-1', + commandName: 'review', + selectedCandidateId: 'candidate-opencode', + candidates: [{ + candidateId: 'candidate-opencode', + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + sourceDisplayName: 'OpenCode', + ecosystemId: 'opencode', + contentVersion: 'v1', + commandDescription: 'Review', + sourceScope: 'user_global', + sourceLocation: '~/.config/opencode', + availability: { state: 'available' }, + }], + }], + })); + + expect(result.totalAttentionCount).toBe(0); + }); + + it('exposes what connecting would enable so the dialog never hard-codes access levels', () => { + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: withMode('opencode', 'discover_only'), + commands: [{ + candidateId: 'command-1', + definition: { + id: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'review', + }, + name: 'review', + description: 'Review', + availability: { state: 'available' }, + contentVersion: 'v1', + }, + }], + })); + + const plan = result.applications[0].connectPlan; + expect(plan.find((entry) => entry.capabilityId === 'command')) + .toMatchObject({ recommendedAccess: 'auto', count: 1 }); + expect(plan.find((entry) => entry.capabilityId === 'tool')) + .toMatchObject({ recommendedAccess: 'ask_before_use', count: 0 }); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts new file mode 100644 index 0000000000..c4c6f9fd21 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts @@ -0,0 +1,290 @@ +import type { + ExternalIntegrationAccess, + ExternalIntegrationMode, + ExternalSourceCatalogSnapshot, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import type { + ExternalSourceCapabilityCounts, + ExternalSourcePresentationGroup, +} from '../../externalSourcePresentation'; + +/** + * Application status shown on the overview. + * + * V1 has no application connection facts, so `connected` is derived from the + * effective integration mode rather than a real connection lifecycle. The + * design's "temporarily unavailable" state is intentionally absent: V1 cannot + * separate "not installed" from "probe failed", and guessing would mislead. + */ +export type ExternalApplicationStatus = + | 'needs_attention' + | 'connected' + | 'connected_custom' + | 'discovered' + | 'checking' + | 'no_configuration'; + +export type ExternalApplicationAction = 'connect' | 'manage' | 'review' | 'none'; + +export interface ExternalApplicationCapabilityPlan { + capabilityId: string; + /** Access this capability reaches once the ecosystem switches to recommended. */ + recommendedAccess: ExternalIntegrationAccess; + count: number; +} + +export interface ExternalApplicationView { + ecosystemId: string; + displayName: string; + mode: ExternalIntegrationMode; + status: ExternalApplicationStatus; + primaryAction: ExternalApplicationAction; + counts: ExternalSourceCapabilityCounts; + sourceCount: number; + locations: string[]; + /** Attention items that could be attributed to this ecosystem. */ + attentionCount: number; + /** What switching to `recommended` would enable, used by the connect dialog. */ + connectPlan: ExternalApplicationCapabilityPlan[]; +} + +export interface ExternalApplicationsView { + applications: ExternalApplicationView[]; + /** Attention items with no ecosystem identity (catalog diagnostics, policy). */ + unattributedAttentionCount: number; + totalAttentionCount: number; +} + +const CAPABILITY_COUNT_FIELD: Record = { + command: 'commands', + tool: 'tools', + subagent: 'agents', + mcp: 'mcps', +}; + +function sourcePairKey(providerId: string, sourceId: string): string { + return `${providerId}\u0000${sourceId}`; +} + +/** + * Maps every discovered source pair to its ecosystem so attention items that + * only carry a source identity can still be attributed to an application. + */ +function ecosystemBySourcePair(snapshot: ExternalSourceCatalogSnapshot): Map { + const bySource = new Map(); + for (const source of snapshot.sources) { + bySource.set( + sourcePairKey(source.record.key.providerId, source.record.key.sourceId), + source.record.ecosystemId, + ); + } + return bySource; +} + +function addAttention(counts: Map, ecosystemId: string | undefined): boolean { + if (!ecosystemId) return false; + counts.set(ecosystemId, (counts.get(ecosystemId) ?? 0) + 1); + return true; +} + +/** + * Attributes pending approvals and unresolved conflicts to ecosystems. + * + * Items that cannot be attributed — catalog-level diagnostics, policy + * incompatibility, conflict candidates without a source — are counted + * separately instead of being spread across applications. + */ +function attentionByEcosystem( + snapshot: ExternalSourceCatalogSnapshot, + _groups: ExternalSourcePresentationGroup[], + _catalogAttentionCount: number, + _policyIncompatible: boolean, +): { byEcosystem: Map; unattributed: number } { + const byEcosystem = new Map(); + const bySource = ecosystemBySourcePair(snapshot); + // Diagnostics and policy incompatibility are system status, not user + // decisions. They must not inflate the review count shown in the overview. + let unattributed = 0; + + for (const request of snapshot.toolApprovalRequests ?? []) { + const ecosystemId = bySource.get(sourcePairKey( + request.targetId.source.providerId, + request.targetId.source.sourceId, + )); + if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + } + + for (const request of snapshot.mcpApprovalRequests ?? []) { + const ecosystemId = bySource.get(sourcePairKey( + request.definition.id.source.providerId, + request.definition.id.source.sourceId, + )); + if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + } + + const subagentById = new Map( + (snapshot.subagents ?? []).map((agent) => [agent.candidateId, agent]), + ); + for (const candidateId of snapshot.pendingSubagentApprovals ?? []) { + const agent = subagentById.get(candidateId); + // A subagent may span several sources; the first resolvable one owns the + // item so a single approval is never counted twice. + const ecosystemId = agent?.sourceKeys + .map((key) => bySource.get(sourcePairKey(key.providerId, key.sourceId))) + .find((value): value is string => Boolean(value)); + if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + } + + for (const conflict of snapshot.commandConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + const ecosystemIds = new Set(conflict.candidates.map((candidate) => candidate.ecosystemId)); + if (ecosystemIds.size === 1) { + addAttention(byEcosystem, [...ecosystemIds][0]); + } else { + // Cross-ecosystem collisions belong to no single application. + unattributed += 1; + } + } + + for (const conflict of snapshot.toolConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + const ecosystemIds = new Set( + conflict.candidates + .map((candidate) => (candidate.source + ? bySource.get(sourcePairKey(candidate.source.providerId, candidate.source.sourceId)) + : undefined)) + .filter((value): value is string => Boolean(value)), + ); + if (ecosystemIds.size === 1) { + addAttention(byEcosystem, [...ecosystemIds][0]); + } else { + unattributed += 1; + } + } + + for (const conflict of snapshot.mcpConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + const ecosystemIds = new Set( + conflict.candidates + .map((candidate) => (candidate.source + ? bySource.get(sourcePairKey(candidate.source.providerId, candidate.source.sourceId)) + : undefined)) + .filter((value): value is string => Boolean(value)), + ); + if (ecosystemIds.size === 1) { + addAttention(byEcosystem, [...ecosystemIds][0]); + } else { + unattributed += 1; + } + } + + for (const conflict of snapshot.subagentConflicts ?? []) { + if (conflict.selectedCandidateId) continue; + // Subagent conflict candidates carry no source identity in V1. + unattributed += 1; + } + + return { byEcosystem, unattributed }; +} + +function statusFor( + mode: ExternalIntegrationMode, + sourceCount: number, + attentionCount: number, + discoveryPending: boolean, +): ExternalApplicationStatus { + if (attentionCount > 0) return 'needs_attention'; + if (sourceCount === 0) return discoveryPending ? 'checking' : 'no_configuration'; + if (mode === 'recommended') return 'connected'; + if (mode === 'custom') return 'connected_custom'; + return 'discovered'; +} + +function actionFor(status: ExternalApplicationStatus): ExternalApplicationAction { + switch (status) { + case 'needs_attention': + return 'review'; + case 'connected': + case 'connected_custom': + return 'manage'; + case 'discovered': + return 'connect'; + default: + return 'none'; + } +} + +/** + * Builds the application-level overview from a V1 snapshot. + * + * Pure derivation: no host calls, no policy decisions beyond reading the + * effective mode the host already computed. + */ +export function buildExternalApplicationsView( + snapshot: ExternalSourceCatalogSnapshot | null, + groups: ExternalSourcePresentationGroup[], + catalogAttentionCount: number, + policyScope: 'user' | 'workspace', +): ExternalApplicationsView { + if (!snapshot) { + return { applications: [], unattributedAttentionCount: 0, totalAttentionCount: 0 }; + } + + const policy = snapshot.integrationPolicy; + const policyIncompatible = policy.status !== 'compatible'; + const effective = policyScope === 'workspace' ? policy.effective : policy.globalEffective; + const { byEcosystem, unattributed } = attentionByEcosystem( + snapshot, + groups, + catalogAttentionCount, + policyIncompatible, + ); + + const applications = policy.registeredEcosystems.map((descriptor) => { + const ecosystemId = descriptor.ecosystemId; + const ecosystemGroups = groups.filter((group) => group.ecosystemId === ecosystemId); + const sources = snapshot.sources.filter( + (source) => source.record.ecosystemId === ecosystemId, + ); + const counts = ecosystemGroups.reduce((total, group) => ({ + commands: total.commands + group.counts.commands, + tools: total.tools + group.counts.tools, + agents: total.agents + group.counts.agents, + mcps: total.mcps + group.counts.mcps, + }), { commands: 0, tools: 0, agents: 0, mcps: 0 }); + + const mode = effective.ecosystems[ecosystemId]?.mode ?? 'recommended'; + const attentionCount = byEcosystem.get(ecosystemId) ?? 0; + const status = statusFor(mode, sources.length, attentionCount, snapshot.discoveryPending); + + return { + ecosystemId, + displayName: descriptor.displayName, + mode, + status, + primaryAction: actionFor(status), + counts, + sourceCount: sources.length, + locations: Array.from(new Set(sources.map((source) => source.record.location))), + attentionCount, + connectPlan: descriptor.capabilities.map((capability) => ({ + capabilityId: capability.capabilityId, + recommendedAccess: capability.recommendedAccess, + count: CAPABILITY_COUNT_FIELD[capability.capabilityId] + ? counts[CAPABILITY_COUNT_FIELD[capability.capabilityId]] + : 0, + })), + }; + }); + + const totalAttentionCount = applications.reduce( + (total, application) => total + application.attentionCount, + unattributed, + ); + + return { + applications, + unattributedAttentionCount: unattributed, + totalAttentionCount, + }; +} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts new file mode 100644 index 0000000000..5e68fe9e95 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -0,0 +1,23 @@ +export type { ExternalHostCapabilities, ExternalSectionCommonProps } from './types'; +export { + SOURCE_COUNT_LABELS, + abbreviatedLocation, + sourceDiagnosticCategory, + sourceScopeLabel, +} from './presentation'; +export { ExternalCommandConflicts } from './ExternalCommandConflicts'; +export type { ExternalCommandConflictsProps } from './ExternalCommandConflicts'; +export { ExternalSourceSection } from './ExternalSourceSection'; +export type { ExternalSourceSectionProps } from './ExternalSourceSection'; +export { useExternalAppAwareness } from './useExternalAppAwareness'; +export { ExternalAppsOverview } from './ExternalAppsOverview'; +export type { ExternalAppsOverviewProps } from './ExternalAppsOverview'; +export { ExternalAppDetail } from './ExternalAppDetail'; +export type { ExternalAppDetailProps } from './ExternalAppDetail'; +export { buildExternalApplicationsView } from './applicationModel'; +export type { + ExternalApplicationView, + ExternalApplicationsView, + ExternalApplicationStatus, + ExternalApplicationAction, +} from './applicationModel'; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts new file mode 100644 index 0000000000..f9a80f033c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/presentation.ts @@ -0,0 +1,69 @@ +import type { TFunction } from 'i18next'; +import type { + ExternalSourceCatalogSnapshot, + ExternalToolCatalogEntry, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; + +/** Trims deep paths to the trailing segments that identify the source. */ +export function abbreviatedLocation(location: string): string { + const normalized = location.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + return segments.length <= 3 ? normalized : `…/${segments.slice(-3).join('/')}`; +} + +export function matchesToolSource( + source: ExternalSourceCatalogSnapshot['sources'][number], + tool: ExternalToolCatalogEntry, +): boolean { + return source.record.key.providerId === tool.definition.id.target.source.providerId + && source.record.key.sourceId === tool.definition.id.target.source.sourceId; +} + +export function executionLocationLabel(t: TFunction, executionDomainId?: string): string { + if (executionDomainId?.startsWith('local')) return t('executionLocation.local'); + if (executionDomainId?.startsWith('remote')) return t('executionLocation.remote'); + return t('executionLocation.unknown'); +} + +export function sourceScopeLabel(scope: string, t: TFunction): string { + return scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${scope}`); +} + +/** Capability counters shown as badges on a source group. */ +export const SOURCE_COUNT_LABELS = [ + ['commands', 'sources.commandCount'], + ['tools', 'sources.toolCount'], + ['agents', 'sources.agentCount'], + ['mcps', 'sources.mcpCount'], +] as const; + +/** + * Maps a raw diagnostic code to a user-facing category. Codes stay in the + * collapsed technical details; the category is what the user reads first. + */ +export function sourceDiagnosticCategory(code: string): string { + if (code.includes('preference_read_failed')) return 'confirmationStateUnavailable'; + if (code.includes('conflict_history_write_failed')) return 'conflictHistoryUnavailable'; + if (code.includes('discovery_in_progress')) return 'checkInProgress'; + if (code.includes('timeout')) return 'checkTimedOut'; + if (code.includes('trust_required')) return 'confirmationRequired'; + if (code.includes('too_large') || code.includes('file_limit') || code.includes('bytes_limit')) { + return 'sourceTooLarge'; + } + if (code.includes('invalid') || code.includes('parse') || code.includes('definition') + || code.includes('export_missing') || code.includes('name_unsupported')) { + return 'invalidSettings'; + } + if (code.includes('unreadable') || code.includes('read_failed') + || code.includes('metadata_failed') || code.includes('directory_')) { + return 'unreadableSource'; + } + if (code.includes('projection_only') || code.includes('unsupported') + || code.includes('restricted')) { + return 'notSupported'; + } + if (code.includes('failed')) return 'checkFailed'; + return 'sourceIssue'; +} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/types.ts b/src/web-ui/src/infrastructure/config/components/external-sources/types.ts new file mode 100644 index 0000000000..762e35f70d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/types.ts @@ -0,0 +1,27 @@ +import type { TFunction } from 'i18next'; +import type React from 'react'; +import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; + +/** + * Host capability gates travel with the snapshot, so section components read + * them from the same authoritative object the controller validated. + */ +export type ExternalHostCapabilities = ExternalSourceCatalogSnapshot['hostCapabilities']; + +/** + * Shared contract for every external-sources section. + * + * Sections are presentation-only: they hold no state, issue no requests, and + * derive no policy. The controller owns request sequencing, mutation fencing + * and stale-response rejection, and passes results down through these props. + */ +export interface ExternalSectionCommonProps { + snapshot: ExternalSourceCatalogSnapshot; + t: TFunction; + /** Mutation key currently in flight, used to drive per-control loading state. */ + busyKey: string | null; + hostCapabilities: ExternalHostCapabilities; + policyCompatible: boolean; + /** Renders a reveal-in-explorer link, or a disabled hint when unsupported. */ + renderPathLink: (location: string, sourceKey?: string) => React.ReactNode; +} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts new file mode 100644 index 0000000000..a21c5a9e91 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react'; +import { externalSourcesAPI } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { useOptionalCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; +import { createLogger } from '@/shared/utils/logger'; + +const logger = createLogger('ExternalAppAwareness'); + +/** Marks the external sources tab when the host found an application the user + * has never been told about, and clears it once they open the tab. + * + * The lookup is lazy on purpose: it only runs while the settings scene is + * mounted, so a user who never opens settings pays nothing. Failures stay + * silent because a missing hint is far less harmful than an error toast for + * something the user did not ask for. + */ +export function useExternalAppAwareness(): void { + const { workspacePath } = useOptionalCurrentWorkspace(); + const activeTab = useSettingsStore((state) => state.activeTab); + const markTabUnseen = useSettingsStore((state) => state.markTabUnseen); + const acknowledgedScopeRef = useRef(null); + + useEffect(() => { + let cancelled = false; + void externalSourcesAPI + .getEcosystemAwareness(workspacePath) + .then((unacknowledged) => { + if (cancelled) return; + markTabUnseen('external-sources', unacknowledged.length > 0); + }) + .catch((error) => { + logger.debug('Could not read external application awareness', { error }); + }); + return () => { + cancelled = true; + }; + }, [markTabUnseen, workspacePath]); + + useEffect(() => { + if (activeTab !== 'external-sources' || acknowledgedScopeRef.current === workspacePath) return; + acknowledgedScopeRef.current = workspacePath; + // Clear the dot immediately: the user is looking at the list right now, so + // waiting for the host round-trip would leave a stale marker on screen. + markTabUnseen('external-sources', false); + void externalSourcesAPI + .getEcosystemAwareness(workspacePath) + .then((unacknowledged) => (unacknowledged.length > 0 + ? externalSourcesAPI.acknowledgeEcosystems(workspacePath, unacknowledged) + : undefined)) + .catch((error) => { + logger.debug('Could not record external application awareness', { error }); + }); + }, [activeTab, markTabUnseen, workspacePath]); +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 27c7017b7d..97fc9ed608 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -802,6 +802,10 @@ "nativeCommandReconfirmationRequired": "The external command you previously chose is no longer available. Choose the BitFun command from the slash menu to run it.", "selectHint": "↑↓ Select · Enter Confirm · Esc Cancel", "current": "Current", + "commandStatus": { + "restricted": "Restricted", + "chooseSource": "Choose source" + }, "computerUseDisabled": "Computer Use is disabled in Settings → Session permissions", "professionalMode": "Expert Mode", "designMode": "Design Mode", diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 80009a077d..92c344519f 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -5,6 +5,7 @@ "searchNoResults": "No matching settings", "searchClear": "Clear search", "beta": "Beta", + "unseenItems": "Not seen yet", "searchAliases": { "archivedSessions": [], "keyboard": [], diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index fab5c93320..2c57837734 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -4,6 +4,66 @@ "legacyHostNotice": "This execution Host uses the legacy external-source protocol. Safe Mode requires a Host upgrade.", "loading": "Checking external sources…", "checkingNonBlocking": "Checking for updates…", + "applications": { + "title": "Discovered applications", + "description": "BitFun continuously reads connected application settings and checks changes before they become available.", + "status": { + "needs_attention": "Needs attention", + "connected": "Connected", + "connected_custom": "Connected · Custom", + "discovered": "Configuration found", + "checking": "Checking", + "no_configuration": "No configuration found" + }, + "summary": { + "attention": "{{count}} item needs review", + "attention_other": "{{count}} items need review", + "checking": "Looking for configuration", + "noContent": "Nothing available yet" + }, + "counts": { + "commands_one": "{{count}} command", + "commands_other": "{{count}} commands", + "tools_one": "{{count}} tool", + "tools_other": "{{count}} tools", + "agents_one": "{{count}} agent", + "agents_other": "{{count}} agents", + "mcps_one": "{{count}} MCP server", + "mcps_other": "{{count}} MCP servers" + }, + "actions": { + "connect": "Connect", + "manage": "Manage", + "review": "Review" + }, + "connectTitle": "Connect {{name}}", + "connectMessage": "{{commands}} commands will be available automatically. {{tools}} tools, {{agents}} agents, and {{mcps}} MCP servers will still require confirmation before they can run.", + "connectionComplete": "The application is connected. Review any capabilities that still need confirmation.", + "advanced": { + "title": "Advanced settings", + "description": "Sources, capability access, diagnostics, and conflicts" + }, + "detail": { + "back": "Back to applications", + "reviewTitle_one": "{{count}} item needs review", + "reviewTitle_other": "{{count}} items need review", + "reviewDescription": "Executable capabilities do not run until they are confirmed.", + "sourceSummary_one": "{{count}} configuration source", + "sourceSummary_other": "{{count}} configuration sources", + "usingTitle": "Available content", + "usingDescription": "Low-risk content can be available automatically; executable content remains controlled.", + "foundCount_one": "{{count}} found", + "foundCount_other": "{{count}} found", + "autoAvailable": "Available automatically", + "managed": "Managed", + "capabilities": { + "commands": "Commands", + "tools": "Tools", + "agents": "Agents", + "mcps": "MCP servers" + } + } + }, "hooks": { "title": "Hooks", "description": "View Hook configuration from OpenCode, Claude Code, and Codex. BitFun only reads config files without running any code.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 9317e0c39a..9d1f686bfb 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -796,6 +796,10 @@ "nativeCommandReconfirmationRequired": "之前选择的外部命令已不可用。请从斜杠菜单中选择 BitFun 命令后再执行。", "selectHint": "↑↓ 选择 · Enter 确认 · Esc 取消", "current": "当前", + "commandStatus": { + "restricted": "受限", + "chooseSource": "选择来源" + }, "computerUseDisabled": "已在「设置 → 会话权限」中禁用 Computer Use", "professionalMode": "专业模式", "designMode": "设计模式", diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index c457d350f7..fbf3396cc6 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -5,6 +5,7 @@ "searchNoResults": "没有匹配的配置", "searchClear": "清除搜索", "beta": "Beta", + "unseenItems": "尚未查看", "searchAliases": { "archivedSessions": [ "归档", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index 8116a23cae..bb7bcc6771 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -4,6 +4,66 @@ "legacyHostNotice": "当前执行 Host 使用旧版外部来源协议;升级 Host 后才能使用安全模式。", "loading": "正在检查外部来源…", "checkingNonBlocking": "正在检查更新…", + "applications": { + "title": "已发现的应用", + "description": "连接后 BitFun 会持续读取来源配置,并在内容可用前检查变更。", + "status": { + "needs_attention": "需要处理", + "connected": "已连接", + "connected_custom": "已连接 · 自定义", + "discovered": "发现可用配置", + "checking": "正在检查", + "no_configuration": "未发现配置" + }, + "summary": { + "attention": "{{count}} 项等待确认", + "attention_other": "{{count}} 项等待确认", + "checking": "正在查找配置", + "noContent": "暂时没有可用内容" + }, + "counts": { + "commands_one": "{{count}} 个命令", + "commands_other": "{{count}} 个命令", + "tools_one": "{{count}} 个工具", + "tools_other": "{{count}} 个工具", + "agents_one": "{{count}} 个 Agent", + "agents_other": "{{count}} 个 Agent", + "mcps_one": "{{count}} 个 MCP 服务器", + "mcps_other": "{{count}} 个 MCP 服务器" + }, + "actions": { + "connect": "连接", + "manage": "管理", + "review": "检查" + }, + "connectTitle": "连接 {{name}}", + "connectMessage": "{{commands}} 个命令将自动可用;{{tools}} 个工具、{{agents}} 个 Agent 和 {{mcps}} 个 MCP 服务器在运行前仍需要确认。", + "connectionComplete": "应用已连接。仍需确认的能力会保留在检查列表中。", + "advanced": { + "title": "高级设置", + "description": "配置来源、能力访问、诊断与冲突" + }, + "detail": { + "back": "返回应用列表", + "reviewTitle_one": "{{count}} 项等待确认", + "reviewTitle_other": "{{count}} 项等待确认", + "reviewDescription": "可执行能力在确认前不会运行。", + "sourceSummary_one": "{{count}} 个配置来源", + "sourceSummary_other": "{{count}} 个配置来源", + "usingTitle": "可用内容", + "usingDescription": "低风险内容可以自动可用;可执行内容仍受控。", + "foundCount_one": "发现 {{count}} 项", + "foundCount_other": "发现 {{count}} 项", + "autoAvailable": "自动可用", + "managed": "管理", + "capabilities": { + "commands": "命令", + "tools": "工具", + "agents": "Agent", + "mcps": "MCP 服务器" + } + } + }, "hooks": { "title": "Hooks", "description": "查看 OpenCode、Claude Code 和 Codex 的 Hook 配置。BitFun 只读取配置文件,不运行任何代码。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d3b210c9e3..76a016de51 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -796,6 +796,10 @@ "nativeCommandReconfirmationRequired": "先前選擇的外部命令已無法使用。請從斜線選單中選擇 BitFun 命令後再執行。", "selectHint": "↑↓ 選擇 · Enter 確認 · Esc 取消", "current": "目前", + "commandStatus": { + "restricted": "受限", + "chooseSource": "選擇來源" + }, "computerUseDisabled": "已在「設定 → 工作階段權限」中停用 Computer Use", "professionalMode": "專業模式", "designMode": "設計模式", diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 272c1c7c50..a3e49b264e 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -5,6 +5,7 @@ "searchNoResults": "沒有匹配的設定", "searchClear": "清除搜尋", "beta": "Beta", + "unseenItems": "尚未檢視", "searchAliases": { "archivedSessions": [ "歸檔", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 7f865abf49..6bbcff8911 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -4,6 +4,66 @@ "legacyHostNotice": "目前執行 Host 使用舊版外部來源協定;升級 Host 後才能使用安全模式。", "loading": "正在檢查外部來源…", "checkingNonBlocking": "正在檢查更新…", + "applications": { + "title": "已發現的應用", + "description": "連線後 BitFun 會持續讀取來源設定,並在內容可用前檢查變更。", + "status": { + "needs_attention": "需要處理", + "connected": "已連線", + "connected_custom": "已連線 · 自訂", + "discovered": "發現可用設定", + "checking": "正在檢查", + "no_configuration": "未發現設定" + }, + "summary": { + "attention": "{{count}} 項等待確認", + "attention_other": "{{count}} 項等待確認", + "checking": "正在尋找設定", + "noContent": "暫時沒有可用內容" + }, + "counts": { + "commands_one": "{{count}} 個命令", + "commands_other": "{{count}} 個命令", + "tools_one": "{{count}} 個工具", + "tools_other": "{{count}} 個工具", + "agents_one": "{{count}} 個 Agent", + "agents_other": "{{count}} 個 Agent", + "mcps_one": "{{count}} 個 MCP 伺服器", + "mcps_other": "{{count}} 個 MCP 伺服器" + }, + "actions": { + "connect": "連線", + "manage": "管理", + "review": "檢查" + }, + "connectTitle": "連線 {{name}}", + "connectMessage": "{{commands}} 個命令將自動可用;{{tools}} 個工具、{{agents}} 個 Agent 和 {{mcps}} 個 MCP 伺服器在執行前仍需要確認。", + "connectionComplete": "應用已連線。仍需確認的能力會保留在檢查清單中。", + "advanced": { + "title": "進階設定", + "description": "設定來源、能力存取、診斷與衝突" + }, + "detail": { + "back": "返回應用清單", + "reviewTitle_one": "{{count}} 項等待確認", + "reviewTitle_other": "{{count}} 項等待確認", + "reviewDescription": "可執行能力在確認前不會執行。", + "sourceSummary_one": "{{count}} 個設定來源", + "sourceSummary_other": "{{count}} 個設定來源", + "usingTitle": "可用內容", + "usingDescription": "低風險內容可以自動可用;可執行內容仍受控。", + "foundCount_one": "發現 {{count}} 項", + "foundCount_other": "發現 {{count}} 項", + "autoAvailable": "自動可用", + "managed": "管理", + "capabilities": { + "commands": "命令", + "tools": "工具", + "agents": "Agent", + "mcps": "MCP 伺服器" + } + } + }, "hooks": { "title": "Hooks", "description": "檢視 OpenCode、Claude Code 與 Codex 的 Hook 設定。BitFun 只讀取設定檔,不執行任何程式碼。", From 3dec3ac28af826d608ca7de5f93fa5dfe6bece0c Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 7 Aug 2026 17:37:40 +0800 Subject: [PATCH 2/3] fix(external-sources): harden application connection UI --- .../components/ExternalSourcesConfig.scss | 3 +- .../components/ExternalSourcesConfig.tsx | 14 ++- .../external-sources/applicationModel.test.ts | 29 +++-- .../external-sources/applicationModel.ts | 31 ++++-- .../components/external-sources/index.ts | 2 +- .../useExternalAppAwareness.test.tsx | 100 ++++++++++++++++++ .../useExternalAppAwareness.ts | 22 ++-- .../en-US/settings/external-sources.json | 6 +- .../zh-CN/settings/external-sources.json | 6 +- .../zh-TW/settings/external-sources.json | 6 +- 10 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 81c07748ee..52fbca4e33 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -123,8 +123,7 @@ strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } small { margin-top: 3px; } } - - + &__app-list { display: grid; overflow: hidden; border: 1px solid var(--bf-appearance-token-border-subtle); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 68e6ed323e..16b9278c51 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -63,6 +63,7 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, + buildExternalConnectionMessage, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -631,8 +632,8 @@ const ExternalSourcesConfig: React.FC = () => { [snapshot, sourceGroups], ); const applicationsView = useMemo( - () => buildExternalApplicationsView(snapshot, sourceGroups, catalogDiagnostics.length, policyScope), - [catalogDiagnostics.length, policyScope, snapshot, sourceGroups], + () => buildExternalApplicationsView(snapshot, sourceGroups, policyScope), + [policyScope, snapshot, sourceGroups], ); const selectedApplication = applicationsView.applications.find( (application) => application.ecosystemId === selectedApplicationId, @@ -3188,12 +3189,9 @@ const ExternalSourcesConfig: React.FC = () => { onClose={() => setConnectingApplication(null)} onConfirm={() => void connectApplication()} title={connectingApplication ? t('applications.connectTitle', { name: connectingApplication.displayName }) : ''} - message={connectingApplication ? t('applications.connectMessage', { - commands: connectingApplication.counts.commands, - tools: connectingApplication.counts.tools, - agents: connectingApplication.counts.agents, - mcps: connectingApplication.counts.mcps, - }) : ''} + message={connectingApplication + ? buildExternalConnectionMessage(connectingApplication.connectPlan, t, policyScope) + : ''} type="info" confirmText={t('applications.actions.connect')} /> diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts index 9268b3eb22..9e0c2b528d 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts @@ -4,7 +4,7 @@ import type { ExternalSourceRecord, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { buildExternalSourcePresentationGroups } from '../../externalSourcePresentation'; -import { buildExternalApplicationsView } from './applicationModel'; +import { buildExternalApplicationsView, buildExternalConnectionMessage } from './applicationModel'; const OPENCODE_CAPABILITIES = [ { capabilityId: 'command', recommendedAccess: 'auto' as const, safetyCeiling: 'auto' as const }, @@ -92,11 +92,10 @@ function snapshot( }; } -function view(input: ExternalSourceCatalogSnapshot, catalogAttention = 0) { +function view(input: ExternalSourceCatalogSnapshot) { return buildExternalApplicationsView( input, buildExternalSourcePresentationGroups(input), - catalogAttention, 'workspace', ); } @@ -178,13 +177,10 @@ describe('external application model', () => { }); it('keeps catalog diagnostics and policy incompatibility out of per-application counts', () => { - const result = view( - snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: policy({ status: 'incompatible_schema' }), - }), - 2, - ); + const result = view(snapshot({ + sources: [source('opencode-user', 'opencode')], + integrationPolicy: policy({ status: 'incompatible_schema' }), + })); expect(result.applications.every((application) => application.attentionCount === 0)) .toBe(true); @@ -279,4 +275,17 @@ describe('external application model', () => { expect(plan.find((entry) => entry.capabilityId === 'tool')) .toMatchObject({ recommendedAccess: 'ask_before_use', count: 0 }); }); + + it('builds connection copy from the capability plan instead of fixed capability names', () => { + const result = view(snapshot({ + sources: [source('claude-user', 'claude-code')], + integrationPolicy: withMode('claude-code', 'discover_only'), + })); + + expect(buildExternalConnectionMessage(result.applications[1].connectPlan, (key, params) => + `${key}:${JSON.stringify(params)}`, + )).toBe( + 'applications.connectSummary:{"automaticCount":0,"managedCount":0,"scope":"applications.connectScope.workspace:undefined"}', + ); + }); }); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts index c4c6f9fd21..e53422c1f7 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts @@ -3,6 +3,7 @@ import type { ExternalIntegrationMode, ExternalSourceCatalogSnapshot, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import type { TFunction } from 'i18next'; import type { ExternalSourceCapabilityCounts, ExternalSourcePresentationGroup, @@ -62,6 +63,24 @@ const CAPABILITY_COUNT_FIELD: Record entry.recommendedAccess === 'auto') + .reduce((total, entry) => total + entry.count, 0); + const managedCount = plan + .filter((entry) => entry.recommendedAccess !== 'auto') + .reduce((total, entry) => total + entry.count, 0); + return t('applications.connectSummary', { + automaticCount, + managedCount, + scope: t(`applications.connectScope.${scope}`), + }); +} + function sourcePairKey(providerId: string, sourceId: string): string { return `${providerId}\u0000${sourceId}`; } @@ -96,9 +115,6 @@ function addAttention(counts: Map, ecosystemId: string | undefin */ function attentionByEcosystem( snapshot: ExternalSourceCatalogSnapshot, - _groups: ExternalSourcePresentationGroup[], - _catalogAttentionCount: number, - _policyIncompatible: boolean, ): { byEcosystem: Map; unattributed: number } { const byEcosystem = new Map(); const bySource = ecosystemBySourcePair(snapshot); @@ -223,7 +239,6 @@ function actionFor(status: ExternalApplicationStatus): ExternalApplicationAction export function buildExternalApplicationsView( snapshot: ExternalSourceCatalogSnapshot | null, groups: ExternalSourcePresentationGroup[], - catalogAttentionCount: number, policyScope: 'user' | 'workspace', ): ExternalApplicationsView { if (!snapshot) { @@ -231,14 +246,8 @@ export function buildExternalApplicationsView( } const policy = snapshot.integrationPolicy; - const policyIncompatible = policy.status !== 'compatible'; const effective = policyScope === 'workspace' ? policy.effective : policy.globalEffective; - const { byEcosystem, unattributed } = attentionByEcosystem( - snapshot, - groups, - catalogAttentionCount, - policyIncompatible, - ); + const { byEcosystem, unattributed } = attentionByEcosystem(snapshot); const applications = policy.registeredEcosystems.map((descriptor) => { const ecosystemId = descriptor.ecosystemId; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts index 5e68fe9e95..ec452b5fbd 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -14,7 +14,7 @@ export { ExternalAppsOverview } from './ExternalAppsOverview'; export type { ExternalAppsOverviewProps } from './ExternalAppsOverview'; export { ExternalAppDetail } from './ExternalAppDetail'; export type { ExternalAppDetailProps } from './ExternalAppDetail'; -export { buildExternalApplicationsView } from './applicationModel'; +export { buildExternalApplicationsView, buildExternalConnectionMessage } from './applicationModel'; export type { ExternalApplicationView, ExternalApplicationsView, diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx new file mode 100644 index 0000000000..923b5356e2 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useExternalAppAwareness } from './useExternalAppAwareness'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const getAwarenessMock = vi.hoisted(() => vi.fn()); +const acknowledgeMock = vi.hoisted(() => vi.fn()); +const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project', kind: 'normal' })); +const settingsState = vi.hoisted(() => ({ activeTab: 'general', markTabUnseen: vi.fn() })); + +vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ + externalSourcesAPI: { + getEcosystemAwareness: getAwarenessMock, + acknowledgeEcosystems: acknowledgeMock, + }, +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useOptionalCurrentWorkspace: () => ({ + workspace: { workspaceKind: workspaceState.kind }, + workspacePath: workspaceState.path, + }), +})); +vi.mock('@/app/scenes/settings/settingsStore', () => ({ + useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState), +})); +vi.mock('@/shared/types', () => ({ + isRemoteWorkspace: (workspace: { workspaceKind?: string } | null) => workspace?.workspaceKind === 'remote', +})); +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ debug: vi.fn() }), +})); + +function Harness() { + useExternalAppAwareness(); + return null; +} + +async function flush() { + await act(async () => { await Promise.resolve(); }); +} + +describe('useExternalAppAwareness', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + root = createRoot(container); + workspaceState.path = 'D:/workspace/project'; + workspaceState.kind = 'normal'; + settingsState.activeTab = 'general'; + settingsState.markTabUnseen.mockReset(); + getAwarenessMock.mockReset().mockResolvedValue(['opencode']); + acknowledgeMock.mockReset().mockResolvedValue(undefined); + }); + + it('does not call local-only awareness commands for remote workspaces', async () => { + workspaceState.kind = 'remote'; + await act(async () => { root.render(); }); + await flush(); + + expect(getAwarenessMock).not.toHaveBeenCalled(); + }); + + it('does not restore a stale dot after acknowledgement wins the initial-read race', async () => { + let resolveInitial: ((ids: string[]) => void) | undefined; + getAwarenessMock + .mockImplementationOnce(() => new Promise((resolve) => { resolveInitial = resolve; })) + .mockResolvedValueOnce(['opencode']); + settingsState.activeTab = 'external-sources'; + + await act(async () => { root.render(); }); + await flush(); + await flush(); + await act(async () => { resolveInitial?.(['opencode']); }); + + expect(settingsState.markTabUnseen).not.toHaveBeenLastCalledWith('external-sources', true); + }); + + it('allows acknowledgement to retry after a failed persistence attempt', async () => { + settingsState.activeTab = 'external-sources'; + acknowledgeMock.mockRejectedValueOnce(new Error('write failed')).mockResolvedValueOnce(undefined); + await act(async () => { root.render(); }); + await flush(); + await flush(); + + settingsState.activeTab = 'general'; + await act(async () => { root.render(); }); + settingsState.activeTab = 'external-sources'; + await act(async () => { root.render(); }); + await flush(); + await flush(); + + expect(acknowledgeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts index a21c5a9e91..7663bcb454 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/useExternalAppAwareness.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { externalSourcesAPI } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { useOptionalCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { isRemoteWorkspace } from '@/shared/types'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import { createLogger } from '@/shared/utils/logger'; @@ -15,17 +16,18 @@ const logger = createLogger('ExternalAppAwareness'); * something the user did not ask for. */ export function useExternalAppAwareness(): void { - const { workspacePath } = useOptionalCurrentWorkspace(); + const { workspace, workspacePath } = useOptionalCurrentWorkspace(); const activeTab = useSettingsStore((state) => state.activeTab); const markTabUnseen = useSettingsStore((state) => state.markTabUnseen); const acknowledgedScopeRef = useRef(null); useEffect(() => { + if (isRemoteWorkspace(workspace)) return; let cancelled = false; void externalSourcesAPI .getEcosystemAwareness(workspacePath) .then((unacknowledged) => { - if (cancelled) return; + if (cancelled || acknowledgedScopeRef.current === workspacePath) return; markTabUnseen('external-sources', unacknowledged.length > 0); }) .catch((error) => { @@ -34,21 +36,25 @@ export function useExternalAppAwareness(): void { return () => { cancelled = true; }; - }, [markTabUnseen, workspacePath]); + }, [markTabUnseen, workspace, workspacePath]); useEffect(() => { - if (activeTab !== 'external-sources' || acknowledgedScopeRef.current === workspacePath) return; - acknowledgedScopeRef.current = workspacePath; - // Clear the dot immediately: the user is looking at the list right now, so - // waiting for the host round-trip would leave a stale marker on screen. + if (isRemoteWorkspace(workspace) + || activeTab !== 'external-sources' + || acknowledgedScopeRef.current === workspacePath) return; + // Clear the dot immediately while allowing a failed host write to retry. markTabUnseen('external-sources', false); void externalSourcesAPI .getEcosystemAwareness(workspacePath) .then((unacknowledged) => (unacknowledged.length > 0 ? externalSourcesAPI.acknowledgeEcosystems(workspacePath, unacknowledged) : undefined)) + .then(() => { + acknowledgedScopeRef.current = workspacePath; + }) .catch((error) => { + markTabUnseen('external-sources', true); logger.debug('Could not record external application awareness', { error }); }); - }, [activeTab, markTabUnseen, workspacePath]); + }, [activeTab, markTabUnseen, workspace, workspacePath]); } diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 2c57837734..2266491ab1 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -37,7 +37,11 @@ "review": "Review" }, "connectTitle": "Connect {{name}}", - "connectMessage": "{{commands}} commands will be available automatically. {{tools}} tools, {{agents}} agents, and {{mcps}} MCP servers will still require confirmation before they can run.", + "connectSummary": "{{automaticCount}} low-risk items will be available automatically; {{managedCount}} executable items remain managed. Scope: {{scope}}.", + "connectScope": { + "workspace": "this workspace", + "user": "all workspaces in this execution domain" + }, "connectionComplete": "The application is connected. Review any capabilities that still need confirmation.", "advanced": { "title": "Advanced settings", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index bb7bcc6771..b335a66074 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -37,7 +37,11 @@ "review": "检查" }, "connectTitle": "连接 {{name}}", - "connectMessage": "{{commands}} 个命令将自动可用;{{tools}} 个工具、{{agents}} 个 Agent 和 {{mcps}} 个 MCP 服务器在运行前仍需要确认。", + "connectSummary": "{{automaticCount}} 项低风险内容将自动可用;{{managedCount}} 项可执行内容仍受管理。作用范围:{{scope}}。", + "connectScope": { + "workspace": "仅当前工作区", + "user": "当前执行域中的所有工作区" + }, "connectionComplete": "应用已连接。仍需确认的能力会保留在检查列表中。", "advanced": { "title": "高级设置", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 6bbcff8911..b9bdeb89c9 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -37,7 +37,11 @@ "review": "檢查" }, "connectTitle": "連線 {{name}}", - "connectMessage": "{{commands}} 個命令將自動可用;{{tools}} 個工具、{{agents}} 個 Agent 和 {{mcps}} 個 MCP 伺服器在執行前仍需要確認。", + "connectSummary": "{{automaticCount}} 項低風險內容將自動可用;{{managedCount}} 項可執行內容仍受管理。作用範圍:{{scope}}。", + "connectScope": { + "workspace": "僅目前工作區", + "user": "目前執行域中的所有工作區" + }, "connectionComplete": "應用已連線。仍需確認的能力會保留在檢查清單中。", "advanced": { "title": "進階設定", From e73b2b48ca0bc00de0a12f787fd7b35926a85d28 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 7 Aug 2026 18:44:05 +0800 Subject: [PATCH 3/3] fix(external-sources): register application appearance parts --- .../external-sources/ExternalAppDetail.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx index e805f980d8..7b937d11ec 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppDetail.tsx @@ -30,7 +30,11 @@ export const ExternalAppDetail: React.FC = ({ onBack, onOpenAdvanced, }) => ( -
+
@@ -53,6 +57,8 @@ export const ExternalAppDetail: React.FC = ({