From 0a657c3204af069a3f29a37c9dd6d45530391f34 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 14 Sep 2026 09:37:21 +0800 Subject: [PATCH] fix(mobile): show remote workspace SSH hosts across native clients --- .../app/ui/remote/CreateSessionScreen.kt | 6 +++--- .../mobile/app/ui/remote/PairingScreen.kt | 2 +- .../sidebar/SidebarRemoteWorkspaceSection.kt | 4 ++-- .../entry/src/main/ets/model/RemoteModels.ets | 2 ++ .../pages/components/SidebarDeviceGroup.ets | 2 +- .../components/SidebarWorkspacePicker.ets | 2 +- .../pages/policy/SessionListProjection.ets | 3 ++- .../ets/services/RemoteResponseMapper.ets | 1 + .../ConversationPresentationUnit.test.ets | 10 +++++++++ .../MobileAppModel+RemoteSession.swift | 6 +++--- .../mobile/core/domain/WorkspaceModels.kt | 8 ++++++- .../core/domain/WorkspaceDisplayNameTest.kt | 15 +++++++++++++ .../feature/workspace/RemoteWorkspaceStore.kt | 6 ++++-- .../mobile/core/persistence/ChatLocalStore.kt | 1 + .../persistence/RemotePersistenceStoreTest.kt | 7 +++++++ .../mobile/core/protocol/WorkspaceDtos.kt | 3 +++ .../mobile/core/protocol/WorkspaceHostTest.kt | 21 +++++++++++++++++++ 17 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/WorkspaceDisplayNameTest.kt create mode 100644 src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceHostTest.kt diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt index 1e8cd88310..304768b9be 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt @@ -276,7 +276,7 @@ internal fun CreateSessionScreen( label = when { workspaceState is RemoteWorkspaceUiState.Loading -> stringResource(R.string.sessions_loading) workspacePath.isEmpty() -> stringResource(R.string.create_chat) - else -> ready?.workspaces?.firstOrNull { it.path == workspacePath }?.name.orEmpty() + else -> ready?.workspaces?.firstOrNull { it.path == workspacePath }?.displayName.orEmpty() .ifEmpty { workspacePath } }, expanded = pickerKind == CreateSelectionKind.WORKSPACE, @@ -299,7 +299,7 @@ internal fun CreateSessionScreen( ) { WorkspacePicker( workspaces = ready?.workspaces.orEmpty().map { - WorkspaceChoice(path = it.path, name = it.name) + WorkspaceChoice(path = it.path, name = it.displayName) }, selectedPath = workspacePath, showHeader = false, @@ -380,7 +380,7 @@ internal fun CreateSessionScreen( ModalBottomSheet(onDismissRequest = { pickerKind = null }) { WorkspacePicker( workspaces = ready?.workspaces.orEmpty().map { - WorkspaceChoice(path = it.path, name = it.name) + WorkspaceChoice(path = it.path, name = it.displayName) }, selectedPath = workspacePath, showHeader = true, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt index e3d2e92dcc..9db394b0f2 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt @@ -357,7 +357,7 @@ internal fun RemoteWorkspacePanel( TextButton( onClick = { onIntent(RemoteWorkspaceIntent.SelectWorkspace(workspace.path)) }, enabled = !state.busy && state.selected?.path != workspace.path, - ) { Text(workspace.name) } + ) { Text(workspace.displayName) } } if (state.assistants.isNotEmpty()) { Text(stringResource(R.string.assistants_title), style = MaterialTheme.typography.titleSmall) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt index 157e8eeefe..12e2a16655 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt @@ -277,13 +277,13 @@ private fun SidebarActiveDeviceBody( val selected = readyWorkspace.selected val workspaceRows = buildList { if (selected != null && selected.path.isNotBlank()) { - add(selected.path to selected.name) + add(selected.path to (readyWorkspace.workspaces.firstOrNull { RemoteWorkspacePathPolicy.equal(it.path, selected.path) }?.displayName ?: selected.name)) } readyWorkspace.workspaces.forEach { workspace -> if (workspace.path.isNotBlank() && none { RemoteWorkspacePathPolicy.equal(it.first, workspace.path) }) { - add(workspace.path to workspace.name) + add(workspace.path to workspace.displayName) } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 810a9fb49f..8b8e5c7956 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -23,6 +23,7 @@ export interface InitialSyncResult { } export interface RecentWorkspaceEntry { + remoteSshHost?: string; path: string; name: string; lastOpened: string; @@ -156,6 +157,7 @@ export interface WorkspaceInfoResponse extends CommandStatusResponse { } export interface RecentWorkspaceEntryResponse { + remote_ssh_host?: string; path?: string; name?: string; last_opened?: string | number; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets index 6498b22c5e..bae48217aa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarDeviceGroup.ets @@ -370,7 +370,7 @@ export struct SidebarDeviceGroup { SidebarWorkspaceGroup({ deviceId: this.device.deviceId, path: entry.path, - name: entry.name, + name: entry.remoteSshHost?.trim() ? `${entry.name} · ${entry.remoteSshHost.trim()}` : entry.name, sessions: this.sessionsFor(entry.path), selectedSessionId: this.selectedSessionId, runningSessionId: this.runningSessionId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets index 7a209b9970..8f76b0ef5f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspacePicker.ets @@ -89,7 +89,7 @@ export struct SidebarWorkspacePicker { .borderRadius(10) .backgroundColor(CARD) Column({ space: 3 }) { - Text(workspace.name || this.basename(workspace.path)) + Text((workspace.name || this.basename(workspace.path)) + (workspace.remoteSshHost?.trim() ? ` · ${workspace.remoteSshHost.trim()}` : '')) .width('100%') .fontSize(MobileDesignTypography.bodyLarge.size) .fontWeight(workspace.path === this.selectedWorkspacePath ? FontWeight.Medium : FontWeight.Regular) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets index 16302c6b77..b7bc453661 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionListProjection.ets @@ -195,6 +195,7 @@ export class SessionListProjector { entries.push({ path: inputs.workspacePath, name: inputs.workspaceName, + remoteSshHost: inputs.recentWorkspaces.find((item: RecentWorkspaceEntry) => item.path === inputs.workspacePath)?.remoteSshHost, lastOpened: '', workspaceKind: 'normal' }); @@ -355,7 +356,7 @@ export class SessionListProjectionCache { private static workspaceFingerprint(entries: RecentWorkspaceEntry[]): string[] { return entries.map((item: RecentWorkspaceEntry): string => { - return `${item.path}${item.name}${item.workspaceKind}`; + return `${item.path}${item.name}${item.workspaceKind}\u0001${item.remoteSshHost || ''}`; }); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets index 560b21b741..84e02d5c10 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteResponseMapper.ets @@ -51,6 +51,7 @@ export class RemoteResponseMapper { item.updated_at, item.updatedAt ]), + remoteSshHost: item.remote_ssh_host, workspaceKind: item.workspace_kind || 'normal' }; return workspace; diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index bd42c57644..b0b732ef5f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -1,3 +1,4 @@ +import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { AccountLoginErrorPolicy } from '../main/ets/pages/policy/AccountLoginErrorPolicy'; import { describe, it, expect } from '@ohos/hypium'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; @@ -55,6 +56,15 @@ interface LoginFailureFixture { code?: number; statusCode?: number; } export default function conversationPresentationUnitTest() { describe('HarnessProfilePolicy', () => { + it('workspace_host_survives_mapping_and_legacy_payloads', 0, () => { + const workspaces = RemoteResponseMapper.recentWorkspaces([ + { path: '/remote', name: 'App', remote_ssh_host: '10.0.0.8' }, + { path: '/local', name: 'App' } + ]); + expect(workspaces[0].remoteSshHost).assertEqual('10.0.0.8'); + expect(workspaces[0].name).assertEqual('App'); + expect(workspaces[1].remoteSshHost).assertUndefined(); + }); it('maps mobile execution levels to canonical host Agents', 0, () => { expect(HarnessProfilePolicy.agentType('minimal')).assertEqual('minimal'); expect(HarnessProfilePolicy.agentType('standard')).assertEqual('agentic'); diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index 604c732dbd..ab5769df03 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -132,7 +132,7 @@ extension MobileAppModel { let directory = entry.workspace(path: workspace.path) return MobileWorkspaceGroup( path: workspace.path, - name: workspace.name.isEmpty ? workspace.path : workspace.name, + name: workspace.name.isEmpty ? workspace.path : workspace.displayName, selected: remoteExpectedDeviceKey == deviceKey && normalizedSessionWorkspacePath(workspace.path) == normalizedSessionWorkspacePath(workspaceCatalog.first(where: { $0.selected })?.path ?? ""), sessions: sessions.filter { normalizedSessionWorkspacePath($0.workspacePath ?? "") == normalizedSessionWorkspacePath(workspace.path) }, @@ -1016,13 +1016,13 @@ extension MobileAppModel { !selected.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let normalizedPath = normalizedSessionWorkspacePath(selected.path) seen.insert(normalizedPath) - catalog.append((selected.path, selected.name, true)) + catalog.append((selected.path, ready.workspaces.first(where: { normalizedSessionWorkspacePath($0.path) == normalizedPath })?.displayName ?? selected.name, true)) } for workspace in ready.workspaces { guard !workspace.path.isEmpty else { continue } let normalizedPath = normalizedSessionWorkspacePath(workspace.path) guard seen.insert(normalizedPath).inserted else { continue } - catalog.append((workspace.path, workspace.name, false)) + catalog.append((workspace.path, workspace.displayName, false)) } workspaceCatalog = catalog remoteAssistants = ready.assistants.map { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt index 134ca6c6b3..1c4f1e6297 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/WorkspaceModels.kt @@ -5,7 +5,13 @@ public data class RecentWorkspace public constructor( public val name: String, public val lastOpened: String, public val kind: String, -) + public val remoteSshHost: String?, +) { + public constructor(path: String, name: String, lastOpened: String, kind: String) : + this(path, name, lastOpened, kind, null) + public val displayName: String + get() = remoteSshHost?.trim()?.takeIf { it.isNotEmpty() }?.let { "$name · $it" } ?: name +} public data class WorkspaceAssistant public constructor( public val path: String, diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/WorkspaceDisplayNameTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/WorkspaceDisplayNameTest.kt new file mode 100644 index 0000000000..0aa3fc2478 --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/WorkspaceDisplayNameTest.kt @@ -0,0 +1,15 @@ +package com.openbitfun.mobile.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals + +class WorkspaceDisplayNameTest { + @Test fun appendsHostWithoutChangingNameOrPath() { + val remote = RecentWorkspace("/app", "App", "", "normal", "10.0.0.8") + assertEquals("App · 10.0.0.8", remote.displayName) + assertEquals("App", remote.name) + assertEquals("/app", remote.path) + assertEquals("App", RecentWorkspace("/app", "App", "", "normal").displayName) + assertEquals("App", remote.copy(remoteSshHost = " ").displayName) + } +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt index 0f6a5b4e7a..c79c176114 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt @@ -124,6 +124,7 @@ public class RemoteWorkspaceStore internal constructor( name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), lastOpened = item.lastOpened, kind = item.workspaceKind.orEmpty(), + remoteSshHost = item.remoteSshHost, ) }.filter { it.path.isNotEmpty() } val loadedAssistants = assistants.assistants.map { item -> @@ -180,6 +181,7 @@ public class RemoteWorkspaceStore internal constructor( name = item.name?.takeIf(String::isNotBlank) ?: basename(item.path.orEmpty()), lastOpened = item.lastOpened, kind = item.workspaceKind.orEmpty(), + remoteSshHost = item.remoteSshHost, ) }.filter { it.path.isNotEmpty() } val loadedAssistants = assistants.assistants.map { item -> @@ -646,7 +648,7 @@ public class RemoteWorkspaceStore internal constructor( WorkspaceAssistant(row.path, row.name.ifEmpty { basename(row.path) }, null) } val workspaces = rows.filterNot { it.workspaceKind == ASSISTANT_KIND }.map { row -> - RecentWorkspace(row.path, row.name.ifEmpty { basename(row.path) }, row.lastOpened, row.workspaceKind) + RecentWorkspace(row.path, row.name.ifEmpty { basename(row.path) }, row.lastOpened, row.workspaceKind, row.remoteSshHost) } return RemoteWorkspaceUiState.Ready( workspaces = workspaces, @@ -664,7 +666,7 @@ public class RemoteWorkspaceStore internal constructor( assistants: List, ): List { val rows = workspaces.map { workspace -> - PersistedRemoteWorkspace(workspace.path, workspace.name, workspace.lastOpened, workspace.kind) + PersistedRemoteWorkspace(workspace.path, workspace.name, workspace.lastOpened, workspace.kind, workspace.remoteSshHost) }.toMutableList() assistants.forEach { assistant -> if (rows.none { it.path == assistant.path }) { diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt index b94a7b6cd3..c746274bdd 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/openbitfun/mobile/core/persistence/ChatLocalStore.kt @@ -180,6 +180,7 @@ public data class PersistedRemoteWorkspace public constructor( public val name: String = "", public val lastOpened: String = "", public val workspaceKind: String = "", + public val remoteSshHost: String? = null, ) public interface RemoteSessionListStore { diff --git a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt index 825faf130c..4ebacd932a 100644 --- a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt +++ b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/openbitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt @@ -11,6 +11,13 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json class RemotePersistenceStoreTest { + @Test fun workspaceHostSurvivesCacheRoundTripAndLegacyRecords() { + val legacy = Json.decodeFromString("""{"path":"/app","name":"App"}""") + assertEquals(null, legacy.remoteSshHost) + val remote = legacy.copy(remoteSshHost = "10.0.0.8") + assertEquals(remote, Json.decodeFromString(Json.encodeToString(remote))) + } + private suspend fun stores(): Pair { val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) MobileDatabase.Schema.create(driver).await() diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt index f6f2740606..d213290659 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/WorkspaceDtos.kt @@ -42,6 +42,7 @@ public data class RecentWorkspaceEntryResponse( /** Resolved from [RECENT_WORKSPACE_TIME_KEYS]; empty string when absent. */ val lastOpened: String = "", val workspaceKind: String? = null, + val remoteSshHost: String? = null, ) public object RecentWorkspaceEntryResponseSerializer : KSerializer { @@ -54,6 +55,7 @@ public object RecentWorkspaceEntryResponseSerializer : KSerializer("""{"path":"/app","name":"App"}""") + assertNull(legacy.remoteSshHost) + for (host in listOf("10.0.0.8", "build.example.org", "2001:db8::1")) { + val entry = json.decodeFromString("""{"path":"/app","name":"App","remote_ssh_host":"$host"}""") + assertEquals(host, entry.remoteSshHost) + assertEquals(entry, json.decodeFromString(json.encodeToString(entry))) + } + } +}