Skip to content

Commit 68dfaf1

Browse files
author
Claude Fix
committed
fix: correct chat bubble count and ordering across all backends
Root cause (WebView, takes effect immediately, no rebuild needed): sendMessage() created the pending model/spinner bubble synchronously via addModelBubble('', true) right after calling Bridge.sendMessage()/ sendMessageWithImages(). Those Android bridge calls dispatch to the UI thread asynchronously, so the spinner bubble was always inserted into the DOM before the native onUserMessage/onAiMessage callbacks could fire - making the spinner appear in its own bubble above the user's message instead of below it. The pending model bubble is now created exclusively by the native onAiMessage('', true) callback once generation actually starts. Native ordering bugs (require a rebuild to take effect): - reasonWithCerebras / reasonWithMistral set _uiState = Loading before appending the user message to chatMessagesFlow, so onAiMessage fired before onUserMessage - same spinner-above-user-bubble bug at the native level for these two backends. - HUMAN_EXPERT branch in reason(): same ordering issue, and the 'already connected' path (postTaskToHumanExpert) never updated chatMessagesFlow at all, so a follow-up message while already connected never appeared as a bubble. - MainActivity's chatMessagesFlow collector compared the last user message by text content to decide whether to call onUserMessage. Sending the exact same text twice in a row was therefore silently swallowed (no second bubble at all). Now compares by the message's unique id instead, so every distinct message always gets its bubble.
1 parent 06765dd commit 68dfaf1

3 files changed

Lines changed: 18 additions & 9 deletions

File tree

app/src/main/kotlin/com/google/ai/sample/MainActivity.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,16 +1717,16 @@ class MainActivity : ComponentActivity() {
17171717
// Observe chat messages so the WebView user bubble is updated with the full message text
17181718
// (including screen elements and Termux output) once native code has assembled it.
17191719
// The WebView's sendMessage() adds the bubble with only the typed text; this corrects it.
1720-
var lastObservedUserText = ""
1720+
var lastObservedUserMessageId: String? = null
17211721
lifecycleScope.launch {
17221722
vm.chatMessagesFlow.collect { messages ->
17231723
val lastUser = messages.lastOrNull {
17241724
it.participant == com.google.ai.sample.feature.multimodal.PhotoParticipant.USER && !it.isPending
17251725
}
1726-
val fullText = lastUser?.text ?: return@collect
1727-
if (fullText != lastObservedUserText) {
1728-
lastObservedUserText = fullText
1729-
val escaped = escapeForJs(fullText)
1726+
if (lastUser == null) return@collect
1727+
if (lastUser.id != lastObservedUserMessageId) {
1728+
lastObservedUserMessageId = lastUser.id
1729+
val escaped = escapeForJs(lastUser.text)
17301730
wv.post {
17311731
wv.evaluateJavascript("window.onUserMessage && window.onUserMessage('$escaped')", null)
17321732
}

app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningViewModel.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,8 @@ class PhotoReasoningViewModel(
834834
isPending = false
835835
)
836836
_chatState.addMessage(userMessage)
837-
837+
_chatMessagesFlow.value = _chatState.getAllMessages()
838+
838839
_uiState.value = PhotoReasoningUiState.Loading
839840

840841
// We need to ensure we have MediaProjection permission.
@@ -1185,7 +1186,6 @@ class PhotoReasoningViewModel(
11851186
@Suppress("UNUSED_PARAMETER") selectedImages: List<Bitmap>,
11861187
screenInfoForPrompt: String? = null
11871188
) {
1188-
_uiState.value = PhotoReasoningUiState.Loading
11891189
val context = appContext
11901190
val apiKeyManager = ApiKeyManager.getInstance(context)
11911191

@@ -1205,6 +1205,8 @@ class PhotoReasoningViewModel(
12051205
)
12061206
)
12071207

1208+
_uiState.value = PhotoReasoningUiState.Loading
1209+
12081210
resetStreamingCommandState()
12091211

12101212
viewModelScope.launch(Dispatchers.IO) {
@@ -1311,7 +1313,6 @@ class PhotoReasoningViewModel(
13111313
TAG,
13121314
"reasonWithMistral: start, images=${selectedImages.size}, screenInfo=${!screenInfoForPrompt.isNullOrBlank()}, chatSize=${_chatState.getAllMessages().size}"
13131315
)
1314-
_uiState.value = PhotoReasoningUiState.Loading
13151316
_showStopNotificationFlow.value = true
13161317
val context = appContext
13171318
val apiKeyManager = ApiKeyManager.getInstance(context)
@@ -1334,6 +1335,8 @@ class PhotoReasoningViewModel(
13341335
)
13351336
)
13361337

1338+
_uiState.value = PhotoReasoningUiState.Loading
1339+
13371340
resetStreamingCommandState()
13381341

13391342
currentReasoningJob?.cancel()

index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4199,7 +4199,13 @@
41994199
selectedImages.forEach(img => { if (img.objectUrl) URL.revokeObjectURL(img.objectUrl); });
42004200
selectedImages = [];
42014201
renderImagePreviews();
4202-
addModelBubble('', true);
4202+
// NOTE: do NOT create the pending model bubble here. Bridge.sendMessage /
4203+
// Bridge.sendMessageWithImages return immediately (the Android side dispatches to
4204+
// the UI thread asynchronously), so any bubble created synchronously at this point
4205+
// would always land in the DOM before the native onUserMessage/onAiMessage callbacks
4206+
// fire — showing the spinner above the user's own message. The pending model bubble
4207+
// is created by window.onAiMessage('', true) once Android actually starts generating,
4208+
// which is guaranteed to happen after onUserMessage has added the user bubble.
42034209
window.onGenerationStateChanged(true, false);
42044210
}
42054211

0 commit comments

Comments
 (0)