diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 37bb3f472ee..190e16bb648 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -75,7 +75,7 @@ class ChatController( fun load(sessionKey: String) { val key = sessionKey.trim().ifEmpty { "main" } _sessionKey.value = key - scope.launch { bootstrap(forceHealth = true) } + scope.launch { bootstrap(forceHealth = true, refreshSessions = true) } } fun applyMainSessionKey(mainSessionKey: String) { @@ -84,11 +84,11 @@ class ChatController( if (_sessionKey.value == trimmed) return if (_sessionKey.value != "main") return _sessionKey.value = trimmed - scope.launch { bootstrap(forceHealth = true) } + scope.launch { bootstrap(forceHealth = true, refreshSessions = true) } } fun refresh() { - scope.launch { bootstrap(forceHealth = true) } + scope.launch { bootstrap(forceHealth = true, refreshSessions = true) } } fun refreshSessions(limit: Int? = null) { @@ -106,7 +106,9 @@ class ChatController( if (key.isEmpty()) return if (key == _sessionKey.value) return _sessionKey.value = key - scope.launch { bootstrap(forceHealth = true) } + // Keep the thread switch path lean: history + health are needed immediately, + // but the session list is usually unchanged and can refresh on explicit pull-to-refresh. + scope.launch { bootstrap(forceHealth = true, refreshSessions = false) } } fun sendMessage( @@ -249,7 +251,7 @@ class ChatController( } } - private suspend fun bootstrap(forceHealth: Boolean) { + private suspend fun bootstrap(forceHealth: Boolean, refreshSessions: Boolean) { _errorText.value = null _healthOk.value = false clearPendingRuns() @@ -271,7 +273,9 @@ class ChatController( history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } pollHealthIfNeeded(force = forceHealth) - fetchSessions(limit = 50) + if (refreshSessions) { + fetchSessions(limit = 50) + } } catch (err: Throwable) { _errorText.value = err.message } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt index 5bf3a60ec01..73a931b488f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CanvasScreen.kt @@ -25,7 +25,7 @@ import ai.openclaw.app.MainViewModel @SuppressLint("SetJavaScriptEnabled") @Composable -fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { +fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) { val context = LocalContext.current val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 val webViewRef = remember { mutableStateOf(null) } @@ -45,6 +45,7 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { modifier = modifier, factory = { WebView(context).apply { + visibility = if (visible) View.VISIBLE else View.INVISIBLE settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE @@ -127,6 +128,16 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { webViewRef.value = this } }, + update = { webView -> + webView.visibility = if (visible) View.VISIBLE else View.INVISIBLE + if (visible) { + webView.resumeTimers() + webView.onResume() + } else { + webView.onPause() + webView.pauseTimers() + } + }, ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt index 5e04d905407..133252c6f8e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt @@ -39,7 +39,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.zIndex import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -68,10 +70,19 @@ private enum class StatusVisual { @Composable fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + var chatTabStarted by rememberSaveable { mutableStateOf(false) } + var screenTabStarted by rememberSaveable { mutableStateOf(false) } - // Stop TTS when user navigates away from voice tab + // Stop TTS when user navigates away from voice tab, and lazily keep the Chat/Screen tabs + // alive after the first visit so repeated tab switches do not rebuild their UI trees. LaunchedEffect(activeTab) { viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice) + if (activeTab == HomeTab.Chat) { + chatTabStarted = true + } + if (activeTab == HomeTab.Screen) { + screenTabStarted = true + } } val statusText by viewModel.statusText.collectAsState() @@ -120,11 +131,35 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) .consumeWindowInsets(innerPadding) .background(mobileBackgroundGradient), ) { + if (chatTabStarted) { + Box( + modifier = + Modifier + .matchParentSize() + .alpha(if (activeTab == HomeTab.Chat) 1f else 0f) + .zIndex(if (activeTab == HomeTab.Chat) 1f else 0f), + ) { + ChatSheet(viewModel = viewModel) + } + } + + if (screenTabStarted) { + ScreenTabScreen( + viewModel = viewModel, + visible = activeTab == HomeTab.Screen, + modifier = + Modifier + .matchParentSize() + .alpha(if (activeTab == HomeTab.Screen) 1f else 0f) + .zIndex(if (activeTab == HomeTab.Screen) 1f else 0f), + ) + } + when (activeTab) { HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) - HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Chat -> if (!chatTabStarted) ChatSheet(viewModel = viewModel) HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel) - HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Screen -> Unit HomeTab.Settings -> SettingsSheet(viewModel = viewModel) } } @@ -132,16 +167,19 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) } @Composable -private fun ScreenTabScreen(viewModel: MainViewModel) { +private fun ScreenTabScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier = Modifier) { val isConnected by viewModel.isConnected.collectAsState() - LaunchedEffect(isConnected) { - if (isConnected) { + var refreshedForCurrentConnection by rememberSaveable(isConnected) { mutableStateOf(false) } + + LaunchedEffect(isConnected, visible, refreshedForCurrentConnection) { + if (visible && isConnected && !refreshedForCurrentConnection) { viewModel.refreshHomeCanvasOverviewIfConnected() + refreshedForCurrentConnection = true } } - Box(modifier = Modifier.fillMaxSize()) { - CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + Box(modifier = modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, visible = visible, modifier = Modifier.fillMaxSize()) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 2d8fb255baa..5883cdd965a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -63,7 +63,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { LaunchedEffect(mainSessionKey) { viewModel.loadChat(mainSessionKey) - viewModel.refreshChatSessions(limit = 200) } val context = LocalContext.current