Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/crates/assembly/core/src/service_agent_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteRecentWorkspaceFacts> {
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<RemoteWorkspaceFacts> {
let workspace_service = crate::service::workspace::get_global_workspace_service()?;
Expand Down Expand Up @@ -2629,6 +2667,14 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost {
.collect()
}

async fn opened_workspaces(&self) -> Result<Option<Vec<RemoteRecentWorkspaceFacts>>, 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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ pub struct RemoteFileChunkRange {
pub trait RemoteWorkspaceRuntimeHost: Send + Sync {
async fn current_workspace(&self) -> Option<RemoteWorkspaceFacts>;
async fn recent_workspaces(&self) -> Vec<RemoteRecentWorkspaceFacts>;
/// Authoritative sidebar catalog, including opened assistant workspaces.
/// `None` advertises a legacy host; `Some([])` means no workspaces are open.
async fn opened_workspaces(&self) -> Result<Option<Vec<RemoteRecentWorkspaceFacts>>, String> {
Ok(None)
}
async fn open_workspace(
&self,
path: &str,
Expand Down
90 changes: 76 additions & 14 deletions src/crates/services/services-integrations/src/remote_connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -961,18 +961,24 @@ pub fn remote_workspace_info_response(workspace: Option<RemoteWorkspaceFacts>) -
pub fn remote_recent_workspaces_response(
workspaces: Vec<RemoteRecentWorkspaceFacts>,
) -> RemoteResponse {
remote_workspace_catalog_response(workspaces, None)
}

fn remote_workspace_catalog_response(
workspaces: Vec<RemoteRecentWorkspaceFacts>,
opened_workspaces: Option<Vec<RemoteRecentWorkspaceFacts>>,
) -> 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()),
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2492,6 +2499,10 @@ pub enum RemoteResponse {
},
RecentWorkspaces {
workspaces: Vec<RecentWorkspaceEntry>,
/// 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<Vec<RecentWorkspaceEntry>>,
},
WorkspaceUpdated {
success: bool,
Expand Down Expand Up @@ -4016,6 +4027,19 @@ mod tests {
}]
}

async fn opened_workspaces(
&self,
) -> Result<Option<Vec<RemoteRecentWorkspaceFacts>>, 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,
Expand Down Expand Up @@ -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::<RemoteResponse>(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::<RemoteResponse>(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;
Expand Down
6 changes: 6 additions & 0 deletions src/mobile-web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
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',
Expand Down Expand Up @@ -357,6 +358,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
switchWorkspace: '切换工作区',
selectWorkspace: '选择工作区',
noWorkspaces: '暂无工作区',
legacyWorkspaceCatalog: '该设备仅提供最近访问记录。更新其 OpenBitFun 后,可同步已打开的工作区列表。',
noWorkspaceSelected: '未选择工作区',
assistant: SHARED_TERMS_BY_LOCALE['zh-CN'].agents.Claw,
switchAssistant: '切换助理',
Expand Down Expand Up @@ -626,6 +628,7 @@ export const messages: Record<MobileLanguage, MessageTree> = {
switchWorkspace: '切換工作區',
selectWorkspace: '選擇工作區',
noWorkspaces: '暫無工作區',
legacyWorkspaceCatalog: '此裝置僅提供最近存取記錄。更新其 OpenBitFun 後,可同步已開啟的工作區清單。',
noWorkspaceSelected: '未選擇工作區',
assistant: SHARED_TERMS_BY_LOCALE['zh-TW'].agents.Claw,
switchAssistant: '切換助理',
Expand Down
Loading
Loading