From 6aceceb57467c2af3aa267c651bef77afbeb7c58 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 10 Sep 2026 10:27:20 +0800 Subject: [PATCH 1/2] fix(mobile): sync opened workspaces and assistant identity names --- .../core/src/service_agent_runtime.rs | 105 ++++++++++++++++++ .../src/remote_workspace_ports.rs | 5 + .../src/remote_connect.rs | 90 ++++++++++++--- src/mobile-web/AGENTS.md | 6 + src/mobile-web/src/i18n/messages.ts | 3 + src/mobile-web/src/pages/SessionListPage.tsx | 102 +++++++---------- src/mobile-web/src/services/RemoteCache.ts | 10 +- .../src/services/RemoteSessionManager.ts | 16 +++ .../src/services/workspaceIdentity.ts | 32 +++++- src/mobile-web/tests/account-login.test.mjs | 9 +- .../tests/workspace-identity.test.mjs | 95 ++++++++++++++++ 11 files changed, 393 insertions(+), 80 deletions(-) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index af48014106..757c2a7093 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -426,6 +426,44 @@ fn workspace_metadata_string( .map(ToOwned::to_owned) } +#[cfg(feature = "remote-connect")] +async fn remote_opened_workspace_catalog( + service: &crate::service::workspace::WorkspaceService, +) -> Vec { + service + .get_opened_workspaces() + .await + .into_iter() + .map(|workspace| { + let name = if workspace.workspace_kind + == crate::service::workspace::WorkspaceKind::Assistant + { + workspace + .identity + .as_ref() + .and_then(|identity| identity.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(&workspace.name) + .to_string() + } else { + workspace.name.clone() + }; + RemoteRecentWorkspaceFacts { + path: workspace.root_path.to_string_lossy().to_string(), + name, + last_opened: workspace.last_accessed.to_rfc3339(), + kind: remote_workspace_kind(workspace.workspace_kind), + remote_connection_id: workspace_metadata_string( + &workspace.metadata, + "connectionId", + ), + remote_ssh_host: workspace_metadata_string(&workspace.metadata, "sshHost"), + } + }) + .collect() +} + #[cfg(feature = "remote-connect")] async fn current_remote_workspace_facts() -> Option { let workspace_service = crate::service::workspace::get_global_workspace_service()?; @@ -2629,6 +2667,14 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { .collect() } + async fn opened_workspaces(&self) -> Result>, String> { + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + Ok(Some( + remote_opened_workspace_catalog(&workspace_service).await, + )) + } + async fn open_workspace( &self, path: &str, @@ -3021,6 +3067,65 @@ mod tests { }; use crate::OpenBitFunError; + #[tokio::test] + async fn remote_workspace_catalog_tracks_opened_rows_and_assistant_identity() { + use crate::service::workspace::{WorkspaceCreateOptions, WorkspaceKind, WorkspaceService}; + let root = tempfile::tempdir().unwrap(); + let paths = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ), + ); + let service = WorkspaceService::new_for_test_path_manager(paths).await; + let project_root = root.path().join("project"); + let assistant_root = root.path().join("workspace"); + std::fs::create_dir_all(&project_root).unwrap(); + std::fs::create_dir_all(&assistant_root).unwrap(); + std::fs::write(assistant_root.join("IDENTITY.md"), "---\nname: Mina\n---\n").unwrap(); + let project = service.open_workspace(project_root).await.unwrap(); + let assistant = service + .open_workspace_with_options( + assistant_root.clone(), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Assistant, + display_name: Some("workspace".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + // Older records can retain the directory name alongside an up-to-date identity. + let mut export = service.export_workspaces().await.unwrap(); + export + .workspaces + .iter_mut() + .find(|row| row.id == assistant.id) + .unwrap() + .name = "workspace".into(); + service.import_workspaces(export, true).await.unwrap(); + + let opened = remote_opened_workspace_catalog(&service).await; + assert_eq!(opened.len(), 2); + let assistant_row = opened + .iter() + .find(|row| row.path == assistant.root_path.to_string_lossy()) + .unwrap(); + assert_eq!(assistant_row.name, "Mina"); + assert_eq!(assistant_row.kind, RemoteConnectWorkspaceKind::Assistant); + + service.close_workspace(&project.id).await.unwrap(); + assert!(service + .get_recent_workspaces() + .await + .iter() + .any(|row| row.id == project.id)); + let refreshed = remote_opened_workspace_catalog(&service).await; + assert_eq!(refreshed.len(), 1); + assert_eq!(refreshed[0].name, "Mina"); + service.close_workspace(&assistant.id).await.unwrap(); + assert!(remote_opened_workspace_catalog(&service).await.is_empty()); + } + #[cfg(feature = "opencode-plugin-host")] fn plugin_session_request() -> AgentSessionCreateRequest { AgentSessionCreateRequest { diff --git a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs index aeeca21363..1cd67351e4 100644 --- a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs @@ -142,6 +142,11 @@ pub struct RemoteFileChunkRange { pub trait RemoteWorkspaceRuntimeHost: Send + Sync { async fn current_workspace(&self) -> Option; async fn recent_workspaces(&self) -> Vec; + /// Authoritative sidebar catalog, including opened assistant workspaces. + /// `None` advertises a legacy host; `Some([])` means no workspaces are open. + async fn opened_workspaces(&self) -> Result>, String> { + Ok(None) + } async fn open_workspace( &self, path: &str, diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 693c8e52b4..8050db9363 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -961,18 +961,24 @@ pub fn remote_workspace_info_response(workspace: Option) - pub fn remote_recent_workspaces_response( workspaces: Vec, ) -> RemoteResponse { + remote_workspace_catalog_response(workspaces, None) +} + +fn remote_workspace_catalog_response( + workspaces: Vec, + opened_workspaces: Option>, +) -> RemoteResponse { + let project = |workspace: RemoteRecentWorkspaceFacts| RecentWorkspaceEntry { + path: workspace.path, + name: workspace.name, + last_opened: workspace.last_opened, + workspace_kind: Some(workspace.kind.as_wire_str().to_string()), + remote_connection_id: workspace.remote_connection_id, + remote_ssh_host: workspace.remote_ssh_host, + }; RemoteResponse::RecentWorkspaces { - workspaces: workspaces - .into_iter() - .map(|workspace| RecentWorkspaceEntry { - path: workspace.path, - name: workspace.name, - last_opened: workspace.last_opened, - workspace_kind: Some(workspace.kind.as_wire_str().to_string()), - remote_connection_id: workspace.remote_connection_id, - remote_ssh_host: workspace.remote_ssh_host, - }) - .collect(), + workspaces: workspaces.into_iter().map(project).collect(), + opened_workspaces: opened_workspaces.map(|rows| rows.into_iter().map(project).collect()), } } @@ -1129,9 +1135,10 @@ where RemoteCommand::GetWorkspaceInfo => { remote_workspace_info_response(host.current_workspace().await) } - RemoteCommand::ListRecentWorkspaces => { - remote_recent_workspaces_response(host.recent_workspaces().await) - } + RemoteCommand::ListRecentWorkspaces => match host.opened_workspaces().await { + Ok(opened) => remote_workspace_catalog_response(host.recent_workspaces().await, opened), + Err(message) => RemoteResponse::Error { message }, + }, RemoteCommand::SetWorkspace { path, remote_connection_id, @@ -2492,6 +2499,10 @@ pub enum RemoteResponse { }, RecentWorkspaces { workspaces: Vec, + /// Presence negotiates the authoritative opened-workspace catalog. + /// Keep `workspaces` as recent history for older clients and pickers. + #[serde(default, skip_serializing_if = "Option::is_none")] + opened_workspaces: Option>, }, WorkspaceUpdated { success: bool, @@ -4016,6 +4027,19 @@ mod tests { }] } + async fn opened_workspaces( + &self, + ) -> Result>, String> { + Ok(Some(vec![RemoteRecentWorkspaceFacts { + path: "/assistant/workspace".into(), + name: "Mina".into(), + last_opened: "2026-05-29T00:00:00Z".into(), + kind: RemoteWorkspaceKind::Assistant, + remote_connection_id: None, + remote_ssh_host: None, + }])) + } + async fn open_workspace( &self, path: &str, @@ -4051,6 +4075,44 @@ mod tests { } } + #[tokio::test] + async fn remote_workspace_catalog_advertises_opened_rows_without_repurposing_recent_history() { + let response = handle_remote_workspace_command( + &FakeWorkspaceHost, + &RemoteCommand::ListRecentWorkspaces, + ) + .await; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["workspaces"][0]["path"], "/workspace/project"); + assert_eq!(json["opened_workspaces"][0]["name"], "Mina"); + assert_eq!(json["opened_workspaces"][0]["workspace_kind"], "assistant"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + response + ); + + let legacy = serde_json::json!({ + "resp": "recent_workspaces", + "workspaces": [{ "path": "/legacy", "name": "Legacy", "last_opened": "" }] + }); + assert_eq!( + serde_json::to_value(serde_json::from_value::(legacy.clone()).unwrap()) + .unwrap(), + legacy + ); + let empty = remote_workspace_catalog_response(Vec::new(), Some(Vec::new())); + assert_eq!( + serde_json::to_value(empty).unwrap()["opened_workspaces"], + serde_json::json!([]) + ); + assert!( + serde_json::to_value(remote_recent_workspaces_response(Vec::new())) + .unwrap() + .get("opened_workspaces") + .is_none() + ); + } + #[tokio::test] async fn remote_workspace_handler_preserves_response_shapes() { let host = FakeWorkspaceHost; diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md index 62495e1624..6d856f1bb2 100644 --- a/src/mobile-web/AGENTS.md +++ b/src/mobile-web/AGENTS.md @@ -6,6 +6,12 @@ Mobile web is the browser-based remote control client for OpenBitFun desktop ses - Keep mobile-web logic inside `src/mobile-web`; do not import from `src/web-ui`. - Treat pairing, reconnect, disconnect, session list, and chat state as one connected product flow. +- The compact sidebar uses the host's opened-workspace catalog, including assistant + identity names. `list_recent_workspaces.opened_workspaces` is an additive capability + offer: an array (including an empty one) is authoritative; absence selects the + explicitly labeled legacy recent-history fallback. Never rebuild sidebar rows + from current-workspace state or cached session paths. Keep history for the picker + and retain session data when a workspace closes or a refresh fails. - Keep connection state semantics consistent across persistent indicators, banners, dialogs, and disabled states. - User-facing strings should use the mobile-web i18n message system when one is already present for the surface being changed. - Locale ids and aliases come from `src/shared/i18n/contract/locales.json` diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 69a70d2ab1..1120586931 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -88,6 +88,7 @@ export const messages: Record = { switchWorkspace: 'Switch workspace', selectWorkspace: 'Select Workspace', noWorkspaces: 'No workspaces found', + legacyWorkspaceCatalog: 'This device provides recent workspaces only. Update OpenBitFun on it to sync its open workspace list.', noWorkspaceSelected: 'No workspace selected', assistant: SHARED_TERMS_BY_LOCALE['en-US'].agents.Claw, switchAssistant: 'Switch assistant', @@ -357,6 +358,7 @@ export const messages: Record = { switchWorkspace: '切换工作区', selectWorkspace: '选择工作区', noWorkspaces: '暂无工作区', + legacyWorkspaceCatalog: '该设备仅提供最近访问记录。更新其 OpenBitFun 后,可同步已打开的工作区列表。', noWorkspaceSelected: '未选择工作区', assistant: SHARED_TERMS_BY_LOCALE['zh-CN'].agents.Claw, switchAssistant: '切换助理', @@ -626,6 +628,7 @@ export const messages: Record = { switchWorkspace: '切換工作區', selectWorkspace: '選擇工作區', noWorkspaces: '暫無工作區', + legacyWorkspaceCatalog: '此裝置僅提供最近存取記錄。更新其 OpenBitFun 後,可同步已開啟的工作區清單。', noWorkspaceSelected: '未選擇工作區', assistant: SHARED_TERMS_BY_LOCALE['zh-TW'].agents.Claw, switchAssistant: '切換助理', diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 47512ff573..b9425970c4 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -74,54 +74,6 @@ function compactWorkspaceKey(workspace: RecentWorkspaceEntry): string { return workspaceIdentityKey(workspace); } -function mergeCompactWorkspaces( - recent: RecentWorkspaceEntry[], - currentWorkspace: { - path?: string; - project_name?: string; - workspace_kind?: 'normal' | 'assistant' | 'remote'; - remote_connection_id?: string; - remote_ssh_host?: string; - } | null, - sessions: SessionInfo[], -): RecentWorkspaceEntry[] { - const merged: RecentWorkspaceEntry[] = []; - const seen = new Set(); - const append = (workspace: RecentWorkspaceEntry) => { - if (!workspace.path) return; - const key = compactWorkspaceKey(workspace); - if (seen.has(key)) return; - seen.add(key); - merged.push(workspace); - }; - - if (currentWorkspace?.path) { - append({ - path: currentWorkspace.path, - name: currentWorkspace.project_name || currentWorkspace.path.split('/').filter(Boolean).pop() || currentWorkspace.path, - last_opened: '', - workspace_kind: currentWorkspace.workspace_kind, - remote_connection_id: currentWorkspace.remote_connection_id, - remote_ssh_host: currentWorkspace.remote_ssh_host, - }); - } - recent.forEach(append); - sessions.forEach((session) => { - if (!session.workspace_path) return; - if (!session.workspace_identity && merged.some((workspace) => ( - workspace.path === session.workspace_path - ))) return; - append({ - path: session.workspace_path, - name: session.workspace_name || session.workspace_path.split('/').filter(Boolean).pop() || session.workspace_path, - last_opened: session.updated_at, - remote_connection_id: session.workspace_identity?.remote_connection_id, - remote_ssh_host: session.workspace_identity?.remote_ssh_host, - }); - }); - return merged; -} - type SessionListTargetOwner = { sessionMgr: RemoteSessionManager; epoch: number; @@ -354,6 +306,7 @@ const SessionListPage: React.FC = ({ remote_ssh_host?: string; }>>([]); const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); + const [workspaceCatalogSource, setWorkspaceCatalogSource] = useState<'opened' | 'recent' | null>(null); // Search, rename & delete state const [searchQuery, setSearchQuery] = useState(''); @@ -560,6 +513,7 @@ const SessionListPage: React.FC = ({ const offsetRef = useRef(0); const listRef = useRef(null); const listRequestSeqRef = useRef(0); + const workspaceCatalogRequestSeqRef = useRef(0); const initLoadedPathRef = useRef(undefined); const touchStartY = useRef(0); const isPulling = useRef(false); @@ -593,6 +547,8 @@ const SessionListPage: React.FC = ({ setDeleting(false); setAssistantList([]); setWorkspaceList([]); + setWorkspaceCatalogSource(null); + workspaceCatalogRequestSeqRef.current += 1; setShowAssistantPicker(false); setShowWorkspacePicker(false); setMenuSession(null); @@ -658,6 +614,7 @@ const SessionListPage: React.FC = ({ offsetRef.current = cached.sessions.length; } setWorkspaceList(cached.workspaces); + setWorkspaceCatalogSource(cached.workspaceCatalogSource ?? 'recent'); const cachedByWorkspace: Record = {}; const cachedStatuses: Record = {}; @@ -753,18 +710,23 @@ const SessionListPage: React.FC = ({ const loadWorkspaceList = useCallback(async () => { const targetEpoch = captureSessionListEpoch(); if (targetEpoch === null) return; + const requestSeq = ++workspaceCatalogRequestSeqRef.current; try { - const workspaces = await sessionMgr.listRecentWorkspaces(); - if (!isSessionListCurrent(targetEpoch)) return; + const catalog = compact + ? await sessionMgr.listWorkspaceCatalog() + : { workspaces: await sessionMgr.listRecentWorkspaces(), source: 'recent' as const }; + if (!isSessionListCurrent(targetEpoch) || requestSeq !== workspaceCatalogRequestSeqRef.current) return; liveDataSeqRef.current += 1; - setWorkspaceList(workspaces); - remoteCache.saveWorkspaceCatalog(cacheScope, workspaces); + setWorkspaceList(catalog.workspaces); + setWorkspaceCatalogSource(catalog.source); + remoteCache.saveWorkspaceCatalog(cacheScope, catalog.workspaces, catalog.source); } catch (e: any) { - if (isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { + if (requestSeq === workspaceCatalogRequestSeqRef.current + && isSessionListCurrent(targetEpoch) && !isRemoteControlTargetChangedError(e)) { setError(e.message); } } - }, [cacheScope, captureSessionListEpoch, isSessionListCurrent, sessionMgr, setError]); + }, [cacheScope, captureSessionListEpoch, compact, isSessionListCurrent, sessionMgr, setError]); const loadCompactDirectory = useCallback(async () => { if (!compact) return; @@ -789,15 +751,18 @@ const SessionListPage: React.FC = ({ const loadCompactWorkspaceCatalog = useCallback(async (expectedTargetEpoch: number) => { if (!compact || !client) return; setCompactDirectoryLoading(true); + const requestSeq = ++workspaceCatalogRequestSeqRef.current; try { - const workspaces = await sessionMgr.listRecentWorkspaces(); - if (client.controlTargetEpoch !== expectedTargetEpoch) return; + const catalog = await sessionMgr.listWorkspaceCatalog(); + if (client.controlTargetEpoch !== expectedTargetEpoch || requestSeq !== workspaceCatalogRequestSeqRef.current) return; liveDataSeqRef.current += 1; - setWorkspaceList(workspaces); - remoteCache.saveWorkspaceCatalog(cacheScope, workspaces); + setWorkspaceList(catalog.workspaces); + setWorkspaceCatalogSource(catalog.source); + remoteCache.saveWorkspaceCatalog(cacheScope, catalog.workspaces, catalog.source); } catch (error: unknown) { if ( client.controlTargetEpoch === expectedTargetEpoch + && requestSeq === workspaceCatalogRequestSeqRef.current && !isRemoteControlTargetChangedError(error) ) { setError(String((error as { message?: string })?.message || error)); @@ -1323,9 +1288,19 @@ const SessionListPage: React.FC = ({ }, [cacheScope, captureSessionListEpoch, currentAssistant?.path, displayMode, isSessionListCurrent, searchQuery, sessionMgr, setCurrentWorkspace, setSessions]); useEffect(() => { - const poll = setInterval(refreshData, 10000); - return () => clearInterval(poll); - }, [refreshData]); + const poll = setInterval(() => { + void refreshData(); + if (compact) void loadWorkspaceList(); + }, 10000); + const refreshDirectory = () => { + if (compact && document.visibilityState === 'visible') void loadWorkspaceList(); + }; + document.addEventListener('visibilitychange', refreshDirectory); + return () => { + clearInterval(poll); + document.removeEventListener('visibilitychange', refreshDirectory); + }; + }, [compact, loadWorkspaceList, refreshData]); useEffect(() => { const workspacePath = displayMode === 'assistant' ? currentAssistant?.path : currentWorkspace?.path; @@ -1564,7 +1539,7 @@ const SessionListPage: React.FC = ({ const visibleSessions = sessions.filter((session) => ( query.length === 0 || (session.name || '').toLocaleLowerCase().includes(query) )); - const compactWorkspaces = mergeCompactWorkspaces(workspaceList, currentWorkspace, sessions); + const compactWorkspaces = workspaceList; const activeDeviceId = client?.targetDeviceId ?? null; const projectedCompactDevices = !activeDeviceId || compactDevices.some((device) => ( @@ -1664,6 +1639,9 @@ const SessionListPage: React.FC = ({