diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index fde64900c..f7af5f2e4 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -243,7 +243,21 @@ impl ExternalSubagentSourceProvider for OpenCodeSubagentProvider { for layer in self.discover_layers(&input.context)? { let source_key = source_key(&layer); - if input.suppressed_sources.contains(&source_key) { + let suppressed = input.suppressed_sources.contains(&source_key); + if suppressed { + sources.push(ExternalSourceRecord { + key: source_key, + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static OpenCode ecosystem id must be valid"), + display_name: layer.display_name.clone(), + source_kind: layer.source_kind().to_string(), + scope: layer.scope, + location: layer.path.to_string_lossy().to_string(), + execution_domain_id: input.context.execution_domain_id.clone(), + health: ExternalSourceHealth::Available, + content_version: digest([layer.path.to_string_lossy().as_ref()]), + diagnostics: Vec::new(), + }); continue; } let parsed = parse_layer(&layer)?; diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs index 8bebd2224..3ab763aa9 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs @@ -122,6 +122,41 @@ fn global_and_project_agent_fields_deep_merge_with_ordered_provenance() { assert_eq!(without_project.definitions[0].provenance.len(), 1); } +#[test] +fn suppressed_agent_source_remains_discoverable_for_reenable() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "review": { + "description": "Review agent", + "prompt": "Review the change", + "mode": "subagent" + } + } + }"#, + ) + .unwrap(); + + let provider = provider(&temp, &workspace); + let initial = discover(&provider, workspace.clone(), BTreeSet::new()); + let source_key = initial.sources[0].key.clone(); + fs::write(temp.path().join("user/opencode.json"), "{ invalid").unwrap(); + let suppressed = discover( + &provider, + workspace, + [source_key.clone()].into_iter().collect(), + ); + + assert!(suppressed.definitions.is_empty()); + assert_eq!(suppressed.sources.len(), 1); + assert_eq!(suppressed.sources[0].key, source_key); +} + #[test] fn safe_subset_is_fail_closed_and_default_tools_are_explicit() { let temp = TempDir::new().unwrap(); diff --git a/src/crates/assembly/core/src/external_mcp_tests.rs b/src/crates/assembly/core/src/external_mcp_tests.rs index 640277f96..63e80e239 100644 --- a/src/crates/assembly/core/src/external_mcp_tests.rs +++ b/src/crates/assembly/core/src/external_mcp_tests.rs @@ -67,6 +67,7 @@ fn snapshot(behavior_version: &str) -> ExternalMcpCoordinatorSnapshot { discovery_pending: false, sources: vec![ExternalSourceCatalogEntry { stable_key: source.preference_key(), + presentation_group_id: None, record: source, lifecycle: ExternalSourceLifecycleState::Available, }], diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 5c6f3b098..b225336e0 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -1176,6 +1176,7 @@ impl WorkspaceExternalSourceService { } policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; snapshot.integration_policy = policy; + assign_external_source_presentation_groups(&mut snapshot); sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); let mut current = lock_snapshot(&self.snapshot); let mcp_changed = snapshot.mcp_servers != current.mcp_servers @@ -1296,6 +1297,7 @@ impl WorkspaceExternalSourceService { } } snapshot.integration_policy = policy; + assign_external_source_presentation_groups(&mut snapshot); sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); let mut current = lock_snapshot(&self.snapshot); let mcp_changed = snapshot.mcp_servers != current.mcp_servers @@ -2078,7 +2080,7 @@ impl WorkspaceExternalSourceService { enabled: bool, expected_preference_revision: u64, ) -> Result { - let _refresh_guard = self.refresh_gate.lock().await; + let refresh_guard = self.refresh_gate.lock().await; if self.snapshot().preference_revision != expected_preference_revision { return Err(stale_operation_error( "External source preferences changed; refresh before retrying", @@ -2138,6 +2140,10 @@ impl WorkspaceExternalSourceService { lock_mcp_coordinator(&self.mcp_coordinator) .replace_suppressed_sources(authoritative.clone()); propagate_suppressed_sources(&authoritative, self); + // Refresh acquires the same gate. Release the mutation critical section + // after the preference and in-memory coordinators agree, then refresh + // from the authoritative store to avoid self-deadlocking the request. + drop(refresh_guard); self.refresh_preserving_worker_recovery().await } @@ -3220,6 +3226,47 @@ pub(super) fn safe_external_source_location( } } +fn assign_external_source_presentation_groups(snapshot: &mut ExternalSourceCatalogSnapshot) { + let mut groups = BTreeMap::<(String, String, String), Vec>::new(); + for (index, source) in snapshot.sources.iter().enumerate() { + let normalized_location = source + .record + .location + .trim() + .replace('\\', "/") + .trim_end_matches('/') + .to_string(); + let location_key = if normalized_location.is_empty() { + format!("", source.stable_key) + } else { + normalized_location + }; + groups + .entry(( + source.record.ecosystem_id.as_str().to_string(), + source.record.execution_domain_id.as_str().to_string(), + location_key, + )) + .or_default() + .push(index); + } + + for indices in groups.into_values() { + let mut stable_keys = indices + .iter() + .map(|index| snapshot.sources[*index].stable_key.as_str()) + .collect::>(); + stable_keys.sort_unstable(); + let group_id = format!( + "external-source:{}", + serde_json::to_string(&stable_keys).unwrap_or_default() + ); + for index in indices { + snapshot.sources[index].presentation_group_id = Some(group_id.clone()); + } + } +} + fn sanitize_external_snapshot_locations( snapshot: &mut ExternalSourceCatalogSnapshot, workspace_root: Option<&Path>, @@ -5223,6 +5270,7 @@ mod tests { discovery_pending: false, sources: vec![ExternalSourceCatalogEntry { stable_key: source_key.stable_key(), + presentation_group_id: None, record: ExternalSourceRecord { key: source_key.clone(), ecosystem_id: EcosystemId::new("future-ai").unwrap(), @@ -5287,6 +5335,84 @@ mod tests { assert!(!serialized.contains("C:/Users/alice")); } + #[test] + fn presentation_groups_are_assigned_before_location_redaction() { + let make_source = |provider_id: &str, stable_key: &str, location: &str| { + let source_key = SourceKey::new(provider_id, "user-configuration").unwrap(); + ExternalSourceCatalogEntry { + stable_key: stable_key.to_string(), + presentation_group_id: None, + record: ExternalSourceRecord { + key: source_key, + ecosystem_id: EcosystemId::new("opencode").unwrap(), + display_name: "OpenCode user configuration".to_string(), + source_kind: "configuration".to_string(), + scope: ExternalSourceScope::RemoteUser, + location: location.to_string(), + execution_domain_id: ExecutionDomainId::new("peer-a").unwrap(), + health: + bitfun_product_domains::external_sources::ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + }, + lifecycle: ExternalSourceLifecycleState::Available, + } + }; + let mut snapshot = ExternalSourceCatalogSnapshot { + generation: 0, + discovery_pending: false, + sources: vec![ + make_source( + "opencode.commands", + "command-source", + "/remote/alice/.config/opencode/opencode.json", + ), + make_source( + "opencode.subagents", + "agent-source", + "/remote/alice/.config/opencode/opencode.json", + ), + make_source( + "opencode.mcp", + "other-user-source", + "/remote/bob/.config/opencode/opencode.json", + ), + ], + commands: Vec::new(), + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + assign_external_source_presentation_groups(&mut snapshot); + sanitize_external_snapshot_locations(&mut snapshot, None); + + assert_eq!( + snapshot.sources[0].presentation_group_id, + snapshot.sources[1].presentation_group_id, + ); + assert_ne!( + snapshot.sources[0].presentation_group_id, + snapshot.sources[2].presentation_group_id, + ); + assert!(snapshot + .sources + .iter() + .all(|source| source.record.location == "/.config/opencode/opencode.json")); + } + struct DelayedProvider { identity: PromptCommandProviderIdentity, source: SourceKey, diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index b14742db0..43f717d92 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -1167,6 +1167,7 @@ mod tests { )]), sources: vec![ExternalSourceCatalogEntry { stable_key: "source".to_string(), + presentation_group_id: None, record: ExternalSourceRecord { key: source, ecosystem_id: EcosystemId::new("fake").unwrap(), diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 78a794531..eb784eb45 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -557,6 +557,7 @@ impl ExternalSourceCoordinator { } sources.push(ExternalSourceCatalogEntry { stable_key, + presentation_group_id: None, record: record.clone(), lifecycle, }); @@ -612,6 +613,7 @@ impl ExternalSourceCoordinator { sources.extend(self.removed_sources.iter().map(|(stable_key, record)| { ExternalSourceCatalogEntry { stable_key: stable_key.clone(), + presentation_group_id: None, record: record.clone(), lifecycle: ExternalSourceLifecycleState::Removed, } diff --git a/src/crates/assembly/external-sources/src/mcp.rs b/src/crates/assembly/external-sources/src/mcp.rs index 4db191265..e4dd1a62a 100644 --- a/src/crates/assembly/external-sources/src/mcp.rs +++ b/src/crates/assembly/external-sources/src/mcp.rs @@ -361,6 +361,7 @@ impl ExternalMcpCoordinator { let suppressed = self.suppressed_sources.contains(&source.preference_key()); sources.push(ExternalSourceCatalogEntry { stable_key: source.preference_key(), + presentation_group_id: None, record: source.clone(), lifecycle: source_lifecycle(source.health, suppressed, failed), }); diff --git a/src/crates/assembly/external-sources/src/subagent.rs b/src/crates/assembly/external-sources/src/subagent.rs index 597df5efe..6ff3b7c51 100644 --- a/src/crates/assembly/external-sources/src/subagent.rs +++ b/src/crates/assembly/external-sources/src/subagent.rs @@ -371,6 +371,7 @@ impl ExternalSubagentCoordinator { let suppressed = suppressed_keys.contains(&source.key); sources.push(ExternalSourceCatalogEntry { stable_key: source.preference_key(), + presentation_group_id: None, record: source.clone(), lifecycle: if suppressed { ExternalSourceLifecycleState::Suppressed diff --git a/src/crates/assembly/external-sources/src/tool.rs b/src/crates/assembly/external-sources/src/tool.rs index 96a0aadff..2a5d76daa 100644 --- a/src/crates/assembly/external-sources/src/tool.rs +++ b/src/crates/assembly/external-sources/src/tool.rs @@ -330,6 +330,7 @@ impl ExternalToolCoordinator { let suppressed = self.suppressed_sources.contains(&source.preference_key()); sources.push(ExternalSourceCatalogEntry { stable_key: source.preference_key(), + presentation_group_id: None, record: source.clone(), lifecycle: if suppressed { ExternalSourceLifecycleState::Suppressed diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 68907cab1..084eedeec 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -1458,6 +1458,11 @@ pub enum ExternalSourceLifecycleState { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceCatalogEntry { pub stable_key: String, + /// Opaque identity shared by provider records that describe the same + /// physical source. Product surfaces may coalesce matching entries without + /// comparing redacted display locations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub presentation_group_id: Option, pub record: ExternalSourceRecord, pub lifecycle: ExternalSourceLifecycleState, } diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index d49019a47..2e5a9ef58 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -12,10 +12,11 @@ use bitfun_product_domains::external_sources::{ ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpStaticStatus, - ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, - ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, - ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, - ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, + ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogEntry, + ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, + ExternalSourcePublicSnapshot, ExternalSourceRecord, ExternalSourceScope, + ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, PreparedExternalMcpServer, PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, @@ -100,6 +101,28 @@ fn source_and_command_identity_remain_provider_qualified() { assert_ne!(left.stable_key(), right.stable_key()); } +#[test] +fn presentation_group_id_is_optional_and_uses_the_camel_case_wire_name() { + let mut entry = ExternalSourceCatalogEntry { + stable_key: "opencode.commands:project".to_string(), + presentation_group_id: None, + record: source("opencode.commands", "opencode", "project"), + lifecycle: ExternalSourceLifecycleState::Available, + }; + + let legacy_value = serde_json::to_value(&entry).unwrap(); + assert!(legacy_value.get("presentationGroupId").is_none()); + let legacy_entry: ExternalSourceCatalogEntry = serde_json::from_value(legacy_value).unwrap(); + assert!(legacy_entry.presentation_group_id.is_none()); + + entry.presentation_group_id = Some("external-source:[\"source\"]".to_string()); + let current_value = serde_json::to_value(&entry).unwrap(); + assert_eq!( + current_value["presentationGroupId"], + "external-source:[\"source\"]" + ); +} + #[test] fn conflict_fingerprint_is_order_independent_and_changes_with_content() { let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]); 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 b03e5d82b..948443fc1 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -133,6 +133,7 @@ export interface ExternalSourceCatalogSnapshot { discoveryPending: boolean; sources: Array<{ stableKey: string; + presentationGroupId?: string; record: ExternalSourceRecord; lifecycle: ExternalSourceLifecycle; }>; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index d645bf0bb..bcaceedb9 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -288,6 +288,93 @@ overflow-wrap: anywhere; } + &__source-scopes { + display: inline; + } + + &__source-members { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--size-gap-1); + flex-wrap: wrap; + max-width: 420px; + } + + &__source-member { + flex-direction: row-reverse; + align-items: center; + justify-content: space-between; + gap: var(--size-gap-2); + min-height: 32px; + padding: 4px 7px 4px 9px; + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-sm); + background: var(--color-bg-tertiary); + cursor: pointer; + + &:focus-within { + border-color: var(--color-accent-500); + } + + .bitfun-switch__wrapper { + margin-top: 0; + } + + .bitfun-switch__content { + min-width: 0; + gap: 0; + text-align: left; + } + + .bitfun-switch__label { + font-size: 12px; + line-height: 15px; + } + + .bitfun-switch__description { + font-size: 10px; + line-height: 13px; + } + } + + &__source-description, + &__source-origin, + &__source-counts { + display: flex; + align-items: center; + flex-wrap: wrap; + } + + &__source-description { + gap: var(--size-gap-2); + min-width: 0; + } + + &__source-origin { + gap: var(--size-gap-1); + min-width: 0; + } + + &__source-counts { + gap: var(--size-gap-1); + } + + &__source-count { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-sm); + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 16px; + white-space: nowrap; + } + &__state { color: var(--color-text-secondary); font-size: 11px; @@ -381,6 +468,22 @@ } @container external-sources (max-width: 720px) { + &__source-group.bitfun-config-page-row { + grid-template-columns: minmax(0, 1fr); + gap: var(--size-gap-2); + align-items: stretch; + } + + &__source-group .bitfun-config-page-row__control { + justify-content: flex-start; + } + + &__source-members { + width: 100%; + max-width: none; + justify-content: flex-start; + } + &__ecosystem-card, &__capability-row { align-items: stretch; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 21ea7fbe5..c7e100b49 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -75,6 +75,7 @@ const snapshot = { discoveryPending: false, sources: [{ stableKey: 'source-key', + presentationGroupId: 'source-key', record: { key: { providerId: 'opencode.commands', sourceId: 'project' }, ecosystemId: 'opencode', @@ -131,6 +132,19 @@ const snapshot = { }, }; +const discoveredCommand = { + definition: { + id: { + source: { providerId: 'opencode.commands', sourceId: 'project' }, + localId: 'review', + }, + name: 'review', + description: 'Review with OpenCode', + availability: { state: 'available' }, + contentVersion: 'v1', + }, +}; + const integrationPolicy = { schemaMajor: 1, status: 'compatible', @@ -256,7 +270,9 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('OpenCode'); expect(container.textContent).toContain('policy.mode.recommended'); expect(container.textContent).toContain('policy.inherited'); - expect(container.textContent).not.toContain('policy.capability.command'); + expect(container.querySelectorAll( + '.bitfun-external-sources-config__capability-row', + )).toHaveLength(0); const capabilityButton = Array.from(container.querySelectorAll('button')).find((button) => button.getAttribute('aria-label') === 'policy.capabilitiesFor:{"ecosystem":"OpenCode"}'); @@ -363,6 +379,135 @@ describe('ExternalSourcesConfig', () => { ); }); + it('combines duplicate physical sources and exposes honest capability-level controls', async () => { + const sharedRecord = { + ...snapshot.sources[0].record, + sourceKind: 'opencode_user_configuration', + scope: 'user_global', + location: '~\\.config\\opencode\\opencode.json', + executionDomainId: 'local', + displayName: 'OpenCode user configuration', + }; + const groupedSnapshot = { + ...snapshot, + preferenceRevision: 7, + diagnostics: [], + commandConflicts: [], + sources: [{ + stableKey: 'opencode-command-source', + presentationGroupId: 'opencode-user-config', + record: { + ...sharedRecord, + key: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + }, + lifecycle: 'available', + }, { + stableKey: 'opencode-agent-source', + presentationGroupId: 'opencode-user-config', + record: { + ...sharedRecord, + key: { providerId: 'opencode.subagents', sourceId: 'user-configuration' }, + }, + lifecycle: 'suppressed', + }], + commands: [{ + definition: { + id: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'smoke-command', + }, + name: 'smoke-command', + description: 'Smoke command', + availability: { state: 'available' }, + contentVersion: 'v1', + }, + }], + subagents: [{ + candidateId: 'smoke-agent', + logicalId: 'smoke-agent', + displayName: 'Smoke agent', + description: 'Smoke agent', + providerLabel: 'OpenCode', + scope: 'user_global', + sourceKeys: [{ providerId: 'opencode.subagents', sourceId: 'user-configuration' }], + sourceLocationLabels: ['~/.config/opencode/opencode.json'], + sourceCount: 1, + effectiveToolLabels: [], + supportsFollowUp: false, + compatibilityState: 'ready', + diagnostics: [], + activationState: { state: 'active' }, + decisionKey: 'smoke-agent', + }], + }; + getSnapshotMock.mockResolvedValue(groupedSnapshot); + setSourceEnabledMock.mockImplementation(async () => ({ + ...groupedSnapshot, + preferenceRevision: 8, + })); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.querySelectorAll( + '.bitfun-external-sources-config__source-group', + )).toHaveLength(1); + expect(container.textContent?.match(/OpenCode user configuration/g)).toHaveLength(1); + expect(container.textContent).toContain('sources.commandCount:{"count":1}'); + expect(container.textContent).toContain('sources.agentCount:{"count":1}'); + expect(container.textContent).not.toContain('sources.toolCount:{"count":0}'); + expect(container.textContent).not.toContain('sources.mcpCount:{"count":0}'); + + const sourceToggles = Array.from(container.querySelectorAll( + 'input[aria-label^="sources.toggleLabel"]', + )) as HTMLInputElement[]; + expect(sourceToggles).toHaveLength(2); + expect(sourceToggles.map((toggle) => toggle.checked).sort()).toEqual([false, true]); + expect(sourceToggles.every((toggle) => ( + toggle.getAttribute('aria-label')?.includes('scope.user_global') + ))).toBe(true); + expect(sourceToggles.some((toggle) => ( + toggle.getAttribute('aria-label')?.includes('lifecycle.suppressed') + ))).toBe(true); + const enabledToggle = sourceToggles.find((toggle) => toggle.checked); + await act(async () => enabledToggle?.click()); + + expect(setSourceEnabledMock).toHaveBeenCalledOnce(); + expect(setSourceEnabledMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'opencode-command-source', + false, + 7, + ); + }); + + it('does not show source configuration UI when discovery found no usable content', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + sources: [{ + ...snapshot.sources[0], + record: { + ...snapshot.sources[0].record, + diagnostics: [{ severity: 'info', code: 'discovered', message: 'Discovered.' }], + }, + }], + commands: [], + commandConflicts: [], + diagnostics: [], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).not.toContain('sources.title'); + expect(container.textContent).not.toContain('sources.empty'); + expect(container.textContent).not.toContain('OpenCode project commands'); + }); + it('reviews MCP risk and binds approval and conflict choices to the visible versions', async () => { const mcpSnapshot = { ...snapshot, @@ -1044,7 +1189,7 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('agentDiagnostics.invalidDefinition.reason'); expect(container.textContent).toContain('agentConflicts.selectionApproves'); expect(container.textContent).toContain('.opencode/agents/explore.md'); - expect(container.textContent).toContain('sources.agentCount:{"count":1}'); + expect(container.textContent).toContain('sources.agentCount:{"count":2}'); expect(container.textContent).not.toContain('D:/workspace/project/.opencode/agents'); expect(container.innerHTML).not.toContain('D:'); expect(container.innerHTML).not.toContain('D:/shared'); @@ -1440,8 +1585,46 @@ describe('ExternalSourcesConfig', () => { const sourceToggle = container.querySelector( 'input[aria-label^="sources.toggleLabel"]', ) as HTMLInputElement; - expect(sourceToggle.disabled).toBe(true); + expect(sourceToggle).not.toBeNull(); expect(sourceToggle.checked).toBe(false); + expect(sourceToggle.disabled).toBe(true); + expect(container.textContent).toContain('sources.title'); + }); + + it('counts a repeated grouped source diagnostic only once in the attention summary', async () => { + const diagnostic = { + severity: 'warning', + code: 'opencode.configuration.invalid', + message: 'The configuration could not be parsed.', + }; + getSnapshotMock.mockResolvedValue({ + ...snapshot, + diagnostics: [diagnostic], + commandConflicts: [], + sources: [{ + ...snapshot.sources[0], + stableKey: 'command-source', + presentationGroupId: 'project-config', + record: { ...snapshot.sources[0].record, diagnostics: [diagnostic] }, + }, { + ...snapshot.sources[0], + stableKey: 'agent-source', + presentationGroupId: 'project-config', + record: { + ...snapshot.sources[0].record, + key: { providerId: 'opencode.subagents', sourceId: 'project' }, + diagnostics: [diagnostic], + }, + }], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('policy.attentionSummary:{"count":1}'); + expect(container.textContent?.match(/opencode\.configuration\.invalid/g)).toHaveLength(1); }); it('ignores an older workspace response after switching workspaces', async () => { @@ -1461,6 +1644,7 @@ describe('ExternalSourcesConfig', () => { location: '/.opencode/commands', }, }], + commands: [discoveredCommand], diagnostics: [], commandConflicts: [], }; @@ -1505,6 +1689,7 @@ describe('ExternalSourcesConfig', () => { displayName: 'Peer B commands', }, }], + commands: [discoveredCommand], diagnostics: [], commandConflicts: [], }); @@ -1546,6 +1731,7 @@ describe('ExternalSourcesConfig', () => { displayName: 'Other workspace commands', }, }], + commands: [discoveredCommand], diagnostics: [], commandConflicts: [], }; @@ -1681,7 +1867,25 @@ describe('ExternalSourcesConfig', () => { it('fails closed for a legacy read-only host and never sends a mutation', async () => { const { hostCapabilities: _omitted, ...legacySnapshot } = snapshot; - getSnapshotMock.mockResolvedValue(legacySnapshot); + const withoutGroupId = snapshot.sources.map((source) => { + const { presentationGroupId: _groupId, ...legacySource } = source; + return legacySource; + }); + getSnapshotMock.mockResolvedValue({ + ...legacySnapshot, + sources: [ + ...withoutGroupId, + { + ...withoutGroupId[0], + stableKey: 'legacy-suppressed-agent', + lifecycle: 'suppressed', + record: { + ...withoutGroupId[0].record, + key: { providerId: 'opencode.agents', sourceId: 'project' }, + }, + }, + ], + }); await act(async () => { root.render(); @@ -1694,6 +1898,9 @@ describe('ExternalSourcesConfig', () => { const policyToggle = container.querySelector( '.bitfun-external-sources-config__policy-card input[type="checkbox"]', ) as HTMLInputElement; + expect(container.querySelectorAll( + '.bitfun-external-sources-config__source-group', + )).toHaveLength(2); expect(sourceToggle.disabled).toBe(true); expect(policyToggle.disabled).toBe(true); await act(async () => { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 60afead1c..63a3c7986 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -39,10 +39,21 @@ import { ConfigPageRow, ConfigPageSection, } from './common'; +import { + buildExternalSourcePresentationGroups, + catalogDiagnosticsWithoutSourceDuplicates, + externalSourceDiagnosticKey, +} from '../externalSourcePresentation'; import { externalSourceRequestScopeKey } from './externalSourceRequestScope'; 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; type SnapshotLoadResult = | { status: 'accepted'; snapshot: ExternalSourceCatalogSnapshot } @@ -439,58 +450,14 @@ const ExternalSourcesConfig: React.FC = () => { }; }, [loadSnapshot, snapshot?.discoveryPending]); - const commandCounts = useMemo(() => { - const namesBySource = new Map>(); - const add = (providerId: string, sourceId: string, commandName: string) => { - const key = `${providerId}\u0000${sourceId}`; - const names = namesBySource.get(key) ?? new Set(); - names.add(commandName.toLowerCase()); - namesBySource.set(key, names); - }; - for (const command of snapshot?.commands ?? []) { - const source = command.definition.id.source; - add(source.providerId, source.sourceId, command.definition.name); - } - for (const conflict of snapshot?.commandConflicts ?? []) { - for (const candidate of conflict.candidates) { - add(candidate.source.providerId, candidate.source.sourceId, conflict.commandName); - } - } - return new Map( - Array.from(namesBySource, ([source, names]) => [source, names.size]), - ); - }, [snapshot]); - - const toolCounts = useMemo(() => { - const counts = new Map(); - for (const tool of snapshot?.tools ?? []) { - const source = tool.definition.id.target.source; - const key = `${source.providerId}\u0000${source.sourceId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [snapshot?.tools]); - - const agentCounts = useMemo(() => { - const counts = new Map(); - for (const agent of snapshot?.subagents ?? []) { - for (const source of agent.sourceKeys) { - const key = `${source.providerId}\u0000${source.sourceId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - } - return counts; - }, [snapshot]); - - const mcpCounts = useMemo(() => { - const counts = new Map(); - for (const server of snapshot?.mcpServers ?? []) { - const source = server.definition.id.source; - const key = `${source.providerId}\u0000${source.sourceId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [snapshot?.mcpServers]); + const sourceGroups = useMemo( + () => snapshot ? buildExternalSourcePresentationGroups(snapshot) : [], + [snapshot], + ); + const catalogDiagnostics = useMemo( + () => snapshot ? catalogDiagnosticsWithoutSourceDuplicates(snapshot, sourceGroups) : [], + [snapshot, sourceGroups], + ); const commandConflicts = useMemo( () => unresolvedFirst(snapshot?.commandConflicts ?? []), @@ -591,18 +558,22 @@ const ExternalSourcesConfig: React.FC = () => { } }, [acceptMutationSnapshot, readOnlyHintKey, requestScope, t]); - const setEnabled = useCallback(async (sourceKey: string, enabled: boolean) => { - if (!snapshot) return; + const setEnabled = useCallback(async ( + sourceKey: string, + enabled: boolean, + ) => { + const currentSnapshot = snapshotRef.current; + if (!currentSnapshot) return; await runMutation( sourceKey, () => externalSourcesAPI.setSourceEnabled( workspacePath, sourceKey, enabled, - snapshot.preferenceRevision ?? 0, + currentSnapshot.preferenceRevision ?? 0, ), ); - }, [runMutation, snapshot, workspacePath]); + }, [runMutation, workspacePath]); const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { if (!snapshot) return; @@ -838,12 +809,9 @@ const ExternalSourcesConfig: React.FC = () => { } return accessRank[requested] <= accessRank[ceiling] ? requested : ceiling; }; - const diagnosticAttentionCount = (snapshot?.diagnostics ?? []) + const diagnosticAttentionCount = catalogDiagnostics .filter((diagnostic) => diagnostic.severity !== 'info').length - + (snapshot?.sources ?? []).reduce((count, source) => ( - count + (source.record.diagnostics ?? []) - .filter((diagnostic) => diagnostic.severity !== 'info').length - ), 0); + + sourceGroups.reduce((count, group) => count + group.diagnostics.length, 0); const externalAttentionCount = (snapshot?.toolApprovalRequests?.length ?? 0) + (snapshot?.pendingSubagentApprovals?.length ?? 0) + (snapshot?.mcpApprovalRequests?.length ?? 0) @@ -1269,18 +1237,18 @@ const ExternalSourcesConfig: React.FC = () => { {agentChangeNotice.message} ) : null} - {(snapshot?.diagnostics?.length ?? 0) > 0 ? ( + {catalogDiagnostics.length > 0 ? (
diagnostic.severity !== 'info') ? 'true' : undefined} > - {t('diagnostics.summary', { count: snapshot?.diagnostics?.length ?? 0 })} + {t('diagnostics.summary', { count: catalogDiagnostics.length })}
    - {snapshot?.diagnostics?.map((diagnostic, index) => ( -
  • + {catalogDiagnostics.map((diagnostic) => ( +
  • {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)}
    {t('common.technicalDetails')} @@ -2042,90 +2010,136 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} - - {snapshot && !snapshot.discoveryPending && snapshot.sources.length === 0 ? ( -
    {t('sources.empty')}
    - ) : snapshot?.sources.map((source) => { - const sourcePair = `${source.record.key.providerId}\u0000${source.record.key.sourceId}`; - const removed = source.lifecycle === 'removed'; - const enabled = !removed && source.lifecycle !== 'suppressed'; - const sourceDiagnostics = (source.record.diagnostics ?? []) - .filter((diagnostic) => diagnostic.severity !== 'info'); - return ( - - - 0 ? ( + + {sourceGroups.map((group) => { + return ( + + + + + {group.location} + + + + {group.scopes.map((scope, index) => ( + + {index > 0 ? : null} + + {scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${scope}`)} + + + ))} + + + {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" + > +
    - {source.record.location} - - {' · '} - {source.record.scope === 'workspace_local' - ? t('shared:features.workspace') - : t(`scope.${source.record.scope}`)} - {' · '} - {t('sources.commandCount', { count: commandCounts.get(sourcePair) ?? 0 })} - {' · '} - {t('sources.toolCount', { count: toolCounts.get(sourcePair) ?? 0 })} - {' · '} - {t('sources.agentCount', { count: agentCounts.get(sourcePair) ?? 0 })} - {' · '} - {t('sources.mcpCount', { count: mcpCounts.get(sourcePair) ?? 0 })} - - )} - align="center" - > -
    - - {t(`lifecycle.${source.lifecycle}`)} - - void setEnabled(source.stableKey, event.currentTarget.checked)} - /> -
    - - {sourceDiagnostics.length > 0 ? ( -
    diagnostic.severity !== 'info') ? 'true' : undefined} - > - - {t('diagnostics.sourceSummary', { - name: source.record.displayName, - count: sourceDiagnostics.length, - })} - -
      - {sourceDiagnostics.map((diagnostic, index) => ( -
    • - {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} -
      - {t('common.technicalDetails')} - {diagnostic.code} -
      {diagnostic.message}
      -
      -
    • - ))} -
    -
    - ) : null} - - ); - })} - + {group.members.map((member) => { + const capabilityLabel = member.capability === 'source' + ? group.displayName + : t(`policy.capability.${member.capability}`); + const scopeLabel = member.scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${member.scope}`); + return ( + 1 ? scopeLabel : undefined} + checked={member.enabled} + disabled={!policyCompatible + || !member.mutable + || !hostCapabilities.canManageSources} + loading={busyKey === member.stableKey} + aria-label={t('sources.toggleLabel', { + name: [ + group.displayName, + capabilityLabel, + scopeLabel, + t(`lifecycle.${member.lifecycle}`), + ].join(' · '), + })} + onChange={(event) => void setEnabled( + member.stableKey, + event.currentTarget.checked, + )} + > + {member.lifecycle !== 'available' ? ( + + {t(`lifecycle.${member.lifecycle}`)} + + ) : null} + + ); + })} +
    +
    + {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} +
      {diagnostic.message}
      +
      +
    • + ))} +
    +
    + ) : null} +
    + ); + })} +
    + ) : null} {(snapshot?.tools?.length ?? 0) > 0 ? ( diff --git a/src/web-ui/src/infrastructure/config/externalSourcePresentation.test.ts b/src/web-ui/src/infrastructure/config/externalSourcePresentation.test.ts new file mode 100644 index 000000000..5d6178df0 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/externalSourcePresentation.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest'; +import type { + ExternalSourceCatalogSnapshot, + ExternalSourceRecord, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { buildExternalSourcePresentationGroups } from './externalSourcePresentation'; + +const integrationPolicy: ExternalSourceCatalogSnapshot['integrationPolicy'] = { + schemaMajor: 1, + status: 'compatible', + userDefaults: { enabled: true, ecosystems: {} }, + globalEffective: { enabled: true, ecosystems: {} }, + effective: { enabled: true, ecosystems: {} }, + registeredEcosystems: [], +}; + +function source( + stableKey: string, + providerId: string, + overrides: Partial = {}, + presentationGroupId = 'opencode-user-config', +): ExternalSourceCatalogSnapshot['sources'][number] { + return { + stableKey, + presentationGroupId, + lifecycle: 'available', + record: { + key: { providerId, sourceId: 'user-configuration' }, + ecosystemId: 'opencode', + displayName: 'OpenCode user configuration', + sourceKind: 'configuration', + scope: 'user_global', + location: '~\\.config\\opencode\\opencode.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, + }, + generation: 1, + discoveryPending: false, + sources: [], + commands: [], + tools: [], + mcpServers: [], + subagents: [], + integrationPolicy, + ...overrides, + }; +} + +describe('external source presentation', () => { + it('combines provider records that describe the same physical configuration', () => { + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [ + source('command-source', 'opencode.commands'), + source('agent-source', 'opencode.subagents'), + source('mcp-source', 'opencode.mcp'), + ], + commands: [{ + definition: { + id: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'smoke-command', + }, + name: 'smoke-command', + description: 'Smoke command', + availability: { state: 'available' }, + contentVersion: 'v1', + }, + }], + subagents: [{ + candidateId: 'smoke-agent', + logicalId: 'smoke-agent', + displayName: 'Smoke agent', + description: 'Smoke agent', + providerLabel: 'OpenCode', + scope: 'user_global', + sourceKeys: [{ providerId: 'opencode.subagents', sourceId: 'user-configuration' }], + sourceLocationLabels: ['~/.config/opencode/opencode.json'], + sourceCount: 1, + effectiveToolLabels: [], + supportsFollowUp: false, + compatibilityState: 'ready', + diagnostics: [], + activationState: { state: 'active' }, + decisionKey: 'smoke-agent', + }], + mcpServers: [{ + candidateId: 'smoke-mcp', + approvalKey: 'smoke-mcp', + decisionKey: 'smoke-mcp', + activationState: { state: 'active' }, + definition: { + id: { + source: { providerId: 'opencode.mcp', sourceId: 'user-configuration' }, + localId: 'smoke-mcp', + }, + provenance: [], + name: 'smoke-mcp', + transport: 'local_stdio', + argumentCount: 0, + environmentKeys: [], + headerNames: [], + sourceEnabled: true, + behaviorVersion: 'v1', + staticStatus: { state: 'ready' }, + }, + }], + })); + + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + displayName: 'OpenCode user configuration', + location: '~/.config/opencode/opencode.json', + counts: { commands: 1, tools: 0, agents: 1, mcps: 1 }, + }); + expect(groups[0].members.map((member) => member.stableKey)).toEqual([ + 'agent-source', + 'command-source', + 'mcp-source', + ]); + }); + + it('omits sources that have no supported assets or actionable diagnostics', () => { + expect(buildExternalSourcePresentationGroups(snapshot({ + sources: [source('empty-source', 'opencode.commands')], + }))).toEqual([]); + + expect(buildExternalSourcePresentationGroups(snapshot({ + sources: [source('info-only-source', 'opencode.commands', { + diagnostics: [{ severity: 'info', code: 'discovered', message: 'Discovered.' }], + })], + }))).toEqual([]); + }); + + it('does not combine matching paths from different execution domains', () => { + const diagnostic = { + severity: 'warning', + code: 'opencode.configuration.invalid', + message: 'The configuration could not be parsed.', + }; + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [ + source('local-source', 'opencode.commands', { diagnostics: [diagnostic] }, 'local-source'), + source('peer-source', 'opencode.subagents', { + executionDomainId: 'peer:device-b', + diagnostics: [diagnostic], + }, 'peer-source'), + ], + })); + + expect(groups).toHaveLength(2); + }); + + it('combines the same physical source across provider-specific scopes', () => { + const diagnostic = { + severity: 'warning', + code: 'opencode.configuration.invalid', + message: 'The configuration could not be parsed.', + }; + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [ + source('command-source', 'opencode.commands', { + scope: 'workspace_local', + diagnostics: [diagnostic], + }), + source('mcp-source', 'opencode.mcp', { + scope: 'user_global', + diagnostics: [diagnostic], + }), + ], + })); + + expect(groups).toHaveLength(1); + expect(groups[0].scopes).toEqual(['user_global', 'workspace_local']); + }); + + it('keeps broad scope disclosure even when that member has no visible assets', () => { + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [ + source('command-source', 'opencode.commands', { scope: 'workspace_local' }), + source('empty-global-source', 'opencode.mcp', { scope: 'user_global' }), + ], + commands: [{ + definition: { + id: { + source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + localId: 'smoke-command', + }, + name: 'smoke-command', + description: 'Smoke command', + availability: { state: 'available' }, + contentVersion: 'v1', + }, + }], + })); + + expect(groups).toHaveLength(1); + expect(groups[0].members).toHaveLength(1); + expect(groups[0].scopes).toEqual(['user_global', 'workspace_local']); + }); + + it('does not merge distinct raw sources that share the same redacted location', () => { + const diagnostic = { + severity: 'warning', + code: 'opencode.configuration.invalid', + message: 'The configuration could not be parsed.', + }; + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [ + source('source-a', 'opencode.commands', { diagnostics: [diagnostic] }, 'raw-source-a'), + source('source-b', 'opencode.subagents', { diagnostics: [diagnostic] }, 'raw-source-b'), + ], + })); + + expect(groups).toHaveLength(2); + }); + + it('keeps a source with only actionable diagnostics and deduplicates repeated diagnostics', () => { + const diagnostic = { + severity: 'warning', + code: 'opencode.configuration.invalid', + message: 'The configuration could not be parsed.', + }; + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [ + source('command-source', 'opencode.commands', { diagnostics: [diagnostic] }), + source('agent-source', 'opencode.subagents', { diagnostics: [diagnostic] }), + ], + })); + + expect(groups).toHaveLength(1); + expect(groups[0].diagnostics).toEqual([diagnostic]); + }); + + it('keeps a user-suppressed source visible so it can be enabled again', () => { + const suppressedSource = source('suppressed-source', 'opencode.mcp'); + suppressedSource.lifecycle = 'suppressed'; + + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [suppressedSource], + })); + + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + lifecycle: 'suppressed', + counts: { commands: 0, tools: 0, agents: 0, mcps: 0 }, + }); + expect(groups[0].members[0]).toMatchObject({ + capability: 'mcp', + enabled: false, + scope: 'user_global', + }); + }); + + it('preserves mixed member states instead of presenting a misleading group switch', () => { + const available = source('command-source', 'opencode.commands'); + const suppressed = source('agent-source', 'opencode.subagents'); + suppressed.lifecycle = 'suppressed'; + + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [available, suppressed], + commands: [{ + definition: { + id: { + source: available.record.key, + localId: 'smoke-command', + }, + name: 'smoke-command', + description: 'Smoke command', + availability: { state: 'available' }, + contentVersion: 'v1', + }, + }], + })); + + expect(groups).toHaveLength(1); + expect(groups[0].members).toEqual([ + expect.objectContaining({ stableKey: 'agent-source', capability: 'subagent', enabled: false }), + expect.objectContaining({ stableKey: 'command-source', capability: 'command', enabled: true }), + ]); + }); + + it.each([ + 'removed', + 'unavailable', + 'degraded', + 'restricted', + 'using_last_valid_version', + ] as const)('keeps an empty %s source visible because its lifecycle is actionable', (lifecycle) => { + const abnormalSource = source(`${lifecycle}-source`, 'opencode.commands'); + abnormalSource.lifecycle = lifecycle; + + const groups = buildExternalSourcePresentationGroups(snapshot({ + sources: [abnormalSource], + })); + + expect(groups).toHaveLength(1); + expect(groups[0].lifecycle).toBe(lifecycle); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/externalSourcePresentation.ts b/src/web-ui/src/infrastructure/config/externalSourcePresentation.ts new file mode 100644 index 000000000..d44a085f4 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/externalSourcePresentation.ts @@ -0,0 +1,286 @@ +import type { + ExternalSourceCatalogSnapshot, + ExternalSourceLifecycle, + ExternalSourceRecord, + ExternalSourceScope, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; + +export interface ExternalSourceCapabilityCounts { + commands: number; + tools: number; + agents: number; + mcps: number; +} + +export type ExternalSourceCapability = 'command' | 'tool' | 'subagent' | 'mcp' | 'source'; + +export interface ExternalSourcePresentationMember { + stableKey: string; + lifecycle: ExternalSourceLifecycle; + scope: ExternalSourceScope; + capability: ExternalSourceCapability; + enabled: boolean; + mutable: boolean; +} + +export interface ExternalSourcePresentationGroup { + key: string; + scopes: ExternalSourceScope[]; + displayName: string; + location: string; + lifecycle: ExternalSourceLifecycle; + members: ExternalSourcePresentationMember[]; + counts: ExternalSourceCapabilityCounts; + diagnostics: NonNullable; +} + +type ExternalSourceDiagnostic = NonNullable[number]; + +function sourcePairKey(providerId: string, sourceId: string): string { + return `${providerId}\u0000${sourceId}`; +} + +function normalizeLocation(location: string): string { + const normalized = location.trim().replace(/\\/g, '/'); + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; +} + +function presentationKey(source: ExternalSourceCatalogSnapshot['sources'][number]): string { + // Older Peer Hosts do not provide a server-issued group id. Keeping those + // entries separate is safer than coalescing distinct raw paths that happen + // to share the same redacted display location. + return source.presentationGroupId ?? source.stableKey; +} + +function commandCountsBySource( + snapshot: ExternalSourceCatalogSnapshot, +): Map { + const namesBySource = new Map>(); + const add = (providerId: string, sourceId: string, commandName: string) => { + const key = sourcePairKey(providerId, sourceId); + const names = namesBySource.get(key) ?? new Set(); + names.add(commandName.toLowerCase()); + namesBySource.set(key, names); + }; + + for (const command of snapshot.commands) { + const source = command.definition.id.source; + add(source.providerId, source.sourceId, command.definition.name); + } + for (const conflict of snapshot.commandConflicts ?? []) { + for (const candidate of conflict.candidates) { + add(candidate.source.providerId, candidate.source.sourceId, conflict.commandName); + } + } + + return new Map(Array.from(namesBySource, ([key, names]) => [key, names.size])); +} + +function increment(counts: Map, key: string): void { + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +function capabilityCountsBySource(snapshot: ExternalSourceCatalogSnapshot): { + commands: Map; + tools: Map; + agents: Map; + mcps: Map; +} { + const tools = new Map(); + for (const tool of snapshot.tools ?? []) { + const source = tool.definition.id.target.source; + increment(tools, sourcePairKey(source.providerId, source.sourceId)); + } + + const agents = new Map(); + for (const agent of snapshot.subagents ?? []) { + for (const source of agent.sourceKeys) { + increment(agents, sourcePairKey(source.providerId, source.sourceId)); + } + } + + const mcps = new Map(); + for (const server of snapshot.mcpServers ?? []) { + const source = server.definition.id.source; + increment(mcps, sourcePairKey(source.providerId, source.sourceId)); + } + + return { + commands: commandCountsBySource(snapshot), + tools, + agents, + mcps, + }; +} + +function countsForSource( + counts: ReturnType, + source: ExternalSourceCatalogSnapshot['sources'][number], +): ExternalSourceCapabilityCounts { + const pair = sourcePairKey(source.record.key.providerId, source.record.key.sourceId); + return { + commands: counts.commands.get(pair) ?? 0, + tools: counts.tools.get(pair) ?? 0, + agents: counts.agents.get(pair) ?? 0, + mcps: counts.mcps.get(pair) ?? 0, + }; +} + +function sourceCapability( + source: ExternalSourceCatalogSnapshot['sources'][number], + counts: ExternalSourceCapabilityCounts, +): ExternalSourceCapability { + const populated = (Object.entries(counts) as Array<[keyof ExternalSourceCapabilityCounts, number]>) + .filter(([, count]) => count > 0) + .map(([capability]) => capability); + if (populated.length === 1) { + return { + commands: 'command', + tools: 'tool', + agents: 'subagent', + mcps: 'mcp', + }[populated[0]] as ExternalSourceCapability; + } + + const identity = `${source.record.key.providerId} ${source.record.sourceKind}`.toLowerCase(); + if (identity.includes('mcp')) return 'mcp'; + if (identity.includes('subagent') || identity.includes('agent')) return 'subagent'; + if (identity.includes('tool')) return 'tool'; + if (identity.includes('command') || identity.includes('prompt')) return 'command'; + return 'source'; +} + +function combinedLifecycle(members: ExternalSourcePresentationMember[]): ExternalSourceLifecycle { + const lifecycles = new Set(members.map((member) => member.lifecycle)); + if (lifecycles.size === 1) { + return members[0]?.lifecycle ?? 'unavailable'; + } + if (lifecycles.has('unavailable')) return 'unavailable'; + if (lifecycles.has('degraded') || lifecycles.has('suppressed') || lifecycles.has('removed')) { + return 'degraded'; + } + if (lifecycles.has('restricted')) return 'restricted'; + if (lifecycles.has('using_last_valid_version')) return 'using_last_valid_version'; + return 'available'; +} + +export function externalSourceDiagnosticKey(diagnostic: ExternalSourceDiagnostic): string { + return JSON.stringify([ + diagnostic.severity.toLowerCase(), + diagnostic.assetKind ?? '', + diagnostic.code, + diagnostic.message, + ]); +} + +function deduplicateDiagnostics( + sources: ExternalSourceCatalogSnapshot['sources'], +): NonNullable { + const diagnostics = new Map(); + for (const source of sources) { + for (const diagnostic of source.record.diagnostics ?? []) { + if (diagnostic.severity.toLowerCase() === 'info') continue; + diagnostics.set(externalSourceDiagnosticKey(diagnostic), diagnostic); + } + } + return Array.from(diagnostics.values()); +} + +export function catalogDiagnosticsWithoutSourceDuplicates( + snapshot: ExternalSourceCatalogSnapshot, + groups: ExternalSourcePresentationGroup[], +): NonNullable { + const sourceDiagnosticKeys = new Set(groups.flatMap((group) => ( + group.diagnostics.map(externalSourceDiagnosticKey) + ))); + const diagnostics = new Map(); + for (const diagnostic of snapshot.diagnostics ?? []) { + const key = externalSourceDiagnosticKey(diagnostic); + if (!sourceDiagnosticKeys.has(key)) diagnostics.set(key, diagnostic); + } + return Array.from(diagnostics.values()); +} + +const SCOPE_ORDER: ExternalSourceScope[] = [ + 'user_global', + 'remote_user', + 'project', + 'remote_project', + 'workspace_local', +]; + +export function buildExternalSourcePresentationGroups( + snapshot: ExternalSourceCatalogSnapshot, +): ExternalSourcePresentationGroup[] { + const countsBySource = capabilityCountsBySource(snapshot); + const groupedSources = new Map(); + + for (const source of snapshot.sources) { + const key = presentationKey(source); + const sources = groupedSources.get(key) ?? []; + sources.push(source); + groupedSources.set(key, sources); + } + + const groups: ExternalSourcePresentationGroup[] = []; + for (const [key, sources] of groupedSources) { + const representative = sources[0]; + if (!representative) continue; + + const sourceCounts = new Map(sources.map((source) => [ + source.stableKey, + countsForSource(countsBySource, source), + ])); + const counts = Array.from(sourceCounts.values()).reduce( + (total, current) => ({ + commands: total.commands + current.commands, + tools: total.tools + current.tools, + agents: total.agents + current.agents, + mcps: total.mcps + current.mcps, + }), + { commands: 0, tools: 0, agents: 0, mcps: 0 }, + ); + const diagnostics = deduplicateDiagnostics(sources); + const totalAssets = Object.values(counts).reduce((total, count) => total + count, 0); + const hasActionableLifecycle = sources.some((source) => source.lifecycle !== 'available'); + if (totalAssets === 0 && diagnostics.length === 0 && !hasActionableLifecycle) continue; + + const members = sources + .filter((source) => { + const memberCounts = sourceCounts.get(source.stableKey); + const assetCount = memberCounts + ? Object.values(memberCounts).reduce((total, count) => total + count, 0) + : 0; + const hasDiagnostic = (source.record.diagnostics ?? []) + .some((diagnostic) => diagnostic.severity.toLowerCase() !== 'info'); + return assetCount > 0 || hasDiagnostic || source.lifecycle !== 'available'; + }) + .map((source) => ({ + stableKey: source.stableKey, + lifecycle: source.lifecycle, + scope: source.record.scope, + capability: sourceCapability( + source, + sourceCounts.get(source.stableKey) ?? { commands: 0, tools: 0, agents: 0, mcps: 0 }, + ), + enabled: source.lifecycle !== 'suppressed' && source.lifecycle !== 'removed', + mutable: source.lifecycle !== 'removed', + })) + .sort((left, right) => left.stableKey.localeCompare(right.stableKey)); + const scopes = Array.from(new Set(sources.map((source) => source.record.scope))) + .sort((left, right) => SCOPE_ORDER.indexOf(left) - SCOPE_ORDER.indexOf(right)); + + groups.push({ + key, + scopes, + displayName: representative.record.displayName, + location: normalizeLocation(representative.record.location), + lifecycle: combinedLifecycle(members), + members, + counts, + diagnostics, + }); + } + + return groups; +}