mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 17:41:35 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user