From 96f559abbb37c0a8f313549d753fb693166b4917 Mon Sep 17 00:00:00 2001 From: Kohei Date: Wed, 18 Feb 2026 22:17:46 +0900 Subject: [PATCH] feat: auto-scroll chat on new messages Scroll to bottom when sending a message, and conditionally scroll on received messages when the user is already near the latest messages. Co-Authored-By: Claude Opus 4.6 --- .../android/feature/chat/ChatViewModel.kt | 6 ++++++ .../android/feature/chat/screen/ChatScreen.kt | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt index 200f622a5..381a41c10 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt @@ -8,8 +8,10 @@ import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase import io.picoclaw.android.core.domain.usecase.SendMessageUseCase +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -26,6 +28,9 @@ class ChatViewModel( private val _uiState = MutableStateFlow(ChatUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _scrollToBottom = MutableSharedFlow(extraBufferCapacity = 1) + val scrollToBottom = _scrollToBottom.asSharedFlow() + init { connectChat() @@ -51,6 +56,7 @@ class ChatViewModel( val state = _uiState.value val text = state.inputText.trim() if (text.isEmpty() && state.pendingImages.isEmpty()) return + _scrollToBottom.tryEmit(Unit) _uiState.update { it.copy(inputText = "", pendingImages = emptyList()) } viewModelScope.launch { try { diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt index f903d8e12..ae6003d09 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt @@ -99,6 +99,22 @@ fun ChatScreen( if (shouldLoadMore) viewModel.onEvent(ChatEvent.OnLoadMore) } + LaunchedEffect(Unit) { + viewModel.scrollToBottom.collect { + listState.animateScrollToItem(0) + } + } + + val isNearBottom by remember { + derivedStateOf { listState.firstVisibleItemIndex <= 3 } + } + + LaunchedEffect(uiState.messages.size) { + if (isNearBottom) { + listState.animateScrollToItem(0) + } + } + Scaffold( topBar = { TopAppBar(title = { Text("PicoClaw") })