diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 71eb0ac0d5..d177037a28 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -180,6 +180,27 @@ final class MobileCoreAdapter { } } + private func reconcileDirectorySessionSnapshot( + _ state: RemoteSessionUiState, + sessionStore: RemoteSessionStore, + targetKey: String + ) { + guard let ready = state as? RemoteSessionUiStateReady, + ready.query.isEmpty, + ready.agentFilter.name == "ALL", + !ready.busy, + let workspaceIdentity = sessionStore.activeWorkspaceIdentity, + targetKey.hasPrefix("account:") else { return } + let deviceID = String(targetKey.dropFirst("account:".count)) + guard !deviceID.isEmpty else { return } + _ = deviceDirectory.reconcileWorkspaceSessions( + deviceId: deviceID, + identity: workspaceIdentity, + sessions: ready.sessions, + hasMore: ready.hasMore + ) + } + @discardableResult func syncDeviceDirectory(_ devices: [MobileAccountDevice]) -> UInt64 { directoryGeneration &+= 1 @@ -622,6 +643,7 @@ final class MobileCoreAdapter { onRemoteTargetBound?(targetKey, boundEpoch, accountGeneration) let sessionFlow = SkieSwiftStateFlow(sessionStore.state) + reconcileDirectorySessionSnapshot(sessionFlow.value, sessionStore: sessionStore, targetKey: targetKey) onRemoteState?(sessionFlow.value, targetKey, boundEpoch) remoteObservations.append(Task { [weak self] in var lastHealthSignature = "" @@ -635,6 +657,7 @@ final class MobileCoreAdapter { lastHealthSignature = signature } #endif + self?.reconcileDirectorySessionSnapshot(state, sessionStore: sessionStore, targetKey: targetKey) self?.onRemoteState?(state, targetKey, boundEpoch) } }) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt index b86ff1b83c..eb0df05935 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt @@ -84,6 +84,39 @@ public class DeviceDirectoryStore internal constructor( return true } + /** + * Replaces one workspace's directory session snapshot with an + * authoritative, unfiltered session-list result from its active store. + * + * The active session store and this directory intentionally have separate + * lifecycles. Keeping this hand-off here makes the directory the owner of + * its own projection and prevents a filtered/search result from deleting + * sessions that were not part of that result. + */ + public fun reconcileWorkspaceSessions( + deviceId: String, + identity: RemoteWorkspaceIdentity, + sessions: List, + hasMore: Boolean, + ): Boolean { + val id = deviceId.trim() + val current = devices[id] ?: return false + if (!current.online) return false + val slot = slotFor(id) ?: return false + val incoming = sessions.filter { it.id.isNotBlank() } + val next = if (hasMore) { + val incomingIds = incoming.mapTo(mutableSetOf()) { it.id } + incoming + current.sessions.filterNot { it.id in incomingIds } + } else { + replaceWorkspaceSessions(current.sessions, identity, incoming) + } + slot.sessionStore.persistDirectorySessions(next, hasMore) + if (current.sessions == next) return true + devices[id] = current.copy(sessions = next) + publish() + return true + } + /** * Cancels every running load and releases the underlying stores, while * keeping the directory entries themselves: already-loaded data stays on diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index a38e213625..ac2be2bd0f 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -207,6 +207,12 @@ public class RemoteSessionStore internal constructor( public val supportsWorkspaceIdReferences: Boolean? get() = if (hostCapabilitiesKnown) WorkspaceReferencePolicy.supportsWorkspaceIdReferences(hostCapabilities) else null + /** The workspace identity used by the current session-list requests. */ + public val activeWorkspaceIdentity: RemoteWorkspaceIdentity? + get() = workspacePath.trim().takeIf(String::isNotEmpty)?.let { + RemoteWorkspaceIdentity(it, workspaceConnectionId, workspaceSshHost, workspaceId) + } + public fun dispatch(intent: RemoteSessionIntent) { val current = _state.value as? RemoteSessionUiState.Ready when (intent) { @@ -463,8 +469,8 @@ public class RemoteSessionStore internal constructor( } /** Persists the directory's merged cross-workspace snapshot for offline restore. */ - internal fun persistDirectorySessions(sessions: List) { - savePersistedSessions(sessions, false) + internal fun persistDirectorySessions(sessions: List, hasMore: Boolean = false) { + savePersistedSessions(sessions, hasMore) } private fun loadWorkspaceSessions(path: String, force: Boolean, remoteConnectionId: String?, remoteSshHost: String?, workspaceId: String?) { diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt index a151e9a5ca..4c7862f18a 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt @@ -1,6 +1,7 @@ package com.openbitfun.mobile.core.feature.directory import com.openbitfun.mobile.core.domain.RemoteSession +import com.openbitfun.mobile.core.domain.RemoteWorkspaceIdentity import com.openbitfun.mobile.core.feature.session.RemoteSessionStore import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceStore import com.openbitfun.mobile.core.persistence.ChatLocalStore @@ -432,6 +433,54 @@ class DeviceDirectoryStoreTest { assertEquals(1, store.state.value.device("a")!!.sessions.count { it.id == "created" }) } + @Test + fun authoritativeWorkspaceSessionSnapshotUpdatesDirectoryAndItsCache() = runTest { + val transport = FakeDeviceTransport("a") + val sessions = MemoryDirectorySessions() + val store = DeviceDirectoryStore.create( + this, + CachedDeviceStoreFactory(transport, sessions, MemoryDirectoryWorkspaces()), + ) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + store.dispatch(DeviceDirectoryIntent.SetWorkspaceExpanded("a", "/repo-a", true)) + advanceUntilIdle() + assertEquals(listOf("s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + + val otherWorkspaceSession = RemoteSession( + id = "other-session", title = "Other", agentType = "code", status = "idle", + updatedAt = "other-time", createdAt = "other-time", messageCount = 0, + workspacePath = "/other", workspaceName = "Other", + ) + assertTrue(store.reconcileCreatedSession(store.reconcileKey("a")!!, otherWorkspaceSession)) + + val snapshot = listOf( + RemoteSession( + id = "new-session", title = "New", agentType = "code", status = "idle", + updatedAt = "new-time", createdAt = "new-time", messageCount = 0, + workspacePath = "/repo-a", workspaceName = "Repo a", + ), + ) + val identity = RemoteWorkspaceIdentity("/repo-a", null, null, null) + assertTrue( + store.reconcileWorkspaceSessions( + "a", + identity, + snapshot, + hasMore = false, + ), + ) + val updated = store.state.value.device("a")!! + assertEquals(listOf("new-session", "other-session"), updated.sessions.map { it.id }) + assertEquals(listOf("new-session"), updated.sessionsForWorkspace(identity).map { it.id }) + assertEquals(listOf("other-session"), updated.sessions.filter { it.id == "other-session" }.map { it.id }) + assertEquals( + listOf("new-session", "other-session"), + sessions.byDevice.getValue("a").map { it.sessionId }, + ) + } + @Test fun staleReconcileCannotReviveRemovedStoppedOrReconnectedDevice() = runTest { val transport = FakeDeviceTransport("a")