fix(android): improve Wear thread follow behavior (#111459)

* improve(android): move Wear agent, session, and model pickers to Home

* fix(android): improve Wear thread follow behavior

* fix(android): stabilize Wear thread follow lifecycle

* fix(android): keep streamed Wear tail visible

Co-authored-by: JARVIS-Glasses <whatsskilll@gmail.com>

---------

Co-authored-by: Colin <colin@solvely.net>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
WhatsSkiLL
2026-07-19 19:54:10 +02:00
committed by GitHub
parent 8e0c50380c
commit 50ce5dedea
3 changed files with 296 additions and 7 deletions

View File

@@ -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<WearRealtimeTalkEntry>,
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<WearRealtimeTalkEntry>,
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,

View File

@@ -17,6 +17,7 @@
<string name="tap">Tap</string>
<string name="double_tap">Double tap</string>
<string name="no_live_conversation">Start Live to see the conversation here.</string>
<string name="new_messages">New</string>
<string name="type">Type</string>
<string name="message">Message</string>
<string name="message_agent">Message agent</string>

View File

@@ -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,