From 1432369e1a46a536a6a49a3fbbc1adaf7c371297 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 10:52:16 -0400 Subject: [PATCH] fix(chat): redraw a typing avatar when the typist's profile resolves A group typist whose profile hadn't loaded yet kept drawing the gradient fallback after the profile arrived. ChatViewModel emits the typist with a null profile first and again once the profile resolves, under the same key. AvatarStack only copied new values into its entries from the effect keyed on the set of keys, so the second emission never reached the avatar. A second effect keyed on the typist list now refreshes each entry that is still shown. The key-driven effect keeps its key, since restarting it would cancel the coroutine that removes a leaving avatar. --- .../ui/components/chat/TypingIndicator.kt | 10 +++++ .../ui/components/chat/TypingIndicatorTest.kt | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 ui/components/src/test/kotlin/com/getcode/ui/components/chat/TypingIndicatorTest.kt diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt index a311cb38d3..e727005c72 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/TypingIndicator.kt @@ -152,6 +152,16 @@ private fun AvatarStack( } } + // A typist can change without changing key, such as when their profile resolves after they + // started typing. The effect above only runs when the keys change, so without this the avatar + // keeps drawing the typist as they first arrived. + LaunchedEffect(shown) { + shown.forEach { item -> + val itemKey = key(item) + entries.firstOrNull { it.key == itemKey && !it.leaving }?.item = item + } + } + // Stacking order: later slots on top, and a leaving avatar beneath everything, so the ones // that close the gap slide over it. fun StackEntry.z(): Float = if (leaving) -1f else slot.value diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/chat/TypingIndicatorTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/chat/TypingIndicatorTest.kt new file mode 100644 index 0000000000..b92ab163fe --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/chat/TypingIndicatorTest.kt @@ -0,0 +1,45 @@ +package com.getcode.ui.components.chat + +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TypingIndicatorTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private data class Typist(val id: String, val name: String?) + + // A typist can appear before their profile resolves, and the resolved profile arrives as the + // same typist (same key) with more to draw. The avatar has to pick that up. + @Test + fun avatarRedrawsWhenATypistChangesUnderTheSameKey() { + var typists by mutableStateOf(listOf(Typist("a", name = null))) + composeTestRule.setContent { + DesignSystem { + TypingIndicator( + typists = typists, + key = { it.id }, + avatar = { Text(it.name ?: "fallback") }, + ) + } + } + composeTestRule.onNodeWithText("fallback").assertExists() + + typists = listOf(Typist("a", name = "Alice")) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Alice").assertExists() + composeTestRule.onNodeWithText("fallback").assertDoesNotExist() + } +}