From c3ce048ca99638dd9eed32e7f8c3e58e80388108 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 27 Oct 2026 23:35:02 +0800 Subject: [PATCH] fix(session-import): show discovery errors as notifications --- .../src/agentic/coordination/scheduler.rs | 1 + .../external_session_import/auto_discovery.rs | 199 ++++++++++++++---- .../assembly/core/src/product_runtime.rs | 1 + .../core/src/service_agent_runtime.rs | 1 - .../services/services-core/src/session/mod.rs | 5 +- .../services-core/src/session/page.rs | 37 ++++ .../sections/sessions/SessionsSection.tsx | 69 +++++- .../sessions/SessionsSectionLayout.test.ts | 7 + .../useWorkspaceSessionDiscovery.test.tsx | 17 ++ .../sessions/useWorkspaceSessionDiscovery.ts | 30 ++- .../api/service-api/SessionAPI.ts | 7 + src/web-ui/src/locales/en-US/common.json | 3 + .../locales/en-US/scenes/capabilities.json | 3 +- src/web-ui/src/locales/zh-CN/common.json | 3 + .../locales/zh-CN/scenes/capabilities.json | 3 +- src/web-ui/src/locales/zh-TW/common.json | 3 + .../locales/zh-TW/scenes/capabilities.json | 3 +- 17 files changed, 338 insertions(+), 54 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 85cac4168b..68d910d9cd 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -3921,6 +3921,7 @@ mod tests { tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), model_id: Some("model-original".to_string()), + enable_tools: false, ..Default::default() }, ) diff --git a/src/crates/assembly/core/src/external_session_import/auto_discovery.rs b/src/crates/assembly/core/src/external_session_import/auto_discovery.rs index e481528a8e..3e49de371c 100644 --- a/src/crates/assembly/core/src/external_session_import/auto_discovery.rs +++ b/src/crates/assembly/core/src/external_session_import/auto_discovery.rs @@ -4,6 +4,9 @@ use super::*; use crate::service::workspace::{WorkspaceInfo, WorkspaceKind}; use openbitfun_product_domains::session_import::ExternalSessionCatalog; +#[cfg(test)] +use openbitfun_product_domains::session_import::ExternalSessionImportWarning; +use openbitfun_services_core::session::SessionDiscoveryDiagnostic; #[derive(Default)] struct DiscoveryState { @@ -18,6 +21,13 @@ struct DiscoveryState { pub struct DiscoverySnapshot { pub pending: bool, pub warnings: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Default)] +struct DiscoveryResult { + warnings: Vec, + diagnostics: Vec, } type DiscoveryLocks = HashMap>>; @@ -57,6 +67,7 @@ pub async fn discover_workspace(workspace: &WorkspaceInfo, refresh: bool) -> Dis return DiscoverySnapshot { pending: false, warnings: vec!["External session discovery preferences could not be read".into()], + diagnostics: Vec::new(), }; } }; @@ -69,6 +80,7 @@ pub async fn discover_workspace(workspace: &WorkspaceInfo, refresh: bool) -> Dis warnings: vec![ "Automatic external session discovery is unavailable for SSH workspaces".into(), ], + diagnostics: Vec::new(), } }; } @@ -98,7 +110,7 @@ async fn schedule_discovery( ) -> DiscoverySnapshot where F: FnOnce(Arc) -> Fut + Send + 'static, - Fut: std::future::Future> + Send + 'static, + Fut: std::future::Future + Send + 'static, { let lock = DISCOVERY .lock() @@ -128,6 +140,7 @@ where state.snapshot = DiscoverySnapshot { pending: true, warnings: Vec::new(), + diagnostics: Vec::new(), }; let worker = state.worker.clone(); let completion_state = lock.clone(); @@ -138,12 +151,13 @@ where if cancelled.load(Ordering::Acquire) { return; } - let warnings = work(cancelled.clone()).await; + let result = work(cancelled.clone()).await; let mut state = completion_state.lock().await; if Arc::ptr_eq(&state.cancelled, &cancelled) && !cancelled.load(Ordering::Acquire) { state.snapshot = DiscoverySnapshot { pending: false, - warnings, + warnings: result.warnings, + diagnostics: result.diagnostics, }; } }); @@ -165,9 +179,18 @@ pub async fn discover_for_storage_path(storage_path: &Path) -> DiscoverySnapshot let discovery = discover_workspace(workspace, false).await; snapshot.pending |= discovery.pending; snapshot.warnings.extend(discovery.warnings); + snapshot.diagnostics.extend(discovery.diagnostics); } snapshot.warnings.sort(); snapshot.warnings.dedup(); + snapshot.diagnostics.sort_by(|left, right| { + left.ecosystem_id + .cmp(&right.ecosystem_id) + .then_with(|| left.code.cmp(&right.code)) + }); + snapshot + .diagnostics + .dedup_by(|left, right| left.ecosystem_id == right.ecosystem_id && left.code == right.code); snapshot } @@ -205,27 +228,46 @@ async fn discover_enabled( enabled: &[String], cancelled: Arc, requested_at: Instant, -) -> Vec { +) -> DiscoveryResult { let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() else { - return vec!["Runtime ownership is unavailable for external session discovery".into()]; + return DiscoveryResult { + warnings: vec![ + "Runtime ownership is unavailable for external session discovery".into(), + ], + ..DiscoveryResult::default() + }; }; if let Err(error) = coordinator.ensure_workspace_runtime_ownership(&workspace.root_path, None, None) { log::warn!("External session discovery could not acquire workspace ownership: {error}"); - return vec![ - "Another runtime owns this workspace; automatic session import is unavailable".into(), - ]; + return DiscoveryResult { + warnings: vec![ + "Another runtime owns this workspace; automatic session import is unavailable" + .into(), + ], + ..DiscoveryResult::default() + }; } let Some(service) = crate::service::workspace::get_global_workspace_service() else { - return vec!["Workspace service is unavailable for external session discovery".into()]; + return DiscoveryResult { + warnings: vec![ + "Workspace service is unavailable for external session discovery".into(), + ], + ..DiscoveryResult::default() + }; }; let workspaces = service.list_workspace_infos().await; let (store, project) = match import_storage(workspace).await { Ok(storage) => storage, - Err(error) => return vec![error.detail], + Err(error) => { + return DiscoveryResult { + warnings: vec![error.detail], + ..DiscoveryResult::default() + } + } }; - let mut warnings = Vec::new(); + let mut result = DiscoveryResult::default(); for ecosystem in enabled { if cancelled.load(Ordering::Acquire) { break; @@ -240,28 +282,28 @@ async fn discover_enabled( .await; match catalog { Ok(catalog) => { - warnings.extend( - register_catalog( - &store, - workspace, - &project, - &workspaces, - &catalog, - cancelled.as_ref(), - ) - .await, - ); + let registration = register_catalog( + &store, + workspace, + &project, + &workspaces, + &catalog, + cancelled.as_ref(), + ) + .await; + result.warnings.extend(registration.warnings); + result.diagnostics.extend(registration.diagnostics); } error => { log::warn!("External session discovery failed for {ecosystem}: {error:?}"); - warnings.push(format!( + result.warnings.push(format!( "{} session history could not be discovered", source.name() )); } } } - warnings + result } #[derive(Debug)] @@ -395,25 +437,55 @@ async fn register_catalog( workspaces: &[WorkspaceInfo], catalog: &SourceCatalog, cancelled: &AtomicBool, -) -> Vec { +) -> DiscoveryResult { if cancelled.load(Ordering::Acquire) { - return Vec::new(); + return DiscoveryResult::default(); } - let mut warnings = Vec::new(); - if !catalog.catalog.diagnostics.is_empty() { - warnings.push(format!( + let mut result = DiscoveryResult::default(); + let has_workspace_unresolved = catalog + .catalog + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "workspace_unresolved"); + let has_read_failure = catalog + .catalog + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code != "workspace_unresolved"); + if has_read_failure { + result.warnings.push(format!( "Some {} source records could not be read", catalog.catalog.ecosystem_id )); } + if has_workspace_unresolved { + result.warnings.push(format!( + "Some {} sessions could not be matched to a workspace", + catalog.catalog.ecosystem_id + )); + } + result + .diagnostics + .extend( + catalog + .catalog + .diagnostics + .iter() + .map(|diagnostic| SessionDiscoveryDiagnostic { + ecosystem_id: catalog.catalog.ecosystem_id.clone(), + code: diagnostic.code.clone(), + }), + ); let candidates = catalog.workspace_candidates(workspace, workspaces, normalized_path); if let Err(error) = super::deferred::register(store.sessions_root(), workspace, project, candidates).await { log::warn!("External session index could not be updated: {error}"); - warnings.push("External session discovery index could not be updated".into()); + result + .warnings + .push("External session discovery index could not be updated".into()); } - warnings + result } pub(super) fn belongs_to_workspace( @@ -464,7 +536,10 @@ pub(super) mod tests { None, |_| async move { wait.await.unwrap(); - vec!["fixture warning".into()] + DiscoveryResult { + warnings: vec!["fixture warning".into()], + ..DiscoveryResult::default() + } }, ) .await; @@ -510,7 +585,10 @@ pub(super) mod tests { tokio::task::yield_now().await; } stopped.send(()).unwrap(); - vec!["must not become visible".into()] + DiscoveryResult { + warnings: vec!["must not become visible".into()], + ..DiscoveryResult::default() + } }, ) .await; @@ -576,6 +654,52 @@ pub(super) mod tests { } } + #[tokio::test] + async fn catalog_diagnostics_keep_read_and_workspace_failures_separate() { + let temp = tempfile::tempdir().unwrap(); + let workspace = workspace("project", temp.path(), WorkspaceKind::Normal); + let mut source_catalog = catalog("cursor", vec![]); + source_catalog.diagnostics = vec![ + ExternalSessionImportWarning { + code: "source_unreadable".into(), + detail: "fixture source could not be read".into(), + }, + ExternalSessionImportWarning { + code: "workspace_unresolved".into(), + detail: "fixture source has no workspace".into(), + }, + ]; + + let result = register_catalog( + &OfflineSessionImportStore::new(temp.path().join("native")), + &workspace, + &workspace, + std::slice::from_ref(&workspace), + &SourceCatalog::new(source_catalog), + &AtomicBool::new(false), + ) + .await; + + assert_eq!( + result.warnings, + vec![ + "Some cursor source records could not be read", + "Some cursor sessions could not be matched to a workspace", + ] + ); + assert_eq!( + result + .diagnostics + .iter() + .map(|diagnostic| (diagnostic.ecosystem_id.as_str(), diagnostic.code.as_str())) + .collect::>(), + vec![ + ("cursor", "source_unreadable"), + ("cursor", "workspace_unresolved"), + ] + ); + } + #[tokio::test] async fn centralized_metadata_storage_discovers_all_codex_history_beyond_existing_imports() { let temp = tempfile::tempdir().unwrap(); @@ -646,7 +770,10 @@ pub(super) mod tests { &AtomicBool::new(false), ) .await; - assert!(warnings.is_empty(), "{warnings:?}"); + assert!( + warnings.warnings.is_empty() && warnings.diagnostics.is_empty(), + "{warnings:?}" + ); let native = store.list_metadata_including_internal().await.unwrap(); assert_eq!( native.len(), @@ -710,7 +837,7 @@ pub(super) mod tests { vec!["codex".into()], false, Some(activation), - |_| async { Vec::new() } + |_| async { DiscoveryResult::default() } ) .await .pending @@ -772,7 +899,7 @@ pub(super) mod tests { &AtomicBool::new(true), ) .await; - assert!(warnings.is_empty()); + assert!(warnings.warnings.is_empty() && warnings.diagnostics.is_empty()); assert!(!root.exists()); } diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 611e176fd2..a160280736 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -1305,6 +1305,7 @@ impl CoreAgentRuntimeCompatibility { #[cfg(feature = "external-sources")] { page.discovery_warnings.extend(warnings.warnings); + page.discovery_diagnostics.extend(warnings.diagnostics); page.discovery_pending = warnings.pending; } Ok(page) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index d1c35bea0b..b0e56f04b7 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -4427,7 +4427,6 @@ mod tests { ..Default::default() }, ); - // Persisted Smart intent must not block explicit model selection while // routing is unavailable, or be rewritten by this read-only guard. for mode in [None, Some("manual"), Some("smart")] { diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index c752b0af6d..059cd50b58 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -38,6 +38,9 @@ pub use offline_import::{ OfflineSessionBundle, OfflineSessionImportError, OfflineSessionImportStore, }; pub use openbitfun_core_types::{SessionKind, SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; -pub use page::{build_session_metadata_page, empty_session_metadata_page, SessionMetadataPage}; +pub use page::{ + build_session_metadata_page, empty_session_metadata_page, SessionDiscoveryDiagnostic, + SessionMetadataPage, +}; pub use types::*; pub use write_lock::{SessionWriteLock, SessionWriteLockError}; diff --git a/src/crates/services/services-core/src/session/page.rs b/src/crates/services/services-core/src/session/page.rs index 682d028103..f62d50f9be 100644 --- a/src/crates/services/services-core/src/session/page.rs +++ b/src/crates/services/services-core/src/session/page.rs @@ -25,6 +25,15 @@ pub enum SessionCatalogOrdering { Updated, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionDiscoveryDiagnostic { + /// Ecosystem adapter that produced the diagnostic (for example, `cursor`). + pub ecosystem_id: String, + /// Stable adapter-owned code. Presentation layers localize this code. + pub code: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionMetadataPage { @@ -41,6 +50,11 @@ pub struct SessionMetadataPage { /// Non-fatal host discovery issues. Native history remains available. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub discovery_warnings: Vec, + /// Structured discovery issues for clients that can localize and present + /// each category separately. Older clients continue to use + /// `discovery_warnings`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub discovery_diagnostics: Vec, #[serde(default, skip_serializing_if = "is_false")] pub discovery_pending: bool, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +100,7 @@ pub fn empty_session_metadata_page() -> SessionMetadataPage { has_more: false, activities: None, discovery_warnings: Vec::new(), + discovery_diagnostics: Vec::new(), discovery_pending: false, catalog_query: None, catalog_revision: None, @@ -271,6 +286,7 @@ impl PreparedSessionCatalog { has_more, activities: None, discovery_warnings: Vec::new(), + discovery_diagnostics: Vec::new(), discovery_pending: false, catalog_query: query.cloned(), catalog_revision: self.revision.clone(), @@ -457,9 +473,30 @@ mod tests { let legacy = serde_json::json!({"sessions": [], "totalTopLevelCount": 0, "loadedTopLevelCount": 0, "hasMore": false}); let page: SessionMetadataPage = serde_json::from_value(legacy.clone()).unwrap(); assert!(page.discovery_warnings.is_empty()); + assert!(page.discovery_diagnostics.is_empty()); assert_eq!(serde_json::to_value(page).unwrap(), legacy); } + #[test] + fn structured_discovery_diagnostics_round_trip_with_legacy_warnings() { + let mut page = empty_session_metadata_page(); + page.discovery_warnings.push("legacy warning".into()); + page.discovery_diagnostics.push(SessionDiscoveryDiagnostic { + ecosystem_id: "cursor".into(), + code: "workspace_unresolved".into(), + }); + + let wire = serde_json::to_value(&page).unwrap(); + assert_eq!( + wire["discoveryDiagnostics"][0], + serde_json::json!({"ecosystemId": "cursor", "code": "workspace_unresolved"}) + ); + let restored: SessionMetadataPage = serde_json::from_value(wire).unwrap(); + assert_eq!(restored.discovery_warnings, vec!["legacy warning"]); + assert_eq!(restored.discovery_diagnostics.len(), 1); + assert_eq!(restored.discovery_diagnostics[0].ecosystem_id, "cursor"); + } + fn entry(id: &str, workspace: &str, created: u64) -> SessionMetadata { let mut metadata = SessionMetadata::new(id.into(), id.into(), "agent".into(), "model".into()); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index a3dfce664f..0c16d61cbb 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -12,7 +12,7 @@ import { canCopySessionsOnSurface } from '@/infrastructure/peer-device/peerCapab */ import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { subscribeOverlayInteraction, createOverlayPortal, Alert, Button, Icon, IconButton, Input, Menu, MenuItem, OverflowText, StatusPill, Tooltip } from '@openbitfun/ui'; +import { subscribeOverlayInteraction, createOverlayPortal, Button, Icon, IconButton, Input, Menu, MenuItem, OverflowText, StatusPill, Tooltip } from '@openbitfun/ui'; import { Loader2, Archive, FolderGit2, FolderOpen, ListChecks } from 'lucide-react'; import { RetainedMountBoundary } from '@/shared/presence'; import { useI18n } from '@/infrastructure/i18n'; @@ -59,6 +59,7 @@ import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; import { useSessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; import { getActiveSurfaceId, getActiveSurfaceScope, isSurfaceChangedError, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { EXTERNAL_SESSION_NAMES, isExternalSessionEcosystem } from '@/shared/types/external-session'; import { getWorkspaceSessionHierarchy } from './sessionNavHierarchy'; import { useWorkspaceSessionCatalog } from './useWorkspaceSessionCatalog'; import { captureWorkspaceSessionCatalogQuery, countPersistedWorkspaceCatalogSessions, workspaceSessionCatalogQuery, workspaceSessionCatalogTotal } from './workspaceSessionCatalogQuery'; @@ -118,6 +119,7 @@ import { createSessionsNavSelector } from './sessionsNavSelector'; import './SessionsSection.scss'; const log = createLogger('SessionsSection'); +const reportedDiscoveryNotificationKeys = new Set(); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); const WorkspaceSessionBatchModal = lazyWithRecovery(() => import('../workspaces/WorkspaceSessionBatchModal')); @@ -298,6 +300,61 @@ const SessionsSection: React.FC = ({ onDiscoveryCompleted, ); const observeDiscovery = discovery.observe; + useEffect(() => { + // The same host scan is observed by the active workspace row and, in some + // layouts, by an aggregate or assistant-history row. Show one toast per + // diagnostic set instead of reserving space in every session list. + if (!isVisible || !isActiveWorkspace || !workspaceId || discovery.pending) return; + + const sources = new Set(discovery.diagnostics.map(diagnostic => diagnostic.ecosystemId)); + const messages = new Map(); + for (const diagnostic of discovery.diagnostics) { + const source = isExternalSessionEcosystem(diagnostic.ecosystemId) + ? EXTERNAL_SESSION_NAMES[diagnostic.ecosystemId] + : diagnostic.ecosystemId; + const kind = diagnostic.code === 'workspace_unresolved' ? 'workspace' : 'read'; + const key = `diagnostic:${diagnostic.ecosystemId}:${kind}`; + messages.set( + key, + t( + kind === 'workspace' + ? 'nav.sessions.discoveryWorkspaceUnresolved' + : 'nav.sessions.discoveryReadFailed', + { source }, + ), + ); + } + + // Keep warnings from older hosts and non-catalog failures visible. Newer + // hosts also send structured diagnostics, so suppress only the legacy + // catalog summaries that those diagnostics replace. + for (const warning of discovery.warnings) { + const derived = [...sources].some(source => ( + warning === `Some ${source} source records could not be read` + || warning === `Some ${source} sessions could not be matched to a workspace` + )); + if (!derived) messages.set(`warning:${warning}`, warning); + } + + const notificationScope = `${getActiveSurfaceId()}:${workspaceId}`; + const notificationKeys = new Set( + [...messages.keys()].map(key => `${notificationScope}:${key}`), + ); + for (const key of reportedDiscoveryNotificationKeys) { + if (key.startsWith(`${notificationScope}:`) && !notificationKeys.has(key)) { + reportedDiscoveryNotificationKeys.delete(key); + } + } + for (const [key, message] of messages) { + const notificationKey = `${notificationScope}:${key}`; + if (reportedDiscoveryNotificationKeys.has(notificationKey)) continue; + reportedDiscoveryNotificationKeys.add(notificationKey); + notificationService.warning(message, { + title: t('nav.sessions.discoveryWarningTitle'), + duration: 7000, + }); + } + }, [discovery.diagnostics, discovery.pending, discovery.warnings, isActiveWorkspace, isVisible, t, workspaceId]); const [openMenuSessionId, setOpenMenuSessionId] = useState(null); /** Second level of the session menu: pick what a Markdown export includes. */ const [isExportScopeMenu, setIsExportScopeMenu] = useState(false); @@ -1280,9 +1337,6 @@ const SessionsSection: React.FC = ({ {t('nav.sessions.partialLoadFailedRetry')} ) : null; - const discoveryNotice = discovery.warnings.length > 0 ? ( - - ) : null; const aggregateLoadStatus = showAllWithoutLimit ? aggregateLoadState.isLoading ? ( @@ -1316,7 +1370,7 @@ const SessionsSection: React.FC = ({ : null; if (allTopLevelSessions.length === 0) { - if (sourceFilterStatus) return
{sourceFilterStatus}{discoveryNotice}
; + if (sourceFilterStatus) return
{sourceFilterStatus}
; if (aggregateLoadStatus) { return (
@@ -1352,7 +1406,6 @@ const SessionsSection: React.FC = ({ } return (
- {discoveryNotice} {presentation?.kind === 'assistant' ? (
= ({ } if (topLevelSessions.length === 0) { - if (sourceFilterStatus) return
{sourceFilterStatus}{discoveryNotice}
; + if (sourceFilterStatus) return
{sourceFilterStatus}
; if (aggregateLoadState.isLoading && aggregateLoadStatus) { return (
@@ -1387,7 +1440,6 @@ const SessionsSection: React.FC = ({ } return (
- {discoveryNotice}
{t('nav.sessions.viewMenu.noMatches')}
@@ -1399,7 +1451,6 @@ const SessionsSection: React.FC = ({ return (
{sourceFilterStatus} - {discoveryNotice} {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const relationship = resolveSessionRelationship(session); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts index 8ca6c44d7c..9a59349285 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts @@ -98,6 +98,13 @@ describe('SessionsSection layout styles', () => { expect(sessionTooltip).not.toContain('followCursor'); }); + it('presents discovery failures as transient notifications instead of a persistent list alert', () => { + const source = readSessionsSectionSource(); + expect(source).toContain('notificationService.warning'); + expect(source).not.toContain(' { const stylesheet = readSessionsSectionStylesheet(); const emptyBlock = extractBlock(stylesheet, '&__inline-empty'); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx index dd5c2b5e4a..61dce3a031 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx @@ -172,4 +172,21 @@ describe('workspace discovery observation', () => { expect(current.failed).toBe(false); expect(current.pending).toBe(false); }); + + it('exposes distinct structured diagnostics and deduplicates repeated codes', async () => { + await render(); + await act(async () => current.observe('workspace', { + ...page(false), + discoveryDiagnostics: [ + { ecosystemId: 'cursor', code: 'workspace_unresolved' }, + { ecosystemId: 'cursor', code: 'workspace_unresolved' }, + { ecosystemId: 'cursor', code: 'source_unreadable' }, + ], + })); + + expect(current.diagnostics).toEqual([ + { ecosystemId: 'cursor', code: 'source_unreadable' }, + { ecosystemId: 'cursor', code: 'workspace_unresolved' }, + ]); + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts index bd1bdae2e3..e445f29377 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts @@ -1,12 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import type { SessionDiscoveryDiagnostic, SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; import { getActiveSurfaceId, getActiveSurfaceScope, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('WorkspaceSessionDiscovery'); -interface DiscoveryState { pending: boolean; warnings: string[] } +interface DiscoveryState { + pending: boolean; + warnings: string[]; + diagnostics: SessionDiscoveryDiagnostic[]; +} /** Watches only host-advertised in-progress work. Imports remain on the host * when this observer is hidden, unmounted, disconnected or switched to a peer. */ @@ -25,9 +29,15 @@ export function useWorkspaceSessionDiscovery( const surfaceId = getActiveSurfaceId(); const observe = useCallback((workspaceId: string, page: SessionMetadataPage, refresh = false) => { const key = surfaceScopedKey(surfaceId, workspaceId); - const next = { pending: page.discoveryPending === true, warnings: page.discoveryWarnings ?? [] }; + const next = { + pending: page.discoveryPending === true, + warnings: page.discoveryWarnings ?? [], + diagnostics: page.discoveryDiagnostics ?? [], + }; const previous = statesRef.current[key]; - if (previous?.pending !== next.pending || JSON.stringify(previous.warnings) !== JSON.stringify(next.warnings)) { + if (previous?.pending !== next.pending + || JSON.stringify(previous.warnings) !== JSON.stringify(next.warnings) + || JSON.stringify(previous.diagnostics) !== JSON.stringify(next.diagnostics)) { statesRef.current = { ...statesRef.current, [key]: next }; setStates(statesRef.current); } @@ -38,6 +48,17 @@ export function useWorkspaceSessionDiscovery( }, [onCompleted, surfaceId]); const pendingKey = JSON.stringify(workspaceIds.filter(id => states[surfaceScopedKey(surfaceId, id)]?.pending)); const warnings = useMemo(() => [...new Set(scopedWorkspaceIds.flatMap(id => states[surfaceScopedKey(surfaceId, id)]?.warnings ?? []))], [states, scopedWorkspaceIds, surfaceId]); + const diagnostics = useMemo(() => { + const unique = new Map(); + for (const id of scopedWorkspaceIds) { + for (const diagnostic of states[surfaceScopedKey(surfaceId, id)]?.diagnostics ?? []) { + unique.set(`${diagnostic.ecosystemId}:${diagnostic.code}`, diagnostic); + } + } + return [...unique.values()].sort((left, right) => ( + left.ecosystemId.localeCompare(right.ecosystemId) || left.code.localeCompare(right.code) + )); + }, [scopedWorkspaceIds, states, surfaceId]); useEffect(() => configManager.onConfigChange(path => { if (path === 'app' || path === 'app.external_session_discovery' || path.startsWith('app.external_session_discovery.')) { @@ -93,6 +114,7 @@ export function useWorkspaceSessionDiscovery( observe, pending: pendingKey !== '[]', warnings, + diagnostics, failed, retry: () => setRetryVersion(value => value + 1), }; diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index 740ef583c0..cd4d810621 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -52,6 +52,8 @@ export interface SessionMetadataPage { /** Absent on older hosts; never interpret absence as an empty snapshot. */ activities?: SessionActivitySummary[]; discoveryWarnings?: string[]; + /** Structured host diagnostics for clients that can localize each issue. */ + discoveryDiagnostics?: SessionDiscoveryDiagnostic[]; discoveryPending?: boolean; /** Present only when the host applied this complete query before paging. */ catalogQuery?: SessionCatalogQuery; @@ -60,6 +62,11 @@ export interface SessionMetadataPage { catalogReset?: boolean; } +export interface SessionDiscoveryDiagnostic { + ecosystemId: string; + code: string; +} + export interface SessionLineageRequest { sessionId: string; workspaceId: string; diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index e6d258ce99..f51776337d 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -493,6 +493,9 @@ "allSourceAgents": "All agents", "sourceAgentLabel": "Imported from {{name}}", "discoveryPartial": "Some external sessions could not be discovered. Existing sessions remain available.", + "discoveryWarningTitle": "External session discovery", + "discoveryReadFailed": "Some {{source}} session records could not be read. Existing sessions remain available.", + "discoveryWorkspaceUnresolved": "Some {{source}} sessions could not be matched to a workspace. Existing sessions remain available.", "discoveringExternal": "Discovering external sessions…" }, "scheduledJobs": { diff --git a/src/web-ui/src/locales/en-US/scenes/capabilities.json b/src/web-ui/src/locales/en-US/scenes/capabilities.json index aa0bcd4268..c6d08e3682 100644 --- a/src/web-ui/src/locales/en-US/scenes/capabilities.json +++ b/src/web-ui/src/locales/en-US/scenes/capabilities.json @@ -471,7 +471,8 @@ "advanced-request": "Add custom headers, request-body fields, and SSL behavior per provider", "smart-routing": "View smart-mode availability (not available yet)", "connection-test": "Test model connections or image generation and refresh the runtime client", - "model-pools": "Manage model pools, routing strategies, and session model selection" + "model-pools": "Manage model pools, routing strategies, and session model selection", + "smart-routing": "View smart-mode availability (not released)" } }, "memory": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 40edda8334..111c7d5b62 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -493,6 +493,9 @@ "allSourceAgents": "全部 Agent", "sourceAgentLabel": "来自 {{name}} 的会话", "discoveryPartial": "部分外部会话无法发现,已有会话仍可使用。", + "discoveryWarningTitle": "外部会话发现", + "discoveryReadFailed": "部分 {{source}} 会话记录无法读取,已有会话仍可使用。", + "discoveryWorkspaceUnresolved": "部分 {{source}} 会话无法匹配到工作区,已有会话仍可使用。", "discoveringExternal": "正在发现外部会话…" }, "scheduledJobs": { diff --git a/src/web-ui/src/locales/zh-CN/scenes/capabilities.json b/src/web-ui/src/locales/zh-CN/scenes/capabilities.json index 8d05cc0b19..3f8eb52892 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/capabilities.json +++ b/src/web-ui/src/locales/zh-CN/scenes/capabilities.json @@ -471,7 +471,8 @@ "advanced-request": "为供应商添加自定义 Header、请求体字段与 SSL 行为", "smart-routing": "查看智能模式可用状态(暂未开放)", "connection-test": "测试模型连接或图像生成,并刷新运行时客户端", - "model-pools": "管理模型池、路由策略与会话模型选择" + "model-pools": "管理模型池、路由策略与会话模型选择", + "smart-routing": "查看智能模式可用状态(暂未开放)" } }, "memory": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index d5ccd2eaf9..6a721ea6c6 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -493,6 +493,9 @@ "allSourceAgents": "全部 Agent", "sourceAgentLabel": "來自 {{name}} 的工作階段", "discoveryPartial": "部分外部工作階段無法探索,既有工作階段仍可使用。", + "discoveryWarningTitle": "外部工作階段探索", + "discoveryReadFailed": "部分 {{source}} 工作階段記錄無法讀取,既有工作階段仍可使用。", + "discoveryWorkspaceUnresolved": "部分 {{source}} 工作階段無法對應到工作區,既有工作階段仍可使用。", "discoveringExternal": "正在探索外部工作階段…" }, "scheduledJobs": { diff --git a/src/web-ui/src/locales/zh-TW/scenes/capabilities.json b/src/web-ui/src/locales/zh-TW/scenes/capabilities.json index 9abc85ac72..29ee4361c3 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/capabilities.json +++ b/src/web-ui/src/locales/zh-TW/scenes/capabilities.json @@ -471,7 +471,8 @@ "advanced-request": "為供應商添加自定義 Header、請求體字段與 SSL 行為", "smart-routing": "查看智慧模式可用狀態(暫未開放)", "connection-test": "測試模型連線或圖像生成,並重新整理執行階段用戶端", - "model-pools": "管理模型池、路由策略與工作階段模型選擇" + "model-pools": "管理模型池、路由策略與工作階段模型選擇", + "smart-routing": "查看智能模式可用狀態(尚未開放)" } }, "memory": {