fix(android): keep Wear chat on the latest reply (#120307)

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
This commit is contained in:
WhatsSkiLL
2026-08-07 22:32:30 +02:00
committed by GitHub
parent ac2994a0e2
commit 62937ea6fc
2 changed files with 288 additions and 93 deletions
@@ -312,92 +312,161 @@ private fun ChatPage(
onSpeakLatest: () -> Unit,
onStopSpeaking: () -> Unit,
) {
val colors = OpenClawWearTheme.colors
WearPage(pageLabel = stringResource(R.string.chat)) {
item {
ConversationIdentity(
snapshot = snapshot,
actionBusy = actionBusy,
onSelectAgent = onSelectAgent,
onSelectSession = onSelectSession,
onSelectModel = onSelectModel,
val listState = rememberTransformingLazyColumnState()
val coroutineScope = rememberCoroutineScope()
val visibleMessages = snapshot.messages.takeLast(VISIBLE_MESSAGE_COUNT)
val streamingText = snapshot.streamingAssistantText?.takeIf(String::isNotBlank)
val hasAssistant = snapshot.messages.any { message -> message.chatRole == WearChatRole.ASSISTANT }
val latestAnchorIndex =
wearChatLatestAnchorIndex(
visibleMessageCount = visibleMessages.size,
hasStreaming = streamingText != null,
canAbort = canAbort,
hasAssistant = hasAssistant,
hasFailure = snapshot.failure != null,
)
val contentRevision =
wearChatContentRevision(
sessionId = snapshot.activeSessionId,
messages = visibleMessages,
streamingText = streamingText,
latestAnchorIndex = latestAnchorIndex,
)
var followState by remember(snapshot.activeSessionId) { mutableStateOf(WearThreadFollowState()) }
LaunchedEffect(listState, snapshot.activeSessionId) {
snapshotFlow {
WearThreadViewport(
atLatest = !listState.canScrollForward,
scrollingBackward = listState.isScrollInProgress && listState.lastScrolledBackward,
)
}.collect { viewport ->
followState =
nextWearThreadFollowForViewport(
state = followState,
atLatest = viewport.atLatest,
scrollingBackward = viewport.scrollingBackward,
)
}
item {
ConversationStatus(
interaction = interaction,
speaking = speaking,
gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED,
}
LaunchedEffect(snapshot.activeSessionId, contentRevision) {
val update =
nextWearThreadFollowForContent(
state = followState,
contentRevision = contentRevision,
)
followState = update.state
if (update.scrollToLatest && latestAnchorIndex >= 0) {
listState.requestScrollToItem(latestAnchorIndex)
}
item {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ActionButton(
label = stringResource(R.string.talk),
enabled = inputEnabled && !actionBusy && !speaking,
onClick = onTalk,
modifier = Modifier.weight(1f),
)
ActionButton(
label = stringResource(R.string.type),
enabled = inputEnabled && !actionBusy && !speaking,
onClick = onType,
modifier = Modifier.weight(1f),
)
}
}
if (canAbort) {
}
Box(modifier = Modifier.fillMaxSize()) {
WearPage(
pageLabel = stringResource(R.string.chat),
listState = listState,
) {
item {
SecondaryButton(
label = stringResource(R.string.abort_run),
enabled = true,
onClick = onAbort,
ConversationIdentity(
snapshot = snapshot,
actionBusy = actionBusy,
onSelectAgent = onSelectAgent,
onSelectSession = onSelectSession,
onSelectModel = onSelectModel,
)
}
}
if (snapshot.messages.isEmpty() && snapshot.streamingAssistantText.isNullOrBlank()) {
item {
EmptyConversation()
ConversationStatus(
interaction = interaction,
speaking = speaking,
gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED,
)
}
} else {
snapshot.messages
.takeLast(VISIBLE_MESSAGE_COUNT)
.forEach { message ->
item {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ActionButton(
label = stringResource(R.string.talk),
enabled = inputEnabled && !actionBusy && !speaking,
onClick = onTalk,
modifier = Modifier.weight(1f),
)
ActionButton(
label = stringResource(R.string.type),
enabled = inputEnabled && !actionBusy && !speaking,
onClick = onType,
modifier = Modifier.weight(1f),
)
}
}
if (canAbort) {
item {
SecondaryButton(
label = stringResource(R.string.abort_run),
enabled = true,
onClick = onAbort,
)
}
}
if (visibleMessages.isEmpty() && streamingText == null) {
item {
EmptyConversation()
}
} else {
visibleMessages.forEach { message ->
item(key = message.id ?: "${message.role}:${message.timestamp}:${message.text.hashCode()}") {
MessageBubble(message = message)
}
}
snapshot.streamingAssistantText
?.takeIf(String::isNotBlank)
?.let { streaming ->
streamingText?.let { streaming ->
item {
StreamingBubble(text = streaming)
}
}
}
if (snapshot.messages.any { message -> message.chatRole == WearChatRole.ASSISTANT }) {
item {
SecondaryButton(
label =
if (speaking) {
stringResource(R.string.stop_speaking)
} else {
stringResource(R.string.speak_reply)
},
enabled = !actionBusy || speaking,
onClick = if (speaking) onStopSpeaking else onSpeakLatest,
)
}
if (hasAssistant) {
item {
SecondaryButton(
label =
if (speaking) {
stringResource(R.string.stop_speaking)
} else {
stringResource(R.string.speak_reply)
},
enabled = !actionBusy || speaking,
onClick = if (speaking) onStopSpeaking else onSpeakLatest,
)
}
}
snapshot.failure?.let { failure ->
item {
InlineError(text = failureDetail(failure))
}
}
if (latestAnchorIndex >= 0) {
item(key = "chat-end") {
Spacer(modifier = Modifier.height(1.dp))
}
}
}
snapshot.failure?.let { failure ->
item {
InlineError(text = failureDetail(failure))
if (followState.hasNewContent) {
NewMessagesAction(
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 8.dp),
) {
followState = wearThreadFollowLatest(followState)
if (latestAnchorIndex >= 0) {
coroutineScope.launch {
listState.animateScrollToItem(latestAnchorIndex)
}
}
}
}
}
@@ -870,33 +939,18 @@ private fun ThreadVoiceMode(
}
}
if (followState.hasNewContent) {
Box(
NewMessagesAction(
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 44.dp)
.minimumInteractiveComponentSize()
.background(colors.voiceAccentSoft, RoundedCornerShape(14.dp))
.border(1.dp, colors.voiceAccent, RoundedCornerShape(14.dp))
.clickable(
role = Role.Button,
onClickLabel = stringResource(R.string.show_new_messages),
) {
followState = wearThreadFollowLatest(followState)
if (latestAnchorIndex >= 0) {
coroutineScope.launch {
listState.animateScrollToItem(latestAnchorIndex)
}
}
}.padding(horizontal = 10.dp, vertical = 5.dp),
contentAlignment = Alignment.Center,
.padding(bottom = 44.dp),
) {
Text(
text = stringResource(R.string.new_messages) + "",
color = colors.voiceAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
)
followState = wearThreadFollowLatest(followState)
if (latestAnchorIndex >= 0) {
coroutineScope.launch {
listState.animateScrollToItem(latestAnchorIndex)
}
}
}
}
Row(
@@ -964,6 +1018,34 @@ private fun ThreadVoiceMode(
}
}
@Composable
private fun NewMessagesAction(
modifier: Modifier,
onClick: () -> Unit,
) {
val colors = OpenClawWearTheme.colors
Box(
modifier =
modifier
.minimumInteractiveComponentSize()
.background(colors.voiceAccentSoft, RoundedCornerShape(14.dp))
.border(1.dp, colors.voiceAccent, RoundedCornerShape(14.dp))
.clickable(
role = Role.Button,
onClickLabel = stringResource(R.string.show_new_messages),
onClick = onClick,
).padding(horizontal = 10.dp, vertical = 5.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.new_messages) + "",
color = colors.voiceAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun WearThreadThinking() {
val colors = OpenClawWearTheme.colors
@@ -1023,6 +1105,48 @@ internal fun wearThreadContentRevision(
)
}
internal fun wearChatContentRevision(
sessionId: String?,
messages: List<WearChatMessage>,
streamingText: String?,
latestAnchorIndex: Int,
): WearThreadContentRevision =
WearThreadContentRevision(
entryCount = messages.size,
latestEntryId = sessionId,
latestText =
buildString {
append(latestAnchorIndex)
messages.forEach { message ->
append('\u0000')
append(message.id)
append('\u0001')
append(message.role)
append('\u0001')
append(message.timestamp)
append('\u0001')
append(message.text)
}
append('\u0000')
append(streamingText)
},
latestStreaming = !streamingText.isNullOrBlank(),
thinking = false,
)
internal fun wearChatLatestAnchorIndex(
visibleMessageCount: Int,
hasStreaming: Boolean,
canAbort: Boolean,
hasAssistant: Boolean,
hasFailure: Boolean,
): Int {
if (visibleMessageCount == 0 && !hasStreaming) return -1
return CHAT_FIXED_ITEM_COUNT +
visibleMessageCount +
listOf(canAbort, hasStreaming, hasAssistant, hasFailure).count { it }
}
internal fun wearThreadLatestAnchorIndex(
entryCount: Int,
thinking: Boolean,
@@ -1353,17 +1477,18 @@ private fun ConnectionStateScreen(
@Composable
private fun WearPage(
pageLabel: String,
listState: androidx.wear.compose.foundation.lazy.TransformingLazyColumnState? = null,
content: androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope.() -> Unit,
) {
val colors = OpenClawWearTheme.colors
val listState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = listState) { contentPadding ->
val resolvedListState = listState ?: rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = resolvedListState) { contentPadding ->
TransformingLazyColumn(
modifier =
Modifier
.fillMaxSize()
.background(colors.canvas),
state = listState,
state = resolvedListState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -2062,5 +2187,6 @@ private fun failureDetail(failure: WearConversationFailure?): String =
-> stringResource(R.string.try_again)
}
private const val CHAT_FIXED_ITEM_COUNT = 4
private const val VISIBLE_MESSAGE_COUNT = 8
private const val VISIBLE_REALTIME_ENTRY_COUNT = 6
@@ -76,6 +76,75 @@ class MainActivityTest {
assertFalse(continued.state.hasNewContent)
}
@Test
fun chatFollowTracksStreamingGrowthAtLatest() {
val messages = listOf(WearChatMessage(id = "user-1", role = "user", text = "Status?", timestamp = 1L))
val anchor = wearChatLatestAnchorIndex(1, hasStreaming = true, canAbort = false, hasAssistant = false, hasFailure = false)
val first =
nextWearThreadFollowForContent(
state = WearThreadFollowState(),
contentRevision = wearChatContentRevision("session-1", messages, "Read", anchor),
)
val continued =
nextWearThreadFollowForContent(
state = first.state,
contentRevision = wearChatContentRevision("session-1", messages, "Ready", anchor),
)
assertTrue(first.scrollToLatest)
assertTrue(continued.scrollToLatest)
assertTrue(continued.state.followingLatest)
assertFalse(continued.state.hasNewContent)
}
@Test
fun chatFollowPreservesManualScrollForNewContent() {
val firstMessage = WearChatMessage(id = "message-1", role = "assistant", text = "First", timestamp = 1L)
val initial =
nextWearThreadFollowForContent(
state = WearThreadFollowState(),
contentRevision = wearChatContentRevision("session-1", listOf(firstMessage), null, 6),
)
val scrolledBack =
nextWearThreadFollowForViewport(
state = initial.state,
atLatest = false,
scrollingBackward = true,
)
val newContent =
nextWearThreadFollowForContent(
state = scrolledBack,
contentRevision =
wearChatContentRevision(
"session-1",
listOf(firstMessage, WearChatMessage(id = "message-2", role = "assistant", text = "Second", timestamp = 2L)),
null,
7,
),
)
assertFalse(newContent.scrollToLatest)
assertFalse(newContent.state.followingLatest)
assertTrue(newContent.state.hasNewContent)
assertTrue(wearThreadFollowLatest(newContent.state).followingLatest)
}
@Test
fun chatFollowTargetsRenderedTrailingAnchor() {
assertEquals(
-1,
wearChatLatestAnchorIndex(0, hasStreaming = false, canAbort = false, hasAssistant = false, hasFailure = true),
)
assertEquals(
5,
wearChatLatestAnchorIndex(1, hasStreaming = false, canAbort = false, hasAssistant = false, hasFailure = false),
)
assertEquals(
10,
wearChatLatestAnchorIndex(2, hasStreaming = true, canAbort = true, hasAssistant = true, hasFailure = true),
)
}
@Test
fun threadFollowTargetsTrailingAnchorAfterLatestContent() {
assertEquals(-1, wearThreadLatestAnchorIndex(entryCount = 0, thinking = false))