diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt index 155e899ad7af..0f21e6c8dde4 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -73,6 +74,7 @@ import androidx.wear.compose.material3.HorizontalPagerScaffold import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -430,6 +432,8 @@ private fun VoicePage( else -> ThreadVoiceMode( conversation = realtimeTalk.conversation, + thinking = + realtimeThinkingOverride || realtimeTalk.status == WearRealtimeTalkStatus.THINKING, realtimeActive = realtimeTalk.active || realtimeCapturing, actionBusy = actionBusy, inputEnabled = inputEnabled, @@ -695,6 +699,7 @@ private fun VoiceGestureLabel( @Composable private fun ThreadVoiceMode( conversation: List, + thinking: Boolean, realtimeActive: Boolean, actionBusy: Boolean, inputEnabled: Boolean, @@ -703,6 +708,40 @@ private fun ThreadVoiceMode( ) { val colors = OpenClawWearTheme.colors val listState = rememberTransformingLazyColumnState() + val coroutineScope = rememberCoroutineScope() + val visibleConversation = conversation.takeLast(VISIBLE_REALTIME_ENTRY_COUNT) + val contentRevision = wearThreadContentRevision(visibleConversation, thinking) + val latestAnchorIndex = wearThreadLatestAnchorIndex(visibleConversation.size, thinking) + var followState by remember { mutableStateOf(WearThreadFollowState()) } + + LaunchedEffect(listState) { + snapshotFlow { + WearThreadViewport( + atLatest = !listState.canScrollForward, + scrollingBackward = listState.isScrollInProgress && listState.lastScrolledBackward, + ) + }.collect { viewport -> + followState = + nextWearThreadFollowForViewport( + state = followState, + atLatest = viewport.atLatest, + scrollingBackward = viewport.scrollingBackward, + ) + } + } + LaunchedEffect(realtimeActive, contentRevision) { + val update = + nextWearThreadFollowForContent( + state = followState, + contentRevision = contentRevision, + realtimeActive = realtimeActive, + ) + followState = update.state + if (update.scrollToLatest && latestAnchorIndex >= 0) { + listState.requestScrollToItem(latestAnchorIndex) + } + } + Box(modifier = Modifier.fillMaxSize()) { TransformingLazyColumn( modifier = @@ -714,7 +753,7 @@ private fun ThreadVoiceMode( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(7.dp), ) { - if (conversation.isEmpty()) { + if (visibleConversation.isEmpty() && !thinking) { item { Text( text = stringResource(R.string.no_live_conversation), @@ -726,13 +765,46 @@ private fun ThreadVoiceMode( ) } } else { - conversation - .takeLast(VISIBLE_REALTIME_ENTRY_COUNT) - .forEach { entry -> - item(key = entry.id) { - RealtimeTalkBubble(entry) - } + visibleConversation.forEach { entry -> + item(key = entry.id) { + RealtimeTalkBubble(entry) } + } + if (thinking) { + item(key = "realtime-thinking") { + WearThreadThinking() + } + } + // Follow a trailing anchor: centering a growing bubble can hide its newly streamed tail. + item(key = "realtime-thread-end") { + Spacer(modifier = Modifier.height(1.dp)) + } + } + } + if (followState.hasNewContent) { + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 44.dp) + .background(colors.voiceAccentSoft, RoundedCornerShape(14.dp)) + .border(1.dp, colors.voiceAccent, RoundedCornerShape(14.dp)) + .clickable(role = Role.Button) { + followState = wearThreadFollowLatest(followState) + if (latestAnchorIndex >= 0) { + coroutineScope.launch { + listState.animateScrollToItem(latestAnchorIndex) + } + } + }.padding(horizontal = 10.dp, vertical = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "${stringResource(R.string.new_messages)} ↓", + color = colors.voiceAccent, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + ) } } Row( @@ -796,6 +868,107 @@ private fun ThreadVoiceMode( } } +@Composable +private fun WearThreadThinking() { + val colors = OpenClawWearTheme.colors + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 28.dp) + .background(colors.surfaceRaised, RoundedCornerShape(14.dp)) + .border(1.dp, colors.borderStrong, RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = "${stringResource(R.string.thinking)}…", + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +internal data class WearThreadContentRevision( + val entryCount: Int, + val latestEntryId: String?, + val latestText: String?, + val latestStreaming: Boolean, + val thinking: Boolean, +) + +internal data class WearThreadFollowState( + val contentRevision: WearThreadContentRevision? = null, + val followingLatest: Boolean = true, + val hasNewContent: Boolean = false, +) + +internal data class WearThreadFollowUpdate( + val state: WearThreadFollowState, + val scrollToLatest: Boolean, +) + +private data class WearThreadViewport( + val atLatest: Boolean, + val scrollingBackward: Boolean, +) + +internal fun wearThreadContentRevision( + conversation: List, + thinking: Boolean, +): WearThreadContentRevision { + val latest = conversation.lastOrNull() + return WearThreadContentRevision( + entryCount = conversation.size, + latestEntryId = latest?.id, + latestText = latest?.text, + latestStreaming = latest?.streaming == true, + thinking = thinking, + ) +} + +internal fun wearThreadLatestAnchorIndex( + entryCount: Int, + thinking: Boolean, +): Int = if (entryCount == 0 && !thinking) -1 else entryCount + if (thinking) 1 else 0 + +internal fun nextWearThreadFollowForContent( + state: WearThreadFollowState, + contentRevision: WearThreadContentRevision, + realtimeActive: Boolean = true, +): WearThreadFollowUpdate { + if (!realtimeActive) { + return WearThreadFollowUpdate( + state = WearThreadFollowState(), + scrollToLatest = false, + ) + } + if (state.contentRevision == contentRevision) { + return WearThreadFollowUpdate(state = state, scrollToLatest = false) + } + return WearThreadFollowUpdate( + state = + state.copy( + contentRevision = contentRevision, + hasNewContent = !state.followingLatest, + ), + scrollToLatest = state.followingLatest, + ) +} + +internal fun nextWearThreadFollowForViewport( + state: WearThreadFollowState, + atLatest: Boolean, + scrollingBackward: Boolean, +): WearThreadFollowState = + when { + atLatest -> state.copy(followingLatest = true, hasNewContent = false) + scrollingBackward -> state.copy(followingLatest = false) + else -> state + } + +internal fun wearThreadFollowLatest(state: WearThreadFollowState): WearThreadFollowState = state.copy(followingLatest = true, hasNewContent = false) + @Composable private fun VoiceOrb( accent: Color, diff --git a/apps/android/wear/src/main/res/values/strings.xml b/apps/android/wear/src/main/res/values/strings.xml index 16d000fd9633..7c9f556de3d6 100644 --- a/apps/android/wear/src/main/res/values/strings.xml +++ b/apps/android/wear/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ Tap Double tap Start Live to see the conversation here. + New Type Message Message agent diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt index 803987893134..99b84fccbbcb 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt @@ -4,7 +4,9 @@ import ai.openclaw.wear.shared.WearRealtimeTalkEntry import ai.openclaw.wear.shared.WearRealtimeTalkRole import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class MainActivityTest { @@ -53,6 +55,119 @@ class MainActivityTest { ) } + @Test + fun threadFollowKeepsStreamingContentVisibleAtLatest() { + val first = + nextWearThreadFollowForContent( + state = WearThreadFollowState(), + contentRevision = threadRevision(text = "Hel", streaming = true), + ) + val continued = + nextWearThreadFollowForContent( + state = first.state, + contentRevision = threadRevision(text = "Hello", streaming = true), + ) + + assertTrue(first.scrollToLatest) + assertTrue(continued.scrollToLatest) + assertTrue(continued.state.followingLatest) + assertFalse(continued.state.hasNewContent) + } + + @Test + fun threadFollowTargetsTrailingAnchorAfterLatestContent() { + assertEquals(-1, wearThreadLatestAnchorIndex(entryCount = 0, thinking = false)) + assertEquals(1, wearThreadLatestAnchorIndex(entryCount = 1, thinking = false)) + assertEquals(3, wearThreadLatestAnchorIndex(entryCount = 2, thinking = true)) + } + + @Test + fun threadFollowPreservesManualScrollUntilLatestIsRequested() { + val initial = + nextWearThreadFollowForContent( + state = WearThreadFollowState(), + contentRevision = threadRevision(text = "First", streaming = false), + ) + val scrolledBack = + nextWearThreadFollowForViewport( + state = initial.state, + atLatest = false, + scrollingBackward = true, + ) + val newContent = + nextWearThreadFollowForContent( + state = scrolledBack, + contentRevision = threadRevision(text = "Second", streaming = false), + ) + + assertFalse(newContent.scrollToLatest) + assertFalse(newContent.state.followingLatest) + assertTrue(newContent.state.hasNewContent) + + val latest = wearThreadFollowLatest(newContent.state) + assertTrue(latest.followingLatest) + assertFalse(latest.hasNewContent) + } + + @Test + fun threadFollowClearsNewContentWhenUserScrollsToLatest() { + val away = + WearThreadFollowState( + followingLatest = false, + hasNewContent = true, + ) + + val latest = + nextWearThreadFollowForViewport( + state = away, + atLatest = true, + scrollingBackward = false, + ) + + assertTrue(latest.followingLatest) + assertFalse(latest.hasNewContent) + } + + @Test + fun threadFollowResetsWhenRealtimeStops() { + val revision = threadRevision(text = "Old", streaming = false) + val away = + WearThreadFollowState( + contentRevision = revision, + followingLatest = false, + hasNewContent = true, + ) + val stopped = + nextWearThreadFollowForContent( + state = away, + contentRevision = revision, + realtimeActive = false, + ) + + assertFalse(stopped.scrollToLatest) + assertTrue(stopped.state.followingLatest) + assertFalse(stopped.state.hasNewContent) + + val restarted = + nextWearThreadFollowForContent( + state = stopped.state, + contentRevision = revision, + ) + assertTrue(restarted.scrollToLatest) + } + + private fun threadRevision( + text: String, + streaming: Boolean, + ): WearThreadContentRevision = + WearThreadContentRevision( + entryCount = 1, + latestEntryId = "entry-1", + latestText = text, + latestStreaming = streaming, + thinking = false, + ) + private fun realtimeSnapshot(entryStreaming: Boolean): WearConversationSnapshot = WearConversationSnapshot( gatewayState = WearGatewayState.CONNECTED,